---
phase: 04-combined-websocket-streams
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - crates/opendeviationbar-streaming/src/live_engine/types.rs
  - crates/opendeviationbar-streaming/src/live_engine/mod.rs
  - crates/opendeviationbar-streaming/src/live_engine/symbol_task.rs
  - src/stream_manager_bindings.rs
  - python/opendeviationbar/config/sidecar.py
  - python/opendeviationbar/sidecar/__init__.py
  - python/opendeviationbar/sidecar/__init__.pyi
autonomous: true
requirements: [WS-01, WS-02]

must_haves:
  truths:
    - "Setting OPENDEVIATIONBAR_WS_MODE=per_symbol causes sidecar to spawn N individual BinanceWebSocketStream connections instead of combined"
    - "Setting OPENDEVIATIONBAR_WS_MODE=combined (default) causes sidecar to spawn 2 CombinedWebSocketStream connections with symbols split by volume"
    - "Both WS modes produce identical per-symbol mpsc::Receiver<AggTrade> channels consumed by symbol_task unchanged"
    - "Antaeus exponential backoff with jitter applies to both combined connections independently"
  artifacts:
    - path: "crates/opendeviationbar-streaming/src/live_engine/types.rs"
      provides: "WsMode enum and ws_mode field on LiveEngineConfig"
      contains: "WsMode"
    - path: "crates/opendeviationbar-streaming/src/live_engine/mod.rs"
      provides: "start_ws() branching on WsMode with 2-connection split"
      contains: "WsMode::Combined"
    - path: "python/opendeviationbar/config/sidecar.py"
      provides: "ws_mode config field with env var"
      contains: "ws_mode"
    - path: "src/stream_manager_bindings.rs"
      provides: "ws_mode parameter threaded from Python to Rust"
      contains: "ws_mode"
  key_links:
    - from: "python/opendeviationbar/config/sidecar.py"
      to: "python/opendeviationbar/sidecar/__init__.py"
      via: "SidecarConfig.ws_mode read in _create_engine"
      pattern: "ws_mode"
    - from: "python/opendeviationbar/sidecar/__init__.py"
      to: "src/stream_manager_bindings.rs"
      via: "StreamManager constructor ws_mode parameter"
      pattern: "ws_mode"
    - from: "src/stream_manager_bindings.rs"
      to: "crates/opendeviationbar-streaming/src/live_engine/types.rs"
      via: "LiveEngineConfig.ws_mode field"
      pattern: "ws_mode"
    - from: "crates/opendeviationbar-streaming/src/live_engine/types.rs"
      to: "crates/opendeviationbar-streaming/src/live_engine/mod.rs"
      via: "start_ws() reads self.config.ws_mode"
      pattern: "self.config.ws_mode"
---

<objective>
Add WS mode feature flag with 2-connection combined split and per-symbol fallback.

Purpose: Enable rollback between combined and per-symbol WS modes (D-01) while splitting the single combined connection into 2 connections for fault isolation (WS-01). Antaeus backoff (WS-02) already works -- each CombinedWebSocketStream has independent backoff state.

Output: Feature flag plumbed from env var through Python config to Rust engine. Combined mode spawns 2 connections (13 symbols each by volume). Per-symbol mode spawns N individual connections.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/04-combined-websocket-streams/04-RESEARCH.md

<interfaces>
<!-- Key types and contracts the executor needs -->

From crates/opendeviationbar-streaming/src/live_engine/types.rs:

```rust
pub struct LiveEngineConfig {
    pub symbols: Vec<String>,
    pub thresholds: Vec<u32>,
    pub include_microstructure: bool,
    pub bar_channel_capacity: usize,
    pub reconnection_policy: ReconnectionPolicy,
    pub initial_checkpoints: HashMap<(String, u32), Checkpoint>,
    pub rate_limiter: Option<Arc<AdaptiveRateLimiter>>,
    pub compute_tier2: bool,
    pub compute_tier3: bool,
    pub compute_hurst: Option<bool>,
    pub compute_permutation_entropy: Option<bool>,
    pub gap_detector_seeds: HashMap<String, i64>,
}
```

From crates/opendeviationbar-streaming/src/live_engine/mod.rs (start_ws at line 179):

```rust
pub fn start_ws(&mut self) {
    // Creates per-symbol mpsc channels (50K capacity)
    // Spawns ONE CombinedWebSocketStream::run_with_reconnect() with ALL symbols
    let symbols: Vec<String> = self.config.symbols.iter().map(|s| s.to_uppercase()).collect();
    tokio::spawn(async move {
        CombinedWebSocketStream::run_with_reconnect(symbols, senders, policy, ws_shutdown).await;
    });
}
```

From crates/opendeviationbar-providers/src/binance/websocket.rs:

```rust
impl BinanceWebSocketStream {
    pub async fn run_with_reconnect(symbol: &str, trade_tx: mpsc::Sender<AggTrade>, policy: ReconnectionPolicy, shutdown: CancellationToken);
}
```

