---
phase: 6
plan: "06-02"
title: "Integrate CombinedWebSocketStream into start_ws() — eliminate per-symbol connections"
wave: 2
depends_on:
  - "06-01"
files_modified:
  - crates/opendeviationbar-streaming/src/live_engine.rs
requirements:
  - COMB-04
  - COMB-06
autonomous: true
---

# Plan 06-02: Integration into start_ws() — Combined Streams Replace Per-Symbol

## Task 1: Replace per-symbol WS spawn in `start_ws()` with single `CombinedWebSocketStream`

<read_first>

- crates/opendeviationbar-streaming/src/live_engine.rs lines 300-340 (start_ws method — current per-symbol BinanceWebSocketStream spawn loop)
- crates/opendeviationbar-streaming/src/live_engine.rs lines 1260-1310 (symbol_task — pre_buffered_rx consumption, fallback WS creation)
- crates/opendeviationbar-providers/src/binance/combined_ws.rs (CombinedWebSocketStream::run_with_reconnect signature from 06-01)
- crates/opendeviationbar-streaming/Cargo.toml (verify opendeviationbar-providers dependency and features)
  </read_first>

<acceptance_criteria>

- grep -q "CombinedWebSocketStream" crates/opendeviationbar-streaming/src/live_engine.rs
- grep -qv "BinanceWebSocketStream::run_with_reconnect" crates/opendeviationbar-streaming/src/live_engine.rs (per-symbol WS no longer spawned in start_ws)
- grep -q "combined_ws" crates/opendeviationbar-streaming/src/live_engine.rs
- grep -q "started WS combined connection" crates/opendeviationbar-streaming/src/live_engine.rs (new log message)
- cargo check -p opendeviationbar-streaming succeeds
  </acceptance_criteria>

<action>
Modify `start_ws()` in `live_engine.rs` (lines ~311-337). Replace the per-symbol loop with a single combined connection.

**Current code** (to replace):

```rust
pub fn start_ws(&mut self) {
    if self.ws_trade_receivers.is_some() || self.started {
        return;
    }
    let mut receivers = HashMap::new();
    for symbol in &self.config.symbols {
        let symbol = symbol.to_uppercase();
        let (trade_tx, trade_rx) = mpsc::channel::<AggTrade>(50_000);
        let ws_shutdown = self.shutdown.clone();
        let ws_symbol = symbol.clone();
        let policy = self.config.reconnection_policy.clone();
        tokio::spawn(async move {
            BinanceWebSocketStream::run_with_reconnect(
                &ws_symbol, trade_tx, policy, ws_shutdown,
            ).await;
        });
        receivers.insert(symbol, trade_rx);
    }
    // ...
}
```

**New code:**

```rust
pub fn start_ws(&mut self) {
    if self.ws_trade_receivers.is_some() || self.started {
        return;
    }

    let mut receivers = HashMap::new();
    let mut senders = HashMap::new();

    // Create per-symbol channels — same capacity, same Receiver type as before
    for symbol in &self.config.symbols {
        let symbol = symbol.to_uppercase();
        let (trade_tx, trade_rx) = mpsc::channel::<AggTrade>(50_000);
        senders.insert(symbol.clone(), trade_tx);
        receivers.insert(symbol, trade_rx);
    }

    // Single combined WS connection for all symbols (1x18 — spike test winner)
    let symbols: Vec<String> = self.config.symbols.iter().map(|s| s.to_uppercase()).collect();
    let ws_shutdown = self.shutdown.clone();
    let policy = self.config.reconnection_policy.clone();
    tokio::spawn(async move {
        CombinedWebSocketStream::run_with_reconnect(
            symbols, senders, policy, ws_shutdown,
        ).await;
    });

    tracing::info!(
        symbols = self.config.symbols.len(),
        "started WS combined connection (1x18, capacity=50000) — trades buffering until start()"
    );
    self.ws_trade_receivers = Some(receivers);
}
```

Key properties preserved:

- `receivers` HashMap<String, mpsc::Receiver<AggTrade>> — same type, same keys
- Channel capacity 50,000 — unchanged
- `symbol_task()` receives `pre_buffered_rx: Option<mpsc::Receiver<AggTrade>>` — unchanged
- Shutdown via `CancellationToken` — unchanged

