# Architecture Research: Streaming Pipeline Gap-Fix Integration

**Date**: 2026-03-19
**Scope**: Junction fix, combined streams, parallel fill_from_rest, rate limiter architecture, build order

---

## Table of Contents

1. [Current Architecture Summary](#current-architecture-summary)
2. [Junction Fix Placement](#junction-fix-placement)
3. [Combined Stream Architecture](#combined-stream-architecture)
4. [Parallel fill_from_rest](#parallel-fill_from_rest)
5. [Rate Limiter Architecture](#rate-limiter-architecture)
6. [Data Flow: New Architecture](#data-flow-new-architecture)
7. [Component Boundaries](#component-boundaries)
8. [Build Order](#build-order)

---

## Current Architecture Summary

### Startup Sequence (as-is)

```
Python sidecar.py::run_sidecar()
  |
  +-> _create_engine()
  |     +-> engine = LiveBarEngine::new()
  |     +-> engine.start_ws()           # 18 per-symbol WS connections start buffering
  |     +-> _seed_trade_ids_from_clickhouse()
  |     +-> _inject_checkpoints()
  |     +-> engine.fill_from_rest()     # SEQUENTIAL: sym1 → sym2 → ... → sym18 (~5 min)
  |     +-> return engine
  |
  +-> engine.start()                    # symbol_task spawned per symbol, reuses processors
        |
        +-> symbol_task()
              +-> reuse pre-buffered WS rx
              +-> seed gap detector
              +-> loop { select! { trade = trade_rx.recv() => ... } }
```

### The Junction Bug

Between `fill_from_rest` completion and `symbol_task` entering the main loop, there is a trade-ID gap. The gap detector (Puck) fires on the first WS trade, calling `puck_fill()` which processes the gap's trades through the same processor. However, this disrupts the forming bar that had accumulated ~100K trades during REST fill (proven on SOLUSDT@500: 12-hour, 99,882-trade gap).

The root issue: Puck does not know that this specific gap is an _expected_ junction gap that should be filled sequentially and gently, not treated as a WS reconnection gap.

---

## Junction Fix Placement

### Location

`crates/opendeviationbar-streaming/src/live_engine.rs`, `symbol_task()` function, lines 1236-1280 (between processor reuse and main `loop { select! { ... } }`).

### Design: Sequential Junction Fill

Insert a junction fill phase between gap detector seeding and the main select loop. The fix is ~30 lines that:

1. Peek the first WS trade from `trade_rx` (non-consuming peek via `recv()`)
2. If processor has state from `fill_from_rest` (i.e., `processor.last_agg_trade_id().is_some()`), compute the junction gap: `last_rest_tid..first_ws_tid`
3. If gap exists, fetch trades via `fetch_aggtrades_parallel()` using the SAME processor — this preserves the forming bar
4. After junction fill, seed the gap detector with the junction's last trade ID — Puck will never fire on this range
5. Then process the peeked WS trade normally and enter the main loop

### Code Sketch

```rust
// --- JUNCTION FILL (between gap detector seed and main loop) ---
// Peek first WS trade to determine junction gap
if let Some(first_ws_trade) = trade_rx.recv().await {
    let rest_last_tid = processors.first()
        .and_then(|(_, p)| p.last_agg_trade_id());

    if let Some(last_tid) = rest_last_tid {
        let junction_gap = first_ws_trade.agg_trade_id - last_tid - 1;
        if junction_gap > 0 {
            tracing::info!(
                %symbol, junction_gap, last_tid,
                first_ws_tid = first_ws_trade.agg_trade_id,
                "junction fill: bridging REST→WS gap"
            );
            // Sequential REST fill (NOT puck_fill — we control the processor)
            let _recovered = junction_fill(
                &symbol, last_tid, first_ws_trade.agg_trade_id,
                &mut processors, &bar_buffer, &metrics,
                &shutdown, rate_limiter.clone(), &forming_tx_map,
            ).await;
            // Advance gap detector past the junction
            gap_detector.seed(first_ws_trade.agg_trade_id - 1);
        }
    }

    // Process the first WS trade through processors
    process_trade_through_processors(
        &first_ws_trade, &mut processors, &mut last_days,
        &bar_buffer, &metrics, &symbol_arc, &symbol, &forming_tx_map,
    );
    gap_detector.update_last_tid(first_ws_trade.agg_trade_id);
}
// --- END JUNCTION FILL ---

// Main WS processing loop (unchanged)
loop { select! { ... } }
```

### Why This Does Not Add Complexity to the Hot Path

- The junction fill runs **once** at startup, before the main loop
- The main `loop { select! { ... } }` is completely unchanged
- No new branches in the per-trade processing path
- No new state variables carried into the main loop (gap detector is already seeded)
- `junction_fill` is functionally identical to `puck_fill` but can be a separate function with explicit "junction" tracing spans for clarity, or can simply reuse `puck_fill` directly since the mechanics are identical

### Recommendation: Reuse `puck_fill` Directly

`puck_fill` already does exactly what the junction fill needs: fetch trades via parallel REST, process through the same processors, handle midnight reset. The only difference is the tracing context. Rather than creating a new function:

```rust
// Before main loop — junction fill reuses puck_fill
if let Some(first_ws_trade) = trade_rx.recv().await {
    if let Some(last_tid) = processors.first().and_then(|(_, p)| p.last_agg_trade_id()) {
        if first_ws_trade.agg_trade_id > last_tid + 1 {
            let _recovered = puck_fill(
                &symbol, last_tid, first_ws_trade.agg_trade_id,
                &mut processors, &bar_buffer, &metrics,
                &shutdown, rate_limiter.clone(), &forming_tx_map,
            ).await;
        }
    }
    // Seed detector AFTER junction fill so Puck won't re-detect
    gap_detector.seed(first_ws_trade.agg_trade_id);
    // Process the trade
    process_trade_through_processors(...);
}
```

This is ~15 lines of code with zero new abstractions.

---

## Combined Stream Architecture

### Decision: New Struct Wrapping `BinanceWebSocketStream`

A new struct `CombinedWebSocketStream` in `crates/opendeviationbar-providers/src/binance/` that:

1. Connects to `wss://stream.binance.com:9443/stream?streams=sym1@aggTrade/sym2@aggTrade/...`
2. Parses the combined message envelope `{"stream":"...","data":{...}}`
3. Routes parsed `AggTrade` to per-symbol `mpsc::Sender<AggTrade>` channels

### Why a New Struct (Not a Mode Within `BinanceWebSocketStream`)

| Factor           | New Struct                                     | Mode in Existing                 |
| ---------------- | ---------------------------------------------- | -------------------------------- |
| Message format   | Combined envelope `{"stream":..., "data":...}` | Individual `{...}` directly      |
| Routing          | One connection → N symbol channels (demux)     | One connection → one channel     |
| Reconnection     | One reconnect restores all N symbols           | Per-symbol independent           |
| Error isolation  | Failure affects all symbols on that connection | Per-symbol isolation             |
| Existing callers | Zero changes to `BinanceWebSocketStream` users | Every caller gets mode branching |

The combined stream is architecturally different (1:N multiplexing vs 1:1). Making it a mode would pollute `BinanceWebSocketStream` with demux logic and change the reconnection semantics for all callers.

### Demux Interaction with `symbol_task`

Currently `symbol_task` receives trades from a per-symbol `mpsc::Receiver<AggTrade>`. The combined stream produces the same per-symbol channels via demux. The `symbol_task` signature is unchanged:

```
                    CURRENT (per-symbol WS)
                    ========================
                    BinanceWebSocketStream("BTCUSDT")
                         |
                    mpsc::channel(1000) ─────────> symbol_task("BTCUSDT", trade_rx)

                    BinanceWebSocketStream("ETHUSDT")
                         |
                    mpsc::channel(1000) ─────────> symbol_task("ETHUSDT", trade_rx)


                    NEW (combined WS, 2x9)
                    ========================
                    CombinedWebSocketStream(["BTCUSDT", ..., "ADAUSDT"])  // 9 symbols
                         |
                    demux ─┬─ mpsc::channel(1000) ─────> symbol_task("BTCUSDT", trade_rx)
                           ├─ mpsc::channel(1000) ─────> symbol_task("ETHUSDT", trade_rx)
                           └─ ...

                    CombinedWebSocketStream(["LINKUSDT", ..., "WIFUSDT"])  // 9 symbols
                         |
                    demux ─┬─ mpsc::channel(1000) ─────> symbol_task("LINKUSDT", trade_rx)
                           └─ ...
```

### Integration Point: `LiveBarEngine::start_ws()` and `start()`

`start_ws()` currently creates 18 individual WS connections. Replace with 2 combined connections:

```rust
// In start_ws() — NEW
pub fn start_ws(&mut self) {
    let mut receivers = HashMap::new();

    // Split symbols into 2 groups of 9
    let groups = self.config.symbols.chunks(9);
    for group in groups {
        let channels: Vec<(String, mpsc::Sender<AggTrade>)> = group.iter()
            .map(|s| {
                let (tx, rx) = mpsc::channel::<AggTrade>(1000);
                receivers.insert(s.to_uppercase(), rx);
                (s.to_uppercase(), tx)
            })
            .collect();

        let shutdown = self.shutdown.clone();
        let policy = self.config.reconnection_policy.clone();
        tokio::spawn(async move {
            CombinedWebSocketStream::run_with_reconnect(
                channels, policy, shutdown,
            ).await;
        });
    }

    self.ws_trade_receivers = Some(receivers);
}
```

`start()` and `symbol_task()` are unchanged — they only see `mpsc::Receiver<AggTrade>`.

### CombinedWebSocketStream Internal Design

```rust
pub struct CombinedWebSocketStream;

impl CombinedWebSocketStream {
    /// Connect to combined stream, demux messages to per-symbol channels.
    pub async fn run_with_reconnect(
        channels: Vec<(String, mpsc::Sender<AggTrade>)>,
        policy: ReconnectionPolicy,
        shutdown: CancellationToken,
    ) {
        // Build stream URL
        let streams: Vec<String> = channels.iter()
            .map(|(s, _)| format!("{}@aggTrade", s.to_lowercase()))
            .collect();
        let url = format!(
            "wss://stream.binance.com:9443/stream?streams={}",
            streams.join("/")
        );

        // Channel lookup by stream name
        let tx_map: HashMap<String, mpsc::Sender<AggTrade>> = channels.into_iter()
            .map(|(s, tx)| (format!("{}@aggtrade", s.to_lowercase()), tx))
            .collect();

        // Reconnection loop (same backoff as BinanceWebSocketStream)
        loop {
            match connect_and_demux(&url, &tx_map, &policy, &shutdown).await {
                Ok(()) => break,  // shutdown
                Err(e) if e.is_terminal() => break,
                Err(_) => { /* backoff + retry */ }
            }
        }
    }
}
```

### Fault Isolation

With 2x9 configuration, a single WS connection failure affects 9 symbols (not all 18). The second connection continues independently. This matches the empirical validation (2026-03-16) where 2x9 was fault-isolated and zero-error.

---

## Parallel fill_from_rest

### Decision: Rust-Side Parallelization

| Factor              | Rust parallel                                                                                                          | Python parallel                                                                                                     |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| Processor ownership | Processors are Rust-owned `OpenDeviationBarProcessor`. Parallel in Rust avoids crossing PyO3 boundary per trade.       | Would need to hold GIL or use separate processors per thread, then merge state (impossible for stateful processor). |
| Rate limiter        | `Arc<AdaptiveRateLimiter>` already shared across tasks                                                                 | Would need a Python wrapper to coordinate with Rust rate limiter                                                    |
| Memory              | Chunked pagination already bounds per-symbol at ~50MB. Parallel: N symbols × 50MB = 900MB for 18 symbols — acceptable. | Same memory profile but more Python overhead                                                                        |
| Error handling      | Tokio JoinSet with per-symbol error isolation                                                                          | asyncio gather with exception groups                                                                                |
| Code location       | Change is ~30 lines in `fill_from_rest()`                                                                              | Would require new PyO3 binding or Python orchestration loop                                                         |

### Design: Bounded Tokio JoinSet in `fill_from_rest()`

Replace the sequential `for symbol in &self.config.symbols { ... }` loop with a `JoinSet` bounded by a semaphore:

```rust
pub async fn fill_from_rest(&mut self) -> Result<u64, ProcessingError> {
    // ... (existing preamble) ...

    let semaphore = Arc::new(Semaphore::new(MAX_PARALLEL_FILLS)); // e.g., 4
    let mut join_set = JoinSet::new();

    for symbol in &self.config.symbols {
        let permit = semaphore.clone().acquire_owned().await.unwrap();
        let symbol = symbol.to_uppercase();
        let processors = /* move processors for this symbol */;
        let rate_limiter = self.config.rate_limiter.clone();
        // ...

        join_set.spawn(async move {
            let result = fill_symbol_from_rest(
                symbol, processors, rate_limiter, ...
            ).await;
            drop(permit);  // Release semaphore
            result
        });
    }

    // Collect results
    while let Some(result) = join_set.join_next().await {
        let (symbol, processors, bars) = result??;
        all_processors.insert(symbol, processors);
        total_bars += bars;
    }
}
```

### Parallelism Bound

- **MAX_PARALLEL_FILLS = 4**: 4 symbols filling concurrently, each consuming ~1000-1500 weight/min from the rate limiter
- Total: ~4000-6000 weight/min — within the 6000 weight/min Binance budget
- Remaining 14 symbols queue behind the semaphore
- Total fill time: ~5 min / 4 = ~75 seconds (vs current ~5 min sequential)

### Rate Limiter Interaction

The existing `AdaptiveRateLimiter` is already `Arc`-wrapped and shared across tasks. Parallel fills naturally compete for the same budget. No partitioning needed at this level — the limiter's `ceiling` mechanism already prevents one process from exhausting the budget.

However, the parallel fills must share a **lower ceiling** than the default sidecar ceiling (3000), since Puck also needs budget once the engine starts. Recommendation:

```
fill_from_rest ceiling: 4800 (full budget, Puck not active yet)
post-start sidecar ceiling: 3000 (Puck active, kintsugi gets rest)
```

Swap the ceiling after `fill_from_rest` completes:

```rust
rate_limiter.set_ceiling(4800);  // full budget for REST fill
self.fill_from_rest().await?;
rate_limiter.set_ceiling(3000);  // normal operations
```

This requires adding a `set_ceiling(&self, new_ceiling: u32)` method to `AdaptiveRateLimiter` (3 lines: atomic store).

---

## Rate Limiter Architecture

### Decision: Single Shared Limiter with Dynamic Ceiling

Not per-symbol limiters. Rationale:

| Factor              | Per-symbol limiters                                                               | Shared limiter                                               |
| ------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| Budget allocation   | Static: 6000/18 = 333 weight/symbol. Wastes budget on idle symbols.               | Dynamic: active symbols get full remaining budget            |
| Binance enforcement | Per-IP, not per-symbol. Per-symbol limiters can collectively exceed IP budget.    | Directly models Binance's IP-level enforcement               |
| Existing code       | Would require replacing `Arc<AdaptiveRateLimiter>` with per-symbol map everywhere | Zero changes to existing callers (puck_fill, parallel_fetch) |
| Server feedback     | `X-MBX-USED-WEIGHT-1m` is per-IP. A single limiter can ingest this directly.      | Same                                                         |

### Composition with Existing `AdaptiveRateLimiter`

The current `AdaptiveRateLimiter` already has:

- Token bucket with `max_weight_per_minute` (6000)
- `ceiling` for per-process budget partitioning
- Server feedback via `X-MBX-USED-WEIGHT-1m`
- Atomic state for thread-safe sharing

**No new rate limiter type needed.** The only addition is `set_ceiling()` for the fill→run transition:

```rust
impl AdaptiveRateLimiter {
    /// Dynamically adjust the per-process ceiling.
    /// Used to give fill_from_rest full budget, then reduce for normal ops.
    pub fn set_ceiling(&self, new_ceiling: u32) {
        // ceiling is currently a non-atomic field set at construction.
        // Change to AtomicU32 for runtime adjustment.
        self.ceiling.store(new_ceiling, Ordering::Relaxed);
    }
}
```

This requires changing `ceiling: u32` to `ceiling: AtomicU32` in the struct — a one-line change with no behavioral difference for existing callers (atomic load instead of field read).

---

## Data Flow: New Architecture

### Startup Sequence (to-be)

```
Python sidecar.py::run_sidecar()
  |
  +-> _create_engine()
  |     +-> engine = LiveBarEngine::new()
  |     +-> engine.start_ws()           # 2 combined WS connections (2x9), buffering
  |     +-> _seed_trade_ids_from_clickhouse()
  |     +-> _inject_checkpoints()
  |     +-> rate_limiter.set_ceiling(4800)
  |     +-> engine.fill_from_rest()     # PARALLEL: 4 symbols at a time (~75s total)
  |     +-> rate_limiter.set_ceiling(3000)
  |     +-> return engine
  |
  +-> engine.start()                    # symbol_task spawned per symbol
        |
        +-> symbol_task()
              +-> reuse pre-buffered WS rx (from combined stream demux)
              +-> seed gap detector
              +-> JUNCTION FILL: peek first WS trade, bridge REST→WS gap
              +-> seed gap detector past junction
              +-> loop { select! { trade = trade_rx.recv() => ... } }  # unchanged
```

### Steady-State Data Flow

```
  Binance WS Combined #1 (9 symbols)    Binance WS Combined #2 (9 symbols)
            |                                       |
  CombinedWebSocketStream::demux()       CombinedWebSocketStream::demux()
            |                                       |
  9x mpsc::channel(1000)                 9x mpsc::channel(1000)
            |                                       |
  +---------+---------+                  +---------+---------+
  |         |         |                  |         |         |
  v         v         v                  v         v         v
  symbol_task      symbol_task         symbol_task      symbol_task
  (BTCUSDT)        (ETHUSDT)           (LINKUSDT)       (WIFUSDT)
     |                |                    |                |
     +---+---+    +---+---+           +---+---+       +---+---+
     |   |   |    |   |   |           |   |   |       |   |   |
     v   v   v    v   v   v           v   v   v       v   v   v
   @250 @500 @750 @250 @500 @750    @250 @500 @750   @250 @500 @750
   proc proc proc proc proc proc    proc proc proc   proc proc proc
     |   |   |    |   |   |           |   |   |       |   |   |
     +---+---+----+---+---+-----------+---+---+-------+---+---+
                         |
                  ConcurrentRingBuffer<CompletedBar> (shared, 10K capacity)
                         |
                  Python sidecar: next_bar() loop
                         |
                  ClickHouse batch write
```

---

## Component Boundaries

### Files Changed (Existing)

| File                                                            | Change                                                                                                                                  | Scope                  |
| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- |
| `crates/opendeviationbar-streaming/src/live_engine.rs`          | Junction fill in `symbol_task` (~15 lines). Parallel `fill_from_rest` (~40 lines). Updated `start_ws` for combined streams (~20 lines). | Contained to this file |
| `crates/opendeviationbar-providers/src/binance/rate_limiter.rs` | Change `ceiling: u32` to `ceiling: AtomicU32`, add `set_ceiling()`                                                                      | 5 lines                |
| `python/opendeviationbar/sidecar.py`                            | Add `rate_limiter.set_ceiling()` calls around `fill_from_rest`                                                                          | 3 lines                |

### Files Created (New)

| File                                                                 | Purpose                              | Size Estimate |
| -------------------------------------------------------------------- | ------------------------------------ | ------------- |
| `crates/opendeviationbar-providers/src/binance/combined_ws.rs`       | `CombinedWebSocketStream` with demux | ~250 lines    |
| `crates/opendeviationbar-providers/src/binance/combined_ws.rs` tests | Unit tests for demux, reconnection   | ~150 lines    |

### Files NOT Changed

| File                         | Why Unchanged                                                       |
| ---------------------------- | ------------------------------------------------------------------- |
| `symbol_task` main loop      | Junction fill runs before the loop; hot path untouched              |
| `puck_fill`                  | Reused as-is for junction fill                                      |
| `stream_manager.rs`          | StreamManager wraps LiveBarEngine; no interface change              |
| `stream_manager_bindings.rs` | PyO3 bindings unchanged; `start_ws/fill_from_rest/start` API stable |
| `parallel_fetch.rs`          | Already supports concurrent REST, used as-is                        |

---

## Build Order

### Phase 1: Junction Fix (standalone, zero dependencies on other phases)

**Files**: `live_engine.rs` (symbol_task)
**Effort**: ~15 lines of Rust
**Risk**: Low — additive code before main loop, no existing behavior changed
**Testability**: Unit-testable with mock WS receiver. E2E with clean-slate deploy + Stathera verification.
**Why first**: This is the root cause fix. Even without combined streams or parallel fill, this eliminates the forming-bar abandonment bug for all 18 symbols. Deploy this alone and verify zero intra-day gaps.

### Phase 2: Junction Telemetry (depends on Phase 1)

**Files**: `live_engine.rs` (symbol_task), metrics struct
**Effort**: ~20 lines
**Risk**: Zero (additive logging/metrics)
**Why second**: Validates Phase 1 in production. Provides data for Phase 3-5 design decisions.

Telemetry points:

- Junction gap size per symbol (trade count)
- Forming bar trade count at junction
- WS buffer depth when junction fill starts
- Junction fill duration per symbol

### Phase 3: Rate Limiter Dynamic Ceiling (standalone, no dependencies)

**Files**: `rate_limiter.rs` (ceiling to AtomicU32 + `set_ceiling`), `sidecar.py` (call sites)
**Effort**: ~10 lines total
**Risk**: Very low — AtomicU32 is a drop-in replacement for u32 in this context
**Why third**: Prerequisite for Phase 4 (parallel fill needs full budget during fill, reduced budget after).

### Phase 4: Parallel fill_from_rest (depends on Phase 3)

**Files**: `live_engine.rs` (fill_from_rest)
**Effort**: ~40 lines (JoinSet + semaphore)
**Risk**: Medium — concurrent processor creation, per-symbol error isolation. OOM risk bounded by existing chunked pagination (50MB per symbol, 4 concurrent = 200MB).
**Why fourth**: Reduces startup time from ~5 min to ~75s. Combined with junction fix (Phase 1), this shrinks the WS buffer gap dramatically, reducing the junction fill's work.

### Phase 5: Combined WebSocket Streams (standalone, no code dependencies on Phase 1-4)

**Files**: New `combined_ws.rs`, updated `live_engine.rs::start_ws()`
**Effort**: ~250 lines new, ~20 lines changed
**Risk**: Medium — new WS message format parsing, demux correctness, reconnection behavior change (all 9 symbols reconnect together)
**Why fifth**: This is the largest change and has the most testing surface. However, it can be developed in parallel with Phases 1-4 since it only touches `start_ws()` which is independent.

**Spike-test first**: Before implementing, validate 1x18 vs 2x9 vs tiered (2+16) configurations with the existing `BinanceWebSocketStream` infrastructure. The PROJECT.md requirements call for this spike-test explicitly.

### Phase 6: Clean-Slate Deploy + Verification (depends on all above)

**Steps**: Stop sidecar -> Delete today's bars -> Clear checkpoints -> Start with new code -> Monitor 24h
**Verification**: Zero intra-day Stathera gaps, zero overlaps, all 18 symbols, 24h+

### Parallelism

Phases 1-3 can be built sequentially in a single session (total ~45 lines).
Phase 5 can be built in parallel with Phases 1-4 (independent code path).
Phase 4 depends on Phase 3 but can be coded immediately after.

```
Timeline:
  Session 1: Phase 1 (junction fix) + Phase 2 (telemetry) + Phase 3 (ceiling)
  Session 2: Phase 4 (parallel fill) — depends on Phase 3
  Session 2 (parallel): Phase 5 (combined WS) — independent
  Session 3: Phase 6 (deploy + verify)
```

---

## Open Questions

1. **WS buffer overflow**: With 18 symbols and ~5 min fill time, the mpsc(1000) per-symbol buffer may overflow for high-volume symbols. BTCUSDT at ~8 trades/sec = ~2400 trades in 5 min — buffer overflows. Parallel fill (Phase 4) reduces this to ~75s = ~600 trades. Alternatively, increase buffer to 5000 (costs ~5MB more per symbol, 90MB total — acceptable).

2. **Combined WS group assignment**: Which 9 symbols go on connection #1 vs #2? Options: (a) sorted alphabetically, (b) sorted by volume (balance load), (c) configurable. Recommendation: sorted by volume to balance throughput across connections.

3. **WS 24h lifetime**: Binance disconnects WS after 24h. Combined stream reconnection resets all 9 symbols simultaneously. Is this acceptable? Yes — the reconnection is sub-second and Puck fills any micro-gap. The current per-symbol approach has the same issue but for one symbol at a time.

---

## Summary of Decisions

| Decision               | Choice                               | Rationale                                                |
| ---------------------- | ------------------------------------ | -------------------------------------------------------- |
| Junction fix location  | Before main loop in `symbol_task`    | Zero hot-path impact, ~15 lines                          |
| Junction fill function | Reuse `puck_fill`                    | Same mechanics, no new abstraction                       |
| Combined WS            | New `CombinedWebSocketStream` struct | Different message format and 1:N routing vs existing 1:1 |
| Demux output           | Per-symbol `mpsc::Sender<AggTrade>`  | `symbol_task` interface unchanged                        |
| Parallel fill          | Rust-side `JoinSet` + `Semaphore(4)` | Processors are Rust-owned, avoids PyO3 boundary          |
| Rate limiter           | Single shared, dynamic ceiling       | Matches Binance's per-IP enforcement                     |
| Build order            | Junction fix first                   | Root cause fix, independently deployable                 |
