---
phase: 6
plan: "06-01"
title: "CombinedWebSocketStream struct + envelope parsing + demux"
wave: 1
depends_on: []
files_modified:
  - crates/opendeviationbar-providers/src/binance/combined_ws.rs
  - crates/opendeviationbar-providers/src/binance/mod.rs
requirements:
  - COMB-02
  - COMB-03
autonomous: true
---

# Plan 06-01: CombinedWebSocketStream + Envelope Parsing + Demux

## Task 1: Create `CombinedWebSocketStream` struct with combined URL construction

<read_first>

- crates/opendeviationbar-providers/src/binance/websocket.rs (full file — reuse BinanceAggTrade, ReconnectionPolicy, WebSocketError, build_backoff, Antaeus reconnection pattern)
- crates/opendeviationbar-providers/src/binance/mod.rs (module exports)
- crates/opendeviationbar-providers/Cargo.toml (dependencies available: tokio-tungstenite, serde_json, backon, tracing)
  </read_first>

<acceptance_criteria>

- grep -q "pub struct CombinedWebSocketStream" crates/opendeviationbar-providers/src/binance/combined_ws.rs
- grep -q "stream.binance.com:9443/stream?streams=" crates/opendeviationbar-providers/src/binance/combined_ws.rs
- grep -q "pub mod combined_ws" crates/opendeviationbar-providers/src/binance/mod.rs
- grep -q "CombinedWebSocketStream" crates/opendeviationbar-providers/src/binance/mod.rs (re-export)
- cargo check -p opendeviationbar-providers succeeds
  </acceptance_criteria>

<action>
Create `crates/opendeviationbar-providers/src/binance/combined_ws.rs` with:

1. **Envelope struct** for combined stream parsing:

```rust
#[derive(Debug, Deserialize)]
struct CombinedEnvelope {
    stream: String,      // e.g. "btcusdt@aggTrade"
    data: BinanceAggTrade, // reuse existing struct from websocket.rs
}
```

Note: `BinanceAggTrade` must be made `pub(crate)` in websocket.rs (currently private).

1. **URL builder function**:

```rust
fn build_combined_url(symbols: &[String]) -> String {
    let streams: Vec<String> = symbols.iter()
        .map(|s| format!("{}@aggTrade", s.to_lowercase()))
        .collect();
    format!("wss://stream.binance.com:9443/stream?streams={}", streams.join("/"))
}
```

For 18 symbols: `wss://stream.binance.com:9443/stream?streams=btcusdt@aggTrade/ethusdt@aggTrade/...`

1. **`CombinedWebSocketStream` struct**:

```rust
pub struct CombinedWebSocketStream {
    symbols: Vec<String>,
}
```

1. **`connect_and_demux()` static method** — connects to combined URL, parses envelopes, routes to per-symbol senders:

```rust
pub async fn connect_and_demux(
    symbols: &[String],
    senders: &HashMap<String, mpsc::Sender<AggTrade>>,
    shutdown: &CancellationToken,
    data_timeout: Duration,
) -> Result<(), WebSocketError>
```

Core loop mirrors `BinanceWebSocketStream::connect_and_stream()` but:

- Parses `CombinedEnvelope` instead of raw `BinanceAggTrade`
- Extracts symbol from `envelope.stream` by splitting on `@` (e.g. `"btcusdt@aggTrade"` -> `"BTCUSDT"`)
- Looks up `senders[&symbol_upper]` and sends `envelope.data.to_agg_trade()`
- If symbol not found in senders map, log warning and skip (handles MATICUSDT-like cases)
- Ping/pong handling identical to existing per-symbol code

1. **`run_with_reconnect()` static method** — Antaeus reconnection loop reusing same pattern from `BinanceWebSocketStream::run_with_reconnect()`:

```rust
pub async fn run_with_reconnect(
    symbols: Vec<String>,
    senders: HashMap<String, mpsc::Sender<AggTrade>>,
    policy: ReconnectionPolicy,
    shutdown: CancellationToken,
)
```

Same stable_threshold / backoff reset / terminal vs recoverable error classification.