Add import at top of file:

```rust
use opendeviationbar_providers::binance::combined_ws::CombinedWebSocketStream;
```

</action>

## Task 2: Remove per-symbol fallback WS creation in `symbol_task()`

<read_first>

- crates/opendeviationbar-streaming/src/live_engine.rs lines 1291-1305 (fallback WS creation in symbol_task when no pre_buffered_rx)
  </read_first>

<acceptance_criteria>

- grep -q "combined stream requires pre-buffered receiver" crates/opendeviationbar-streaming/src/live_engine.rs
- cargo check -p opendeviationbar-streaming succeeds
  </acceptance_criteria>

<action>
In `symbol_task()` (around line 1293), the fallback `else` branch creates a per-symbol `BinanceWebSocketStream::run_with_reconnect`. Since all symbols now use the combined stream via `start_ws()`, the fallback should log an error and return instead of silently creating a per-symbol connection.

**Replace** the else branch (lines ~1296-1305):

```rust
    } else {
        let (trade_tx, rx) = mpsc::channel::<AggTrade>(50_000);
        let ws_shutdown = shutdown.clone();
        let ws_symbol = symbol.clone();
        tokio::spawn(async move {
            BinanceWebSocketStream::run_with_reconnect(&ws_symbol, trade_tx, policy, ws_shutdown)
                .await;
        });
        rx
    };
```

**With:**

```rust
    } else {
        // Combined stream requires start_ws() to be called first.
        // Per-symbol fallback removed — all symbols use combined WS (COMB-04).
        tracing::error!(%symbol, "combined stream requires pre-buffered receiver — call start_ws() before start()");
        return;
    };
```

This enforces the `start_ws() -> fill_from_rest() -> start()` call sequence. If someone skips `start_ws()`, they get a clear error instead of silently falling back to per-symbol connections.

Remove the now-unused `BinanceWebSocketStream` import if it was only used in this fallback path. Keep it if used elsewhere (check with grep first).
</action>

## Task 3: Verify compilation and run existing tests

<read_first>

- crates/opendeviationbar-streaming/Cargo.toml (test dependencies, features)
  </read_first>

<acceptance_criteria>

- cargo check -p opendeviationbar-streaming --features binance-integration succeeds
- cargo nextest run -p opendeviationbar-streaming succeeds (existing tests still pass)
- cargo nextest run -p opendeviationbar-providers -- combined_ws succeeds (wave 1 tests)
- cargo check --workspace succeeds (no cross-crate breakage)
  </acceptance_criteria>

<action>
Run the full verification sequence:

1. `cargo check --workspace` — verify no compile errors across all crates
2. `cargo nextest run -p opendeviationbar-providers -- combined_ws` — wave 1 tests pass
3. `cargo nextest run -p opendeviationbar-streaming` — existing streaming tests pass
4. `cargo nextest run --workspace --exclude opendeviationbar-py` — full workspace test suite

If `BinanceWebSocketStream` import removal causes issues in other parts of `live_engine.rs` (e.g., the `start()` method also references it), keep the import but remove only the per-symbol spawn in `start_ws()`.

Fix any compilation errors from the integration. Common issues:

- Missing import for `CombinedWebSocketStream`
- `HashMap` type needs explicit `use std::collections::HashMap` (already present in file)
- `ReconnectionPolicy` clone — already cloneable, no change needed
  </action>

## must_haves

- [ ] `start_ws()` spawns ONE `CombinedWebSocketStream::run_with_reconnect()` instead of N per-symbol `BinanceWebSocketStream::run_with_reconnect()` calls
- [ ] Per-symbol `mpsc::channel(50_000)` receivers are created identically — `symbol_task()` interface unchanged (COMB-06)
- [ ] All 18 symbols routed through single combined connection (COMB-04)
- [ ] `symbol_task()` fallback per-symbol WS creation removed — error + return if no pre-buffered receiver
- [ ] `cargo check --workspace` passes
- [ ] Existing streaming tests pass unchanged