From crates/opendeviationbar-providers/src/binance/combined_ws.rs:

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

From src/stream_manager_bindings.rs (PyStreamManager::new at line 106):

```rust
#[pyo3(signature = (symbols, thresholds, include_microstructure=true, compute_tier2=true, compute_tier3=false, compute_hurst=None, compute_permutation_entropy=None, max_weight_per_minute=6000, safety_margin=0.20, shadow_validation=false, shadow_interval_secs=5))]
pub fn new(symbols, thresholds, include_microstructure, ...) -> PyResult<Self> {
    let mut engine_config = LiveEngineConfig::new(symbols, thresholds);
    // ... sets fields on engine_config ...
}
```

From python/opendeviationbar/config/sidecar.py:

```python
class SidecarConfig(BaseSettings):
    # env_prefix = "OPENDEVIATIONBAR_STREAMING_"
    # Fields: symbols, thresholds, include_microstructure, verbose, timeout_ms, ...
```

From python/opendeviationbar/sidecar/**init**.py (\_create_engine at line 171):

```python
def _create_engine(config: SidecarConfig, trace_id, gap_fill_results, valid_pairs=None):
    engine = StreamManager(config.symbols, config.thresholds, config.include_microstructure, ...)
```

</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Add WsMode enum and 2-connection split to Rust engine</name>
  <files>
    crates/opendeviationbar-streaming/src/live_engine/types.rs
    crates/opendeviationbar-streaming/src/live_engine/mod.rs
  </files>
  <read_first>
    crates/opendeviationbar-streaming/src/live_engine/types.rs
    crates/opendeviationbar-streaming/src/live_engine/mod.rs
    crates/opendeviationbar-providers/src/binance/websocket.rs (lines 416-430, run_with_reconnect signature)
    crates/opendeviationbar-providers/src/binance/combined_ws.rs (run_with_reconnect signature)
  </read_first>
  <behavior>
    - Test 1: WsMode::Combined is the default on LiveEngineConfig::new()
    - Test 2: WsMode::PerSymbol can be set on LiveEngineConfig
    - Test 3: split_symbols_by_volume() splits 26 symbols into 2 groups of 13, with high-volume symbols in group 1
    - Test 4: split_symbols_by_volume() handles odd counts (e.g., 5 symbols -> 3+2 or 2+3)
    - Test 5: split_symbols_by_volume() with 1 symbol returns (1, 0) split
  </behavior>
  <action>
    **In types.rs:**

    1. Add `WsMode` enum (per D-01 rollback-first design):
       ```rust
       #[derive(Debug, Clone, PartialEq, Eq, Default)]
       pub enum WsMode {
           #[default]
           Combined,
           PerSymbol,
       }
       ```

    2. Add `ws_mode: WsMode` field to `LiveEngineConfig` struct. Default via `WsMode::default()` (Combined).

    3. Add `split_symbols_by_volume(symbols: &[String]) -> (Vec<String>, Vec<String>)` function that splits symbols into 2 groups by known volume ranking. Hardcode the volume ranking order (BTCUSDT, ETHUSDT, SOLUSDT, XRPUSDT, BNBUSDT, DOGEUSDT, ADAUSDT, AVAXUSDT, LINKUSDT, TRXUSDT, SUIUSDT, LTCUSDT, NEARUSDT as high-volume). Symbols not in the ranking go to the smaller group. Even split: alternate assignment to balance group sizes.

    **In mod.rs:**

    4. Add `use opendeviationbar_providers::binance::BinanceWebSocketStream;` import.

    5. Modify `start_ws()` to branch on `self.config.ws_mode`:
       - `WsMode::Combined`: Split symbols via `split_symbols_by_volume()`, spawn TWO `CombinedWebSocketStream::run_with_reconnect()` tasks, each with its own child `CancellationToken` from `self.shutdown`. Each task gets only its group's senders (filter the HashMap). Log "started 2 combined WS connections (group_a={N}, group_b={M})".
       - `WsMode::PerSymbol`: For each symbol, spawn `BinanceWebSocketStream::run_with_reconnect()` with the symbol's sender. Log "started {N} per-symbol WS connections".
       - Both modes produce the same `self.ws_trade_receivers = Some(receivers)` -- symbol_task is mode-agnostic.

    6. Restore the per-symbol fallback in symbol_task.rs line 58-62: keep the error log for missing pre_buffered_rx (it's correct -- start_ws() MUST be called first regardless of mode).

    WS-02 note: Each `CombinedWebSocketStream::run_with_reconnect()` already has independent Antaeus backoff. The 2-connection split inherits this automatically -- no new backoff code needed.

  </action>
  <verify>
    <automated>cd /Users/terryli/eon/opendeviationbar-py && cargo nextest run -p opendeviationbar-streaming -E 'test(ws_mode) | test(split_symbols)' -x</automated>
  </verify>
  <acceptance_criteria>
    - WsMode enum exists with Combined (default) and PerSymbol variants
    - LiveEngineConfig has ws_mode field defaulting to Combined
    - split_symbols_by_volume correctly partitions symbols into 2 balanced groups
    - start_ws() branches on ws_mode: Combined spawns 2 tasks, PerSymbol spawns N tasks
    - Both modes produce identical ws_trade_receivers HashMap
    - Existing combined_ws tests still pass (cargo nextest run -p opendeviationbar-providers -x)
  </acceptance_criteria>
  <done>WsMode enum + 2-connection split + per-symbol fallback implemented in Rust. All tests pass.</done>
</task>

<task type="auto">
  <name>Task 2: Wire WS mode from Python config through PyO3 to Rust</name>
  <files>
    src/stream_manager_bindings.rs
    python/opendeviationbar/config/sidecar.py
    python/opendeviationbar/sidecar/__init__.py
    python/opendeviationbar/sidecar/__init__.pyi
  </files>
  <read_first>
    src/stream_manager_bindings.rs (full file)
    python/opendeviationbar/config/sidecar.py (full file)
    python/opendeviationbar/sidecar/__init__.py (lines 169-230, _create_engine)
    python/opendeviationbar/sidecar/__init__.pyi
  </read_first>
  <action>
    **In python/opendeviationbar/config/sidecar.py:**

    1. Add `ws_mode` field to `SidecarConfig` (per D-01):
       ```python
       ws_mode: Literal["combined", "per_symbol"] = "combined"
       ```
       The env var will be `OPENDEVIATIONBAR_STREAMING_WS_MODE` (follows existing env_prefix pattern). Default is `"combined"` matching current production behavior. Set to `"per_symbol"` for rollback.

    **In src/stream_manager_bindings.rs:**

    2. Add `ws_mode: &str` parameter to `PyStreamManager::new()` signature (default `"combined"`). Add it to the `#[pyo3(signature = (...))]` macro.

    3. Parse the string to `WsMode`:
       ```rust
       engine_config.ws_mode = match ws_mode {
           "per_symbol" => WsMode::PerSymbol,
           _ => WsMode::Combined,  // default + "combined"
       };
       ```

    **In python/opendeviationbar/sidecar/__init__.py:**

    4. In `_create_engine()`, pass `ws_mode=config.ws_mode` to the `StreamManager()` constructor call. The existing call at ~line 211 becomes:
       ```python
       engine = StreamManager(
           config.symbols,
           config.thresholds,
           config.include_microstructure,
           compute_tier2=pop.compute_tier2,
           ...,
           ws_mode=config.ws_mode,
       )
       ```

    5. Add a log line after engine creation: `logger.info("WS mode: %s", config.ws_mode)`

    **In python/opendeviationbar/sidecar/__init__.pyi:**

    6. Add `ws_mode: str` to `SidecarConfig` stub.

    **Build and verify:**

    7. Run `maturin develop --release` to rebuild PyO3 bindings with the new parameter.

  </action>
  <verify>
    <automated>cd /Users/terryli/eon/opendeviationbar-py && maturin develop --release 2>&1 | tail -5 && python -c "from opendeviationbar.config.sidecar import SidecarConfig; c = SidecarConfig(symbols=['BTCUSDT'], thresholds=[250]); assert c.ws_mode == 'combined', f'got {c.ws_mode}'; print('OK: ws_mode defaults to combined')"</automated>
  </verify>
  <acceptance_criteria>
    - SidecarConfig.ws_mode field exists, defaults to "combined", reads from OPENDEVIATIONBAR_STREAMING_WS_MODE env var
    - PyStreamManager accepts ws_mode parameter and passes it to LiveEngineConfig
    - _create_engine passes config.ws_mode to StreamManager
    - maturin develop builds successfully
    - Python import and config instantiation works
  </acceptance_criteria>
  <done>WS mode feature flag plumbed end-to-end from env var to Rust engine. Default is combined (current production behavior). Setting OPENDEVIATIONBAR_STREAMING_WS_MODE=per_symbol activates rollback path.</done>
</task>

</tasks>

<verification>
1. `cargo nextest run -p opendeviationbar-streaming -p opendeviationbar-providers -x` -- all Rust tests pass
2. `maturin develop --release` -- builds successfully
3. `python -c "from opendeviationbar.config.sidecar import SidecarConfig; c = SidecarConfig(symbols=['BTCUSDT'], thresholds=[250]); print(c.ws_mode)"` -- prints "combined"
4. `mise run check-full` -- full quality gate passes
</verification>

<success_criteria>

- WsMode feature flag exists (D-01 satisfied)
- Combined mode spawns 2 connections with balanced symbol split (WS-01 satisfied)
- Per-symbol mode spawns N individual connections (D-01 rollback path)
- Antaeus backoff works independently per connection (WS-02 -- inherited from existing CombinedWebSocketStream)
- symbol_task is unchanged and mode-agnostic
- All tests pass, build succeeds
  </success_criteria>

<output>
After completion, create `.planning/phases/04-combined-websocket-streams/04-01-SUMMARY.md`
</output>