1. **Make `BinanceAggTrade` visible** — change from `struct BinanceAggTrade` to `pub(crate) struct BinanceAggTrade` in websocket.rs, and add `pub(crate)` to `to_agg_trade()`.

2. **Register module** in `mod.rs`:

```rust
pub mod combined_ws;
pub use combined_ws::CombinedWebSocketStream;
```

</action>

## Task 2: Unit tests for envelope parsing and URL construction

<read_first>

- crates/opendeviationbar-providers/src/binance/websocket.rs (test patterns: test_binance_aggtrade_conversion, test_ws_url_format)
  </read_first>

<acceptance_criteria>

- grep -q "test_combined_envelope_parsing" crates/opendeviationbar-providers/src/binance/combined_ws.rs
- grep -q "test_combined_url_construction" crates/opendeviationbar-providers/src/binance/combined_ws.rs
- grep -q "test_envelope_unknown_symbol_skipped" crates/opendeviationbar-providers/src/binance/combined_ws.rs
- cargo nextest run -p opendeviationbar-providers -- combined_ws succeeds with 3+ tests passing
  </acceptance_criteria>

<action>
Add `#[cfg(test)] mod tests` at bottom of combined_ws.rs with:

1. **`test_combined_envelope_parsing`** — parse real Binance combined stream JSON:

```rust
let json = r#"{"stream":"btcusdt@aggTrade","data":{"e":"aggTrade","E":1758666334424,"s":"BTCUSDT","a":3679713739,"p":"112070.01000000","q":"0.01328000","f":5252203256,"l":5252203266,"T":1758666334424,"m":false,"M":true}}"#;
let envelope: CombinedEnvelope = serde_json::from_str(json).unwrap();
assert_eq!(envelope.stream, "btcusdt@aggTrade");
let trade = envelope.data.to_agg_trade();
assert_eq!(trade.agg_trade_id, 3679713739);
assert_eq!(trade.price.0, 11207001000000);
```

1. **`test_combined_url_construction`** — verify URL format for 3 symbols:

```rust
let symbols = vec!["BTCUSDT".into(), "ETHUSDT".into(), "SOLUSDT".into()];
let url = build_combined_url(&symbols);
assert_eq!(url, "wss://stream.binance.com:9443/stream?streams=btcusdt@aggTrade/ethusdt@aggTrade/solusdt@aggTrade");
```

1. **`test_envelope_unknown_symbol_skipped`** — extract symbol from stream field, verify uppercase conversion:

```rust
let stream_field = "xyzusdt@aggTrade";
let symbol = stream_field.split('@').next().unwrap().to_uppercase();
assert_eq!(symbol, "XYZUSDT");
```

1. **`test_combined_url_18_symbols`** — verify all 18 symbols produce valid URL (no URL encoding issues):

```rust
let symbols: Vec<String> = ["BTCUSDT","ETHUSDT","SOLUSDT","ADAUSDT","XRPUSDT","DOGEUSDT","AVAXUSDT","DOTUSDT","LINKUSDT","UNIUSDT","ATOMUSDT","NEARUSDT","FILUSDT","LTCUSDT","MATICUSDT","SHIBUSDT","TRXUSDT","WIFUSDT"].iter().map(|s| s.to_string()).collect();
let url = build_combined_url(&symbols);
assert!(url.starts_with("wss://stream.binance.com:9443/stream?streams="));
assert_eq!(url.matches("@aggTrade").count(), 18);
```

</action>

## must_haves

- [ ] `CombinedEnvelope` struct deserializes `{"stream":"...","data":{...}}` format
- [ ] `build_combined_url()` produces `wss://stream.binance.com:9443/stream?streams=sym1@aggTrade/sym2@aggTrade/...`
- [ ] `connect_and_demux()` routes trades to correct per-symbol `mpsc::Sender<AggTrade>`
- [ ] `run_with_reconnect()` reuses Antaeus backoff pattern from `BinanceWebSocketStream`
- [ ] Unknown symbols in envelope are logged and skipped (no panic)
- [ ] `BinanceAggTrade` and `to_agg_trade()` made `pub(crate)` in websocket.rs
- [ ] Module registered and re-exported in mod.rs
- [ ] `cargo nextest run -p opendeviationbar-providers -- combined_ws` passes 4+ tests
