Skip to main content

digdigdig3_station/
station.rs

1use std::any::Any;
2use std::path::PathBuf;
3use std::sync::atomic::{AtomicUsize, Ordering};
4use std::sync::Arc;
5
6use crate::ws_health::WsHealth;
7
8use dashmap::DashMap;
9use digdigdig3::connector_manager::ExchangeHub;
10use digdigdig3::core::types::{
11    AccountType, ExchangeId, StreamEvent, StreamType, SubscriptionRequest, Symbol, SymbolInfo,
12};
13use digdigdig3::core::websocket::KlineInterval;
14use digdigdig3::core::utils::SymbolNormalizer;
15use futures_util::StreamExt;
16use tokio::sync::{broadcast, mpsc, oneshot, RwLock};
17
18use crate::data::{
19    AggTradePoint, BalanceUpdatePoint, BarPoint, BasisPoint, BlockTradePoint, CompositeIndexPoint,
20    FootprintPoint, FundingRatePoint, FundingSettlementPoint, HistoricalVolatilityPoint,
21    IndexPriceKlinePoint, IndexPricePoint, InsuranceFundPoint, LiquidationPoint,
22    LongShortRatioPoint, MarkPriceKlinePoint, MarkPricePoint,
23    MarketWarningPoint, ObDeltaPoint, ObSnapshotPoint, OpenInterestPoint, OptionGreeksPoint,
24    OrderUpdatePoint, OrderbookL3Point, PositionUpdatePoint,
25    PredictedFundingPoint, PremiumIndexKlinePoint, RiskLimitPoint, SettlementEventPoint,
26    TickerPoint, TradePoint, VolatilityIndexPoint,
27};
28use crate::derived::{
29    BasisDerived, DerivedStream, FundingSettlementDerived, TradeToBarDerived,
30    TradeToRangeBarDerived, TradeToTickBarDerived, TradeToVolumeBarDerived,
31    TradeToFootprintDerived, interval_to_ms,
32};
33#[cfg(not(target_arch = "wasm32"))]
34use crate::polling;
35use crate::series::DiskStore;
36use crate::series::{DataPoint, Kind, Series, SeriesKey};
37use crate::subscription::{Entry, Event, FailedStream, MultiplexRef, Stream};
38use crate::{
39    PersistenceConfig, Result, StationBuilder, StationError, SubscribeReport, SubscriptionHandle,
40    SubscriptionSet,
41};
42
43
44/// Phase 5 Station. Unified `Series<T>` + `DiskStore<T>` plumbing under all
45/// stream classes. One multiplexer actor per `SeriesKey` (= exchange × account
46/// × symbol × kind). N consumers share via `broadcast::channel`.
47pub struct Station {
48    pub(crate) inner: Arc<StationInner>,
49}
50
51pub(crate) struct StationInner {
52    pub(crate) hub: Arc<ExchangeHub>,
53    pub(crate) storage_root: PathBuf,
54    pub(crate) persistence: PersistenceConfig,
55    pub(crate) muxes: DashMap<SeriesKey, Multiplexer>,
56    /// Sync-accessible series handles for render-time consumers.
57    ///
58    /// Each active forwarder stores `Arc<RwLock<Series<T>>>` here (type-erased
59    /// to `Arc<dyn Any + Send + Sync>`). `Station::series<T>()` retrieves and
60    /// downcasts. Entries are removed when the forwarder exits (same lifecycle
61    /// as `muxes`).
62    pub(crate) series_handles: DashMap<SeriesKey, Arc<dyn Any + Send + Sync>>,
63    pub(crate) warm_start_capacity: usize,
64    pub(crate) gap_heal: crate::GapHealConfig,
65    /// How long to keep a forwarder alive after its last consumer drops.
66    /// `Duration::ZERO` = immediate shutdown (default).
67    pub(crate) unsubscribe_grace: std::time::Duration,
68    /// Issue a one-shot REST `get_orderbook` seed on first subscribe to
69    /// `Orderbook` / `OrderbookDelta`. False = WS-only (default).
70    pub(crate) orderbook_rest_seed: bool,
71    /// Depth for the REST seed. Passed as `Some(depth as u16)` to `get_orderbook`.
72    pub(crate) orderbook_seed_depth: usize,
73    /// Broadcast channel for connector lifecycle events (`ConnectorReady`,
74    /// `SymbolsLoaded`). Independent of per-`SeriesKey` data muxes.
75    /// Capacity 256 — lag drops oldest.
76    pub(crate) connector_tx: broadcast::Sender<crate::subscription::Event>,
77    /// Cache for `get_exchange_info` results, keyed by `(exchange, account_type)`.
78    /// Populated by `warmup()`; re-emits without REST on repeated calls.
79    pub(crate) exchange_info_cache: DashMap<(ExchangeId, AccountType), Vec<SymbolInfo>>,
80}
81
82/// One broadcast-fanout actor per `SeriesKey`. Each consumer increments
83/// `consumers`; on the last drop the actor shuts down.
84pub(crate) struct Multiplexer {
85    pub(crate) tx: broadcast::Sender<Event>,
86    pub(crate) consumers: Arc<AtomicUsize>,
87    pub(crate) shutdown: Option<oneshot::Sender<()>>,
88    /// Cancel sender for a pending grace-period timer task.
89    /// `Some` only while the forwarder is in the grace window
90    /// (refcount == 0 but shutdown not yet fired). Sending `()` on this
91    /// channel cancels the timer and keeps the forwarder alive.
92    /// A new subscribe arriving before the timer fires sends on this channel
93    /// and increments consumers.
94    pub(crate) grace_cancel: Option<oneshot::Sender<()>>,
95}
96
97impl std::fmt::Debug for Station {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        f.debug_struct("Station")
100            .field("storage_root", &self.inner.storage_root)
101            .field("persistence", &self.inner.persistence)
102            .field("muxes", &self.inner.muxes.len())
103            .finish()
104    }
105}
106
107impl Station {
108    pub fn builder() -> StationBuilder { StationBuilder::new() }
109    pub fn storage_root(&self) -> &std::path::Path { &self.inner.storage_root }
110    pub fn active_streams(&self) -> usize { self.inner.muxes.len() }
111
112    /// Shared `ExchangeHub` backing this Station's connectors.
113    ///
114    /// Exposed so a consumer that also needs raw REST history (e.g. a chart
115    /// doing scroll-left pagination) can route `backfill::fetch_history` /
116    /// `backfill::klines_recent` through the SAME connector pool the Station's
117    /// live subscriptions use — instead of dialing a second, parallel hub.
118    /// One pool means one dial-wave, one rate-limit budget, one warm-up.
119    ///
120    /// `ExchangeHub::clone` is O(1) (Arc-pooled internally).
121    pub fn hub(&self) -> Arc<ExchangeHub> {
122        self.inner.hub.clone()
123    }
124
125    /// Return a sync handle to the in-memory ring for `key`.
126    ///
127    /// Returns `Some(Arc<RwLock<Series<T>>>)` when a forwarder for `key` is
128    /// currently live (active subscription or within the 30 s grace window) and
129    /// the concrete element type matches `T`.  Returns `None` when no active
130    /// forwarder exists for this key or when the stored type differs from `T`
131    /// (type mismatch is silently treated as absent rather than panicking).
132    ///
133    /// Render-time consumers (chart panels, dashboards) use this to peek at the
134    /// running ring without awaiting an `Event`.  The handle is independent of
135    /// `SubscriptionHandle::recv()` — events still flow through there for
136    /// state-mutation paths; this getter is read-only.
137    pub fn series<T: DataPoint + 'static>(&self, key: &SeriesKey)
138        -> Option<Arc<RwLock<Series<T>>>>
139    {
140        let erased = self.inner.series_handles.get(key)?;
141        // Downcast Arc<dyn Any + Send + Sync> → Arc<RwLock<Series<T>>>.
142        // `Arc::downcast` is not available for trait objects; use `Any::downcast_ref`
143        // on the inner value to verify the type, then clone the concrete Arc.
144        erased
145            .downcast_ref::<Arc<RwLock<Series<T>>>>()
146            .map(Arc::clone)
147    }
148
149    /// Register a consumer with the given quota. Drop the returned
150    /// [`ConsumerHandle`] to release all of the consumer's active
151    /// subscriptions atomically.
152    ///
153    /// This is opt-in: [`Station::subscribe`] continues to work without
154    /// quotas. A consumer that wants caps registers and uses
155    /// [`ConsumerHandle::subscribe`]; one that does not keeps calling
156    /// [`Station::subscribe`] directly.
157    pub fn register_consumer(
158        &self,
159        quota: crate::quota::ConsumerQuota,
160    ) -> crate::quota::ConsumerHandle {
161        let rest_bucket = quota.max_rest_per_window.map(|cap| {
162            crate::quota::TokenBucket::new(cap, quota.rest_window)
163        });
164        crate::quota::ConsumerHandle {
165            station: Arc::clone(&self.inner),
166            quota,
167            rest_bucket: Arc::new(tokio::sync::Mutex::new(rest_bucket)),
168            refs: tokio::sync::Mutex::new((0, Vec::new())),
169        }
170    }
171
172    pub(crate) async fn from_builder(b: StationBuilder) -> Result<Self> {
173        let _ = digdigdig3::core::install_default_crypto_provider();
174        // Native: pre-create the storage root. wasm32: OPFS directories are created
175        // lazily by the OPFS DiskStore on first append — std::fs is Unsupported here
176        // (it would fail Station::build for any persistence-enabled Station on wasm).
177        #[cfg(not(target_arch = "wasm32"))]
178        if b.persistence.enabled {
179            std::fs::create_dir_all(&b.storage_root).map_err(StationError::Io)?;
180        }
181        let (connector_tx, _) = broadcast::channel(256);
182        Ok(Self {
183            inner: Arc::new(StationInner {
184                hub: Arc::new(ExchangeHub::new()),
185                storage_root: b.storage_root,
186                persistence: b.persistence,
187                muxes: DashMap::new(),
188                series_handles: DashMap::new(),
189                warm_start_capacity: b.warm_start.max(1),
190                gap_heal: b.gap_heal,
191                unsubscribe_grace: b.unsubscribe_grace,
192                orderbook_rest_seed: b.orderbook_rest_seed,
193                orderbook_seed_depth: b.orderbook_seed_depth,
194                connector_tx,
195                exchange_info_cache: DashMap::new(),
196            }),
197        })
198    }
199
200    /// A broadcast receiver for connector lifecycle events
201    /// (`ConnectorReady` / `SymbolsLoaded`). Returns events emitted from any
202    /// source — `warmup()`, on-demand subscribe-time connector init, REST
203    /// exchange-info refresh.
204    ///
205    /// Independent of `SubscriptionHandle` event streams. Capacity 256.
206    /// Lag drops oldest.
207    pub fn connector_events(&self) -> broadcast::Receiver<crate::subscription::Event> {
208        self.inner.connector_tx.subscribe()
209    }
210
211    /// Snapshot of cached `SymbolInfo` for `exchange` across all account types.
212    /// Empty if `warmup` hasn't yet been called or REST hasn't completed.
213    pub fn symbols(&self, exchange: ExchangeId) -> Vec<SymbolInfo> {
214        let mut out = Vec::new();
215        for entry in self.inner.exchange_info_cache.iter() {
216            if entry.key().0 == exchange {
217                out.extend_from_slice(entry.value());
218            }
219        }
220        out
221    }
222
223    /// Snapshot the live health metrics for the WS forwarder backing `key`.
224    ///
225    /// Returns `None` if no forwarder exists for this key (no active or
226    /// grace-window subscription).
227    ///
228    /// Sync, non-blocking — suitable for periodic diagnostics polls or
229    /// per-frame UI overlays (latency badges). Each field is a best-effort
230    /// snapshot:
231    ///
232    /// - `connected`: always accurate — derived from mux presence.
233    /// - `rtt_ms`: `None` until per-connector RTT handle wiring is added
234    ///   (incremental; OKX is the first candidate).
235    /// - `last_message_ms`: `None` until per-forwarder atomic timestamp
236    ///   wiring is added (incremental).
237    pub fn ws_health(&self, key: &SeriesKey) -> Option<WsHealth> {
238        // Presence in muxes == a live forwarder (active or grace-window).
239        self.inner.muxes.get(key)?;
240        Some(WsHealth {
241            connected: true,
242            rtt_ms: None,
243            last_message_ms: None,
244        })
245    }
246
247    /// Aggregate health across all forwarders for `exchange`.
248    ///
249    /// - `rtt_ms`: median of non-`None` RTT values across forwarders.
250    /// - `last_message_ms`: max of non-`None` last-message timestamps
251    ///   (most-recent message seen on any forwarder for this exchange).
252    /// - `connected`: `true` if at least one forwarder is connected.
253    ///
254    /// Returns `None` if there are no active forwarders for `exchange`.
255    pub fn ws_health_for_exchange(
256        &self,
257        exchange: ExchangeId,
258    ) -> Option<WsHealth> {
259        let mut any_connected = false;
260        let mut rtts: Vec<u64> = Vec::new();
261        let mut last_msgs: Vec<i64> = Vec::new();
262
263        for entry in self.inner.muxes.iter() {
264            if entry.key().exchange != exchange {
265                continue;
266            }
267            // Each mux entry == one live forwarder.
268            let h = WsHealth {
269                connected: true,
270                rtt_ms: None,
271                last_message_ms: None,
272            };
273            any_connected = true;
274            if let Some(rtt) = h.rtt_ms {
275                rtts.push(rtt);
276            }
277            if let Some(ts) = h.last_message_ms {
278                last_msgs.push(ts);
279            }
280        }
281
282        if !any_connected {
283            return None;
284        }
285
286        let rtt_ms = if rtts.is_empty() {
287            None
288        } else {
289            rtts.sort_unstable();
290            Some(rtts[rtts.len() / 2])
291        };
292
293        Some(WsHealth {
294            connected: true,
295            rtt_ms,
296            last_message_ms: last_msgs.into_iter().max(),
297        })
298    }
299
300    /// Eagerly connect to every exchange in `exchanges` and pre-load their
301    /// full symbol list. Subscribes nothing — produces only
302    /// `Event::ConnectorReady` (one per exchange that finishes
303    /// `connect_public`) and `Event::SymbolsLoaded` (one per exchange whose
304    /// REST `get_exchange_info` succeeds for at least one account type) on
305    /// the broadcast channel returned by `Station::connector_events()`.
306    ///
307    /// Idempotent: running concurrently or repeatedly is safe.
308    /// Already-connected exchanges short-circuit. Already-cached symbol lists
309    /// re-broadcast from cache without REST.
310    ///
311    /// Runs to completion — returns a [`WarmupReport`] of outcomes.
312    pub async fn warmup(&self, exchanges: &[ExchangeId]) -> crate::subscription::WarmupReport {
313        use crate::subscription::{Event, WarmupReport};
314
315        let mut ok = Vec::new();
316        let mut failed: Vec<(ExchangeId, String)> = Vec::new();
317
318        // Phase 1: connect all exchanges (spawn concurrent tasks).
319        #[cfg(not(target_arch = "wasm32"))]
320        {
321            let mut join_set: tokio::task::JoinSet<(ExchangeId, Result<()>)> =
322                tokio::task::JoinSet::new();
323
324            for &eid in exchanges {
325                if self.inner.hub.is_connected(eid) {
326                    let _ = self.inner.connector_tx.send(Event::ConnectorReady { exchange: eid });
327                    ok.push(eid);
328                } else {
329                    let hub = Arc::clone(&self.inner.hub);
330                    join_set.spawn(async move {
331                        (eid, hub.connect_public(eid, false).await.map_err(|e| {
332                            crate::StationError::Core(e.to_string())
333                        }))
334                    });
335                }
336            }
337
338            while let Some(res) = join_set.join_next().await {
339                match res {
340                    Ok((eid, Ok(()))) => {
341                        let _ = self.inner.connector_tx.send(Event::ConnectorReady { exchange: eid });
342                        ok.push(eid);
343                    }
344                    Ok((eid, Err(e))) => {
345                        tracing::warn!(?eid, ?e, "warmup: connect_public failed");
346                        failed.push((eid, e.to_string()));
347                    }
348                    Err(join_err) => {
349                        tracing::warn!(?join_err, "warmup: task panicked");
350                    }
351                }
352            }
353        }
354        // wasm32: no JoinSet — run sequentially (wasm is single-threaded).
355        #[cfg(target_arch = "wasm32")]
356        {
357            for &eid in exchanges {
358                if self.inner.hub.is_connected(eid) {
359                    let _ = self.inner.connector_tx.send(Event::ConnectorReady { exchange: eid });
360                    ok.push(eid);
361                } else {
362                    match self.inner.hub.connect_public(eid, false).await {
363                        Ok(()) => {
364                            let _ = self.inner.connector_tx.send(Event::ConnectorReady { exchange: eid });
365                            ok.push(eid);
366                        }
367                        Err(e) => {
368                            tracing::warn!(?eid, ?e, "warmup: connect_public failed");
369                            failed.push((eid, e.to_string()));
370                        }
371                    }
372                }
373            }
374        }
375
376        // Phase 2: fetch exchange info for all successfully connected exchanges.
377        const ACCOUNT_TYPES: &[AccountType] = &[AccountType::Spot, AccountType::FuturesCross];
378
379        for eid in ok.iter().copied() {
380            let Some(connector) = self.inner.hub.rest(eid) else {
381                // REST connector absent — skip exchange-info silently.
382                continue;
383            };
384            for &at in ACCOUNT_TYPES {
385                // Cache hit: re-emit from cache, skip REST.
386                if let Some(cached) = self.inner.exchange_info_cache.get(&(eid, at)) {
387                    let symbols = cached.value().clone();
388                    let _ = self.inner.connector_tx.send(Event::SymbolsLoaded {
389                        exchange: eid,
390                        account_type: at,
391                        symbols,
392                    });
393                    continue;
394                }
395                // Cache miss: call REST.
396                match connector.get_exchange_info(at).await {
397                    Ok(symbols) if !symbols.is_empty() => {
398                        self.inner.exchange_info_cache.insert((eid, at), symbols.clone());
399                        let _ = self.inner.connector_tx.send(Event::SymbolsLoaded {
400                            exchange: eid,
401                            account_type: at,
402                            symbols,
403                        });
404                    }
405                    Ok(_empty) => {
406                        // Empty list — account type not supported by this exchange, skip.
407                    }
408                    Err(e) => {
409                        use digdigdig3::core::types::ExchangeError;
410                        match &e {
411                            ExchangeError::NotSupported(_)
412                            | ExchangeError::UnsupportedOperation(_) => {
413                                // Expected for exchanges without futures or without
414                                // exchange-info REST — silent skip.
415                            }
416                            other => {
417                                tracing::warn!(?eid, ?at, ?other, "warmup: get_exchange_info failed");
418                                failed.push((eid, other.to_string()));
419                            }
420                        }
421                    }
422                }
423            }
424        }
425
426        WarmupReport { ok, failed }
427    }
428
429    /// Subscribe to every (exchange, symbol, account, stream) combination in
430    /// `set`. Continue-on-error: per-stream failures are collected in
431    /// [`SubscribeReport::failed`] and do not abort the rest of the batch.
432    ///
433    /// The returned `handle` carries events for every stream in `ok`. A
434    /// stream whose subscribe failed will simply not emit events through
435    /// the handle.
436    ///
437    /// The whole call returns `Err` ONLY for batch-level failures (empty
438    /// set). Per-stream failures (StreamNotSupported, connect_websocket,
439    /// symbol normalize) are reported via `report.failed`.
440    pub async fn subscribe(&self, set: SubscriptionSet) -> Result<SubscribeReport> {
441        if set.is_empty() {
442            return Err(StationError::Subscribe("empty SubscriptionSet".into()));
443        }
444
445        let (tx, rx) = mpsc::unbounded_channel::<Event>();
446        let mut refs: Vec<MultiplexRef> = Vec::new();
447        let mut ok: Vec<SeriesKey> = Vec::new();
448        let mut failed: Vec<FailedStream> = Vec::new();
449
450        for entry in set.entries {
451            // REST connector — needed for warm-start backfill (`get_recent_trades` /
452            // `get_klines`). Hub memoizes internally; idempotent. Errors here are
453            // logged-and-continued: WS-only subscribe still works without REST.
454            if let Err(e) = self
455                .inner
456                .hub
457                .connect_public(entry.exchange, false)
458                .await
459            {
460                tracing::debug!(?e, ?entry.exchange, "connect_public failed; warm-start REST backfill will be skipped");
461            }
462
463            // WS connect: skip only if ALL streams in this entry are poll-only
464            // (REST polling — never touch a WS connector). Derived streams DO
465            // need WS: they subscribe to WS-backed upstreams (Basis ← MarkPrice
466            // + IndexPrice; TradeToBar/Range/Tick/Volume/Footprint ← Trade), so
467            // the WS connector must be up before the derived forwarder spawns —
468            // otherwise the recursive upstream acquire fails with "ws handle
469            // missing post-connect". Mixed entries (e.g. [Trade, LongShortRatio])
470            // also need WS for the non-poll stream. Per-stream failures are
471            // reported in `failed`; only poll-only streams are excluded from that.
472            let needs_ws = entry.streams.iter().any(|s| {
473                s.to_kind().is_poll_only().is_none()
474            });
475
476            if needs_ws {
477                // For authenticated entries (private streams), open an
478                // authenticated WS connection.  Falls back to public WS on
479                // wasm32 where private WS auth is unavailable.
480                let ws_connect_result = if let Some(ref creds) = entry.credentials {
481                    #[cfg(not(target_arch = "wasm32"))]
482                    {
483                        self.inner
484                            .hub
485                            .connect_websocket_with_credentials(
486                                entry.exchange,
487                                entry.account_type,
488                                creds.clone(),
489                            )
490                            .await
491                    }
492                    #[cfg(target_arch = "wasm32")]
493                    {
494                        let _ = creds;
495                        Err(digdigdig3::core::types::ExchangeError::UnsupportedOperation(
496                            "private WS streams not supported on wasm32".into(),
497                        ))
498                    }
499                } else {
500                    self.inner
501                        .hub
502                        .connect_websocket(entry.exchange, entry.account_type, false)
503                        .await
504                };
505                if let Err(e) = ws_connect_result
506                {
507                    let err_msg = format!("connect_websocket: {e}");
508                    for s in &entry.streams {
509                        // Only poll-only streams survive a WS-connect failure —
510                        // derived streams need WS upstreams, so a WS failure fails
511                        // them too.
512                        if s.to_kind().is_poll_only().is_some() {
513                            continue;
514                        }
515                        failed.push(FailedStream {
516                            exchange: entry.exchange,
517                            account_type: entry.account_type,
518                            symbol: entry.symbol.clone(),
519                            stream: s.clone(),
520                            error: StationError::Core(err_msg.clone()),
521                        });
522                    }
523                    // Only `continue` if there are no poll-only streams that can
524                    // still be acquired without WS.
525                    let has_non_ws = entry.streams.iter().any(|s| {
526                        s.to_kind().is_poll_only().is_some()
527                    });
528                    if !has_non_ws {
529                        continue;
530                    }
531                }
532            }
533
534            // Resolve to (canonical, raw exchange-native) pair.
535            //
536            // - `add_raw`: passthrough. `entry.symbol` is the wire format
537            //   already; canonical Symbol is built with empty base/quote +
538            //   the raw string as its `raw` field. This is the only path
539            //   that works for exotic instruments where BASE-QUOTE doesn't
540            //   apply (Deribit options "BTC-23MAY26-86000-C", dated
541            //   futures, index symbols, etc.).
542            // - `add` (canonical): parse "BTC-USDT"-style input, translate
543            //   to exchange-native via SymbolNormalizer.
544            let (canonical, raw) = if entry.is_raw {
545                (
546                    Symbol::with_raw("", "", entry.symbol.clone()),
547                    entry.symbol.clone(),
548                )
549            } else {
550                let canonical = parse_symbol(&entry.symbol);
551                match SymbolNormalizer::to_exchange(
552                    entry.exchange,
553                    &canonical,
554                    entry.account_type,
555                ) {
556                    Ok(r) => (canonical, r),
557                    Err(e) => {
558                        let err_msg = format!("symbol normalize: {e}");
559                        for s in &entry.streams {
560                            failed.push(FailedStream {
561                                exchange: entry.exchange,
562                                account_type: entry.account_type,
563                                symbol: entry.symbol.clone(),
564                                stream: s.clone(),
565                                error: StationError::Subscribe(err_msg.clone()),
566                            });
567                        }
568                        continue;
569                    }
570                }
571            };
572
573            // Part B seam: resolve display symbol → wire id for connectors where
574            // the WS subscribe frame coin differs from the caller-facing display
575            // name (HyperLiquid spot: "HYPE/USDC" → "@107"). The REST connector
576            // is always present before subscriptions (connect_public / connect_full
577            // is called before subscribe), and REST connectors self-warm their
578            // universe cache on first use (OnceCell). For all other venues the
579            // default impl is a passthrough (zero allocation, zero round-trip).
580            let raw = if let Some(rest) = self.inner.hub.rest(entry.exchange) {
581                rest.resolve_market_symbol(&raw, entry.account_type).await
582            } else {
583                raw
584            };
585
586            for s in &entry.streams {
587                let kind = s.to_kind();
588                let key = SeriesKey {
589                    exchange: entry.exchange,
590                    account_type: entry.account_type,
591                    symbol: raw.clone(),
592                    kind: kind.clone(),
593                };
594
595                let (bcast_tx, pending_seed) = match self
596                    .acquire_or_spawn(&key, &entry, &canonical, &raw, s)
597                    .await
598                {
599                    Ok(pair) => pair,
600                    Err(e) => {
601                        // NotSupported on a per-(exchange, kind) basis: log
602                        // at debug, record in `failed`, move on. Other errors
603                        // get an info-level log so they are not lost.
604                        if e.is_not_supported() {
605                            tracing::debug!(?key, ?e, "stream not supported; skipping");
606                        } else {
607                            tracing::info!(?key, ?e, "subscribe failed; skipping");
608                        }
609                        failed.push(FailedStream {
610                            exchange: entry.exchange,
611                            account_type: entry.account_type,
612                            symbol: entry.symbol.clone(),
613                            stream: s.clone(),
614                            error: e,
615                        });
616                        continue;
617                    }
618                };
619
620                let mut bcast_rx = bcast_tx.subscribe();
621                let tx_clone = tx.clone();
622                // Per-handle symbol label: relay rewrites Event.symbol from the
623                // raw exchange-native form (carried on the broadcast) to the
624                // user-input form THIS handle subscribed with. Two handles on
625                // the same multiplex with different input forms each see their
626                // own label.
627                let label = entry.symbol.clone();
628                {
629                    let relay_fut = Box::pin(async move {
630                        while let Ok(mut ev) = bcast_rx.recv().await {
631                            ev.set_symbol(label.clone());
632                            if tx_clone.send(ev).is_err() {
633                                break;
634                            }
635                        }
636                    });
637                    #[cfg(not(target_arch = "wasm32"))]
638                    tokio::spawn(relay_fut);
639                    #[cfg(target_arch = "wasm32")]
640                    wasm_bindgen_futures::spawn_local(relay_fut);
641                }
642
643                // For OrderbookDelta with REST seed: emit the snapshot NOW,
644                // after the relay task has subscribed to the broadcast channel.
645                // This guarantees the snapshot reaches the consumer's
646                // SubscriptionHandle::recv() — previously it was sent before
647                // any receiver existed and was silently dropped.
648                if let Some(seed_ev) = pending_seed {
649                    if bcast_tx.send(seed_ev).is_err() {
650                        tracing::debug!(
651                            target: "dig3::ob_seed",
652                            ?key,
653                            "ob delta seed: send failed after relay wired (unexpected)"
654                        );
655                    }
656                }
657
658                refs.push(MultiplexRef {
659                    station: Arc::downgrade(&self.inner),
660                    key: key.clone(),
661                });
662                ok.push(key);
663            }
664        }
665
666        Ok(SubscribeReport {
667            handle: SubscriptionHandle { rx, _refs: refs },
668            ok,
669            failed,
670        })
671    }
672
673    /// Acquire (or spawn) the multiplexer for `key`. Spawn includes:
674    /// - opening DiskStore<T> if persistence is on,
675    /// - seeding broadcast with last-N (warm-start) before any live event,
676    /// - issuing WS subscribe + forwarder task that runs until shutdown.
677    async fn acquire_or_spawn(
678        &self,
679        key: &SeriesKey,
680        entry: &Entry,
681        canonical: &Symbol,
682        raw_symbol: &str,
683        stream: &Stream,
684    ) -> Result<(broadcast::Sender<Event>, Option<Event>)> {
685        if let Some(mut mux) = self.inner.muxes.get_mut(key) {
686            // Cancel any pending grace-period timer — the forwarder is being
687            // reused before the grace window expired. Sending on grace_cancel
688            // unblocks the timer task's select! arm, which then exits without
689            // firing the shutdown signal.
690            if let Some(cancel) = mux.grace_cancel.take() {
691                let _ = cancel.send(());
692            }
693            mux.consumers.fetch_add(1, Ordering::SeqCst);
694            return Ok((mux.tx.clone(), None));
695        }
696
697        // --- Derived stream path (no WS, no REST) ---
698        // Must come BEFORE the ws handle resolution so we never call
699        // ws.subscribe() for a derived kind.
700        if key.kind.is_derived() {
701            return match &key.kind {
702                Kind::Basis => {
703                    self.acquire_or_spawn_derived::<BasisDerived>(key, entry, canonical, raw_symbol).await.map(|tx| (tx, None))
704                }
705                Kind::FundingSettlement => {
706                    self.acquire_or_spawn_derived::<FundingSettlementDerived>(key, entry, canonical, raw_symbol).await.map(|tx| (tx, None))
707                }
708                Kind::RangeBar(_) => {
709                    self.acquire_or_spawn_derived::<TradeToRangeBarDerived>(key, entry, canonical, raw_symbol).await.map(|tx| (tx, None))
710                }
711                Kind::TickBar(_) => {
712                    self.acquire_or_spawn_derived::<TradeToTickBarDerived>(key, entry, canonical, raw_symbol).await.map(|tx| (tx, None))
713                }
714                Kind::VolumeBar(_) => {
715                    self.acquire_or_spawn_derived::<TradeToVolumeBarDerived>(key, entry, canonical, raw_symbol).await.map(|tx| (tx, None))
716                }
717                Kind::Footprint(_) => {
718                    self.acquire_or_spawn_derived::<TradeToFootprintDerived>(key, entry, canonical, raw_symbol).await.map(|tx| (tx, None))
719                }
720                _ => unreachable!("is_derived() returned true for unhandled kind — update acquire_or_spawn dispatch"),
721            };
722        }
723
724        // --- Poll-only stream path (REST periodic polling, no WS) ---
725        // Must come BEFORE the ws.subscribe call so we never try to subscribe
726        // a WS channel for streams that have no WS feed.
727        #[cfg(not(target_arch = "wasm32"))]
728        if let Some(poll_spec) = key.kind.is_poll_only() {
729            return self.acquire_or_spawn_polled(key, entry, poll_spec, raw_symbol).await.map(|tx| (tx, None));
730        }
731        // On wasm, poll-only kinds are not supported (no tokio::time::interval).
732        #[cfg(target_arch = "wasm32")]
733        if key.kind.is_poll_only().is_some() {
734            return Err(StationError::StreamNotSupported(format!(
735                "poll-only streams not supported on wasm32 ({:?})",
736                key.kind
737            )));
738        }
739
740        let sym = Symbol::with_raw(&canonical.base, &canonical.quote, raw_symbol.to_string());
741        let req = ws_request_for(&key.kind, sym, entry.account_type);
742
743        let ws = self
744            .inner
745            .hub
746            .ws(entry.exchange, entry.account_type)
747            .ok_or_else(|| StationError::Core("ws handle missing post-connect".into()))?;
748        // `transport.rs::subscribe` eagerly invokes `subscribe_frame` and
749        // propagates any frame-construction failure (NotSupported and
750        // UnsupportedOperation included). Map those to
751        // `StreamNotSupported` so `Station::subscribe(set)` can bucket
752        // them into `SubscribeReport::failed` without spawning a forwarder
753        // that would loop in heal/resub forever (this is what caused
754        // MLI's 0.3.6 OOM — see release-0.3.7-plan.md).
755        //
756        // Special case — Kind::Kline(iv): if the venue does not natively
757        // support this interval on its WS, fall back to TradeToBarDerived
758        // (trade-aggregation engine) rather than returning a hard error.
759        // The fallback is attempted only when ws.subscribe fails with
760        // NotSupported / UnsupportedOperation; native kline paths are
761        // unchanged. If the interval string is unknown (interval_to_ms
762        // returns None) we cannot build the aggregator either — return a
763        // clear StreamNotSupported to the caller.
764        if let Err(e) = ws.subscribe(req.clone()).await {
765            use digdigdig3::core::types::WebSocketError;
766            let is_not_supported = matches!(
767                e,
768                WebSocketError::NotSupported(_) | WebSocketError::UnsupportedOperation(_)
769            );
770            if is_not_supported {
771                if let Kind::Kline(iv) = &key.kind {
772                    // Validate the interval before attempting the aggregator.
773                    if interval_to_ms(iv.as_str()).is_none() {
774                        return Err(StationError::StreamNotSupported(format!(
775                            "Kline interval {:?} is unknown — cannot aggregate from trades",
776                            iv.as_str()
777                        )));
778                    }
779                    tracing::debug!(
780                        target: "dig3::station::derived",
781                        exchange = ?key.exchange,
782                        symbol   = %key.symbol,
783                        interval = %iv,
784                        "native Kline WS not supported — falling back to TradeToBarDerived"
785                    );
786                    return self
787                        .acquire_or_spawn_derived::<TradeToBarDerived>(key, entry, canonical, raw_symbol)
788                        .await
789                        .map(|tx| (tx, None));
790                }
791            }
792            return Err(match e {
793                WebSocketError::NotSupported(msg)
794                | WebSocketError::UnsupportedOperation(msg) => {
795                    StationError::StreamNotSupported(msg)
796                }
797                other => StationError::Subscribe(format!("ws.subscribe: {other}")),
798            });
799        }
800
801        let (bcast_tx, _) = broadcast::channel::<Event>(1024);
802        let consumers = Arc::new(AtomicUsize::new(1));
803        let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
804
805        let _ = stream; // kept for future per-Stream parameter customizations
806
807        // For each kind, compute the REST backfill seed (used when disk is
808        // empty), then spawn the typed forwarder. Backfill is best-effort —
809        // empty Vec on any failure or unsupported endpoint.
810        let warm_n = self.inner.warm_start_capacity;
811        let hub = self.inner.hub.clone();
812        let acct = entry.account_type;
813        let raw_s = raw_symbol.to_string();
814
815        // Populated by Kind::OrderbookDelta when orderbook_rest_seed=true.
816        // Returned to Station::subscribe so it can be sent AFTER the relay task
817        // has subscribed to the broadcast channel (fixes the race where the snapshot
818        // was dropped because no receivers existed yet).
819        let mut pending_seed: Option<Event> = None;
820
821        match &key.kind {
822            Kind::Trade => {
823                let seed = if warm_n > 0 {
824                    crate::backfill::trades_recent(&hub, key.exchange, acct, &raw_s, warm_n).await
825                } else { Vec::new() };
826                spawn_forwarder::<TradePoint>(self, key, ws, bcast_tx.clone(), shutdown_rx, key.symbol.clone(), seed, req.clone());
827            }
828            Kind::Kline(interval) => {
829                let seed = if warm_n > 0 {
830                    crate::backfill::klines_recent(&hub, key.exchange, acct, &raw_s, interval.as_str(), warm_n).await
831                } else { Vec::new() };
832                spawn_forwarder::<BarPoint>(self, key, ws, bcast_tx.clone(), shutdown_rx, key.symbol.clone(), seed, req.clone());
833            }
834            Kind::AggTrade => spawn_forwarder::<AggTradePoint>(self, key, ws, bcast_tx.clone(), shutdown_rx, key.symbol.clone(), Vec::new(), req.clone()),
835            Kind::Ticker => spawn_forwarder::<TickerPoint>(self, key, ws, bcast_tx.clone(), shutdown_rx, key.symbol.clone(), Vec::new(), req.clone()),
836            Kind::Orderbook => {
837                let ob_seed = if self.inner.orderbook_rest_seed {
838                    ob_rest_seed(&hub, key.exchange, acct, &raw_s, self.inner.orderbook_seed_depth).await
839                } else {
840                    Vec::new()
841                };
842                spawn_forwarder::<ObSnapshotPoint>(self, key, ws, bcast_tx.clone(), shutdown_rx, key.symbol.clone(), ob_seed, req.clone());
843            }
844            Kind::OrderbookDelta => {
845                // Seed via REST snapshot: gives downstream assemblers a seeded
846                // full-book state before deltas arrive. The seed event is NOT
847                // emitted here — it is returned as `pending_seed` and emitted by
848                // `Station::subscribe` AFTER the consumer's relay task has subscribed
849                // to the broadcast channel. Emitting before any receiver exists (the
850                // old behaviour) caused the snapshot to be silently dropped.
851                //
852                // Note: wasm32 skips REST seed (possible CORS), same as before.
853                pending_seed = if self.inner.orderbook_rest_seed {
854                    #[cfg(not(target_arch = "wasm32"))]
855                    {
856                        let snapshots = ob_rest_seed(&hub, key.exchange, acct, &raw_s, self.inner.orderbook_seed_depth).await;
857                        snapshots.into_iter().next().map(|point| Event::OrderbookSnapshot {
858                            exchange: key.exchange,
859                            symbol: raw_s.clone(),
860                            point,
861                        })
862                    }
863                    #[cfg(target_arch = "wasm32")]
864                    {
865                        tracing::warn!(
866                            target: "dig3::ob_seed",
867                            exchange = ?key.exchange, symbol = raw_s.as_str(),
868                            "orderbook REST seed for delta stream skipped on wasm32 (possible CORS) — continuing WS-only"
869                        );
870                        None
871                    }
872                } else {
873                    None
874                };
875                spawn_forwarder::<ObDeltaPoint>(self, key, ws, bcast_tx.clone(), shutdown_rx, key.symbol.clone(), Vec::new(), req.clone());
876            }
877            Kind::MarkPrice => spawn_forwarder::<MarkPricePoint>(self, key, ws, bcast_tx.clone(), shutdown_rx, key.symbol.clone(), Vec::new(), req.clone()),
878            Kind::FundingRate => spawn_forwarder::<FundingRatePoint>(self, key, ws, bcast_tx.clone(), shutdown_rx, key.symbol.clone(), Vec::new(), req.clone()),
879            Kind::OpenInterest => spawn_forwarder::<OpenInterestPoint>(self, key, ws, bcast_tx.clone(), shutdown_rx, key.symbol.clone(), Vec::new(), req.clone()),
880            Kind::Liquidation => spawn_forwarder::<LiquidationPoint>(self, key, ws, bcast_tx.clone(), shutdown_rx, key.symbol.clone(), Vec::new(), req.clone()),
881            Kind::BlockTrade => spawn_forwarder::<BlockTradePoint>(self, key, ws, bcast_tx.clone(), shutdown_rx, key.symbol.clone(), Vec::new(), req.clone()),
882            Kind::IndexPrice => spawn_forwarder::<IndexPricePoint>(self, key, ws, bcast_tx.clone(), shutdown_rx, key.symbol.clone(), Vec::new(), req.clone()),
883            Kind::CompositeIndex => spawn_forwarder::<CompositeIndexPoint>(self, key, ws, bcast_tx.clone(), shutdown_rx, key.symbol.clone(), Vec::new(), req.clone()),
884            Kind::OptionGreeks => spawn_forwarder::<OptionGreeksPoint>(self, key, ws, bcast_tx.clone(), shutdown_rx, key.symbol.clone(), Vec::new(), req.clone()),
885            Kind::VolatilityIndex => spawn_forwarder::<VolatilityIndexPoint>(self, key, ws, bcast_tx.clone(), shutdown_rx, key.symbol.clone(), Vec::new(), req.clone()),
886            Kind::HistoricalVolatility => spawn_forwarder::<HistoricalVolatilityPoint>(self, key, ws, bcast_tx.clone(), shutdown_rx, key.symbol.clone(), Vec::new(), req.clone()),
887            // LongShortRatio: poll-only, unreachable in normal operation (the
888            // is_poll_only() branch above handles this first). Kept as defensive
889            // fallback so the match arm is exhaustive.
890            Kind::LongShortRatio => spawn_forwarder::<LongShortRatioPoint>(self, key, ws, bcast_tx.clone(), shutdown_rx, key.symbol.clone(), Vec::new(), req.clone()),
891            Kind::Basis => spawn_forwarder::<BasisPoint>(self, key, ws, bcast_tx.clone(), shutdown_rx, key.symbol.clone(), Vec::new(), req.clone()),
892            Kind::InsuranceFund => spawn_forwarder::<InsuranceFundPoint>(self, key, ws, bcast_tx.clone(), shutdown_rx, key.symbol.clone(), Vec::new(), req.clone()),
893            Kind::OrderbookL3 => spawn_forwarder::<OrderbookL3Point>(self, key, ws, bcast_tx.clone(), shutdown_rx, key.symbol.clone(), Vec::new(), req.clone()),
894            Kind::SettlementEvent => spawn_forwarder::<SettlementEventPoint>(self, key, ws, bcast_tx.clone(), shutdown_rx, key.symbol.clone(), Vec::new(), req.clone()),
895            Kind::MarketWarning => spawn_forwarder::<MarketWarningPoint>(self, key, ws, bcast_tx.clone(), shutdown_rx, key.symbol.clone(), Vec::new(), req.clone()),
896            Kind::RiskLimit => spawn_forwarder::<RiskLimitPoint>(self, key, ws, bcast_tx.clone(), shutdown_rx, key.symbol.clone(), Vec::new(), req.clone()),
897            Kind::PredictedFunding => spawn_forwarder::<PredictedFundingPoint>(self, key, ws, bcast_tx.clone(), shutdown_rx, key.symbol.clone(), Vec::new(), req.clone()),
898            Kind::FundingSettlement => spawn_forwarder::<FundingSettlementPoint>(self, key, ws, bcast_tx.clone(), shutdown_rx, key.symbol.clone(), Vec::new(), req.clone()),
899            Kind::MarkPriceKline(_) => spawn_forwarder::<MarkPriceKlinePoint>(self, key, ws, bcast_tx.clone(), shutdown_rx, key.symbol.clone(), Vec::new(), req.clone()),
900            Kind::IndexPriceKline(_) => spawn_forwarder::<IndexPriceKlinePoint>(self, key, ws, bcast_tx.clone(), shutdown_rx, key.symbol.clone(), Vec::new(), req.clone()),
901            Kind::PremiumIndexKline(_) => spawn_forwarder::<PremiumIndexKlinePoint>(self, key, ws, bcast_tx.clone(), shutdown_rx, key.symbol.clone(), Vec::new(), req.clone()),
902            // Private streams — no warm-start seed, no persistence (ephemeral by design).
903            Kind::OrderUpdate => spawn_forwarder::<OrderUpdatePoint>(self, key, ws, bcast_tx.clone(), shutdown_rx, key.symbol.clone(), Vec::new(), req.clone()),
904            Kind::BalanceUpdate => spawn_forwarder::<BalanceUpdatePoint>(self, key, ws, bcast_tx.clone(), shutdown_rx, key.symbol.clone(), Vec::new(), req.clone()),
905            Kind::PositionUpdate => spawn_forwarder::<PositionUpdatePoint>(self, key, ws, bcast_tx.clone(), shutdown_rx, key.symbol.clone(), Vec::new(), req),
906            // Derived kinds — handled above by acquire_or_spawn_derived before
907            // reaching this match. These arms satisfy exhaustiveness only.
908            Kind::RangeBar(_) | Kind::TickBar(_) | Kind::VolumeBar(_) | Kind::Footprint(_) => {
909                unreachable!("derived kinds dispatched before forwarder match")
910            }
911        }
912
913        self.inner.muxes.insert(
914            key.clone(),
915            Multiplexer { tx: bcast_tx.clone(), consumers, shutdown: Some(shutdown_tx), grace_cancel: None },
916        );
917
918        Ok((bcast_tx, pending_seed))
919    }
920}
921
922impl Station {
923    /// Acquire (or spawn) a derived-stream multiplexer for `key`.
924    ///
925    /// Recursively calls `acquire_or_spawn` for each upstream dep (which
926    /// follows the normal WS path), subscribes to each upstream broadcast,
927    /// then spawns `spawn_derived_forwarder<D>` to run the computation.
928    ///
929    /// Ref-counting: each upstream `acquire_or_spawn` call increments the
930    /// upstream `consumers` counter by 1 (for the derived forwarder's benefit).
931    /// When the derived forwarder exits it calls `inner.release_consumer` on
932    /// each upstream key, propagating shutdown upward if no other consumer
933    /// holds the upstream.
934    /// Acquire (or spawn) a derived multiplexer — **native** path.
935    /// Returns `Pin<Box<dyn Future + Send>>` so the future can be awaited
936    /// from `acquire_or_spawn` which is itself spawned via `tokio::spawn` on native.
937    #[cfg(not(target_arch = "wasm32"))]
938    fn acquire_or_spawn_derived<'a, D: DerivedStream>(
939        &'a self,
940        key: &'a SeriesKey,
941        entry: &'a Entry,
942        canonical: &'a digdigdig3::core::types::Symbol,
943        raw_symbol: &'a str,
944    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<broadcast::Sender<Event>>> + Send + 'a>>
945    where
946        Event: EventFrom<D::Output>,
947    {
948        Box::pin(async move {
949            self.acquire_or_spawn_derived_body::<D>(key, entry, canonical, raw_symbol).await
950        })
951    }
952
953    /// Acquire (or spawn) a derived multiplexer — **wasm32** path.
954    /// No `Send` bound — wasm is single-threaded and all futures are `!Send`.
955    #[cfg(target_arch = "wasm32")]
956    fn acquire_or_spawn_derived<'a, D: DerivedStream>(
957        &'a self,
958        key: &'a SeriesKey,
959        entry: &'a Entry,
960        canonical: &'a digdigdig3::core::types::Symbol,
961        raw_symbol: &'a str,
962    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<broadcast::Sender<Event>>> + 'a>>
963    where
964        Event: EventFrom<D::Output>,
965    {
966        Box::pin(async move {
967            self.acquire_or_spawn_derived_body::<D>(key, entry, canonical, raw_symbol).await
968        })
969    }
970
971    /// Shared body for `acquire_or_spawn_derived` — called from both cfg variants.
972    async fn acquire_or_spawn_derived_body<D: DerivedStream>(
973        &self,
974        key: &SeriesKey,
975        entry: &Entry,
976        canonical: &digdigdig3::core::types::Symbol,
977        raw_symbol: &str,
978    ) -> Result<broadcast::Sender<Event>>
979    where
980        Event: EventFrom<D::Output>,
981    {
982        let mut upstream_rxs: Vec<broadcast::Receiver<Event>> = Vec::new();
983        let mut upstream_keys: Vec<SeriesKey> = Vec::new();
984
985        for dep_stream in D::deps() {
986            let dep_kind = dep_stream.to_kind();
987            debug_assert!(
988                !dep_kind.is_derived(),
989                "DerivedStream::deps() must not list derived kinds (no derived-of-derived)"
990            );
991            let dep_key = SeriesKey {
992                exchange: key.exchange,
993                account_type: key.account_type,
994                symbol: raw_symbol.to_string(),
995                kind: dep_kind,
996            };
997            // Recursive call — follows the normal WS path for each upstream kind.
998            // The pending_seed (second tuple element) is intentionally ignored here:
999            // derived streams subscribe to the upstream broadcast directly, not through
1000            // a consumer relay, so the seed will be replayed naturally through the
1001            // upstream forwarder's warm-start mechanism.
1002            let (up_tx, _) = self
1003                .acquire_or_spawn(&dep_key, entry, canonical, raw_symbol, dep_stream)
1004                .await?;
1005            upstream_rxs.push(up_tx.subscribe());
1006            upstream_keys.push(dep_key);
1007        }
1008
1009        let (bcast_tx, _) = broadcast::channel::<Event>(512);
1010        let consumers = Arc::new(AtomicUsize::new(1));
1011        let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
1012
1013        spawn_derived_forwarder::<D>(
1014            self,
1015            key,
1016            upstream_rxs,
1017            upstream_keys,
1018            bcast_tx.clone(),
1019            shutdown_rx,
1020            raw_symbol.to_string(),
1021        );
1022
1023        self.inner.muxes.insert(
1024            key.clone(),
1025            Multiplexer { tx: bcast_tx.clone(), consumers, shutdown: Some(shutdown_tx), grace_cancel: None },
1026        );
1027
1028        Ok(bcast_tx)
1029    }
1030}
1031
1032#[cfg(not(target_arch = "wasm32"))]
1033impl Station {
1034    /// Acquire (or spawn) a poll-driven multiplexer for `key`.
1035    ///
1036    /// Called when `key.kind.is_poll_only()` returns `Some(PollSpec)`. Skips
1037    /// `ws.subscribe` entirely and instead spawns a `spawn_poller<T, S>` actor
1038    /// driven by `tokio::time::interval`.
1039    async fn acquire_or_spawn_polled(
1040        &self,
1041        key: &SeriesKey,
1042        entry: &Entry,
1043        poll_spec: crate::series::PollSpec,
1044        raw_symbol: &str,
1045    ) -> Result<broadcast::Sender<Event>> {
1046        use crate::station::Multiplexer;
1047
1048        let (bcast_tx, _) = broadcast::channel::<Event>(1024);
1049        let consumers = Arc::new(AtomicUsize::new(1));
1050        let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
1051        let label = raw_symbol.to_string();
1052
1053        match &key.kind {
1054            Kind::LongShortRatio => {
1055                let source = polling::lsr_poll_source(entry.exchange)
1056                    .ok_or_else(|| StationError::StreamNotSupported(format!(
1057                        "LongShortRatio REST polling not supported for {:?}",
1058                        entry.exchange
1059                    )))?;
1060                polling::spawn_poller::<LongShortRatioPoint, _>(
1061                    self, key, source, poll_spec, bcast_tx.clone(), shutdown_rx, label,
1062                );
1063            }
1064            Kind::HistoricalVolatility => {
1065                let source = polling::hv_poll_source(entry.exchange)
1066                    .ok_or_else(|| StationError::StreamNotSupported(format!(
1067                        "HistoricalVolatility REST polling not supported for {:?} \
1068                         (Deribit only)",
1069                        entry.exchange
1070                    )))?;
1071                polling::spawn_poller::<HistoricalVolatilityPoint, _>(
1072                    self, key, source, poll_spec, bcast_tx.clone(), shutdown_rx, label,
1073                );
1074            }
1075            other => {
1076                return Err(StationError::StreamNotSupported(format!(
1077                    "acquire_or_spawn_polled: no poll source for {:?}",
1078                    other
1079                )));
1080            }
1081        }
1082
1083        self.inner.muxes.insert(
1084            key.clone(),
1085            Multiplexer {
1086                tx: bcast_tx.clone(),
1087                consumers,
1088                shutdown: Some(shutdown_tx),
1089                grace_cancel: None,
1090            },
1091        );
1092        Ok(bcast_tx)
1093    }
1094}
1095
1096impl StationInner {
1097    pub(crate) fn release_consumer(self: &Arc<Self>, key: &SeriesKey) {
1098        let (became_zero, grace) = {
1099            let Some(mux) = self.muxes.get(key) else { return; };
1100            let prev = mux.consumers.fetch_sub(1, Ordering::SeqCst);
1101            (prev <= 1, self.unsubscribe_grace)
1102        };
1103
1104        if !became_zero {
1105            return;
1106        }
1107
1108        if grace.is_zero() {
1109            // Immediate shutdown — existing behaviour.
1110            if let Some((_, mut mux)) = self.muxes.remove(key) {
1111                if let Some(tx) = mux.shutdown.take() {
1112                    let _ = tx.send(());
1113                }
1114            }
1115            return;
1116        }
1117
1118        // Grace period: spawn a timer task. Store a cancel channel in the mux
1119        // so `acquire_or_spawn` can cancel it when a new subscriber arrives.
1120        let (cancel_tx, cancel_rx) = oneshot::channel::<()>();
1121
1122        // Store cancel_tx in the mux before spawning to avoid a race where
1123        // acquire_or_spawn could observe grace_cancel == None before the task
1124        // starts (extremely unlikely but theoretically possible on native).
1125        {
1126            let Some(mut mux) = self.muxes.get_mut(key) else { return; };
1127            mux.grace_cancel = Some(cancel_tx);
1128        }
1129
1130        let inner = Arc::clone(self);
1131        let key = key.clone();
1132
1133        let grace_fut = Box::pin(async move {
1134            // Race: grace timer vs cancel signal from acquire_or_spawn.
1135            #[cfg(not(target_arch = "wasm32"))]
1136            let timed_out = tokio::select! {
1137                _ = cancel_rx => false,
1138                _ = tokio::time::sleep(grace) => true,
1139            };
1140            #[cfg(target_arch = "wasm32")]
1141            let timed_out = tokio::select! {
1142                _ = cancel_rx => false,
1143                _ = gloo_timers::future::sleep(grace) => true,
1144            };
1145
1146            if timed_out {
1147                // Grace expired without a new subscriber — fire shutdown.
1148                // Double-check consumers == 0 as a safety net (the cancel
1149                // channel send happens before fetch_add, so a race that
1150                // increments consumers before we reach here is possible in
1151                // theory; the guard prevents a spurious kill).
1152                let still_zero = inner
1153                    .muxes
1154                    .get(&key)
1155                    .map(|m| m.consumers.load(Ordering::SeqCst) == 0)
1156                    .unwrap_or(false);
1157                if still_zero {
1158                    if let Some((_, mut mux)) = inner.muxes.remove(&key) {
1159                        if let Some(tx) = mux.shutdown.take() {
1160                            let _ = tx.send(());
1161                        }
1162                    }
1163                    inner.series_handles.remove(&key);
1164                }
1165            }
1166        });
1167        #[cfg(not(target_arch = "wasm32"))]
1168        tokio::spawn(grace_fut);
1169        #[cfg(target_arch = "wasm32")]
1170        wasm_bindgen_futures::spawn_local(grace_fut);
1171    }
1172}
1173
1174/// Derived-stream actor. Consumes from N upstream broadcast channels via
1175/// `futures_util::stream::select_all`, runs the `DerivedStream` state machine,
1176/// and emits output to the derived stream's own broadcast channel.
1177///
1178/// On exit (shutdown signal or all upstreams closed):
1179/// - flushes disk store
1180/// - decrements consumer ref-count on each upstream key (RAII propagation)
1181/// - removes own mux entry if no consumers remain
1182fn spawn_derived_forwarder<D: DerivedStream + 'static>(
1183    station: &Station,
1184    key: &SeriesKey,
1185    upstream_rxs: Vec<broadcast::Receiver<Event>>,
1186    upstream_keys: Vec<SeriesKey>,
1187    bcast_tx: broadcast::Sender<Event>,
1188    mut shutdown_rx: oneshot::Receiver<()>,
1189    symbol_label: String,
1190) where
1191    Event: EventFrom<D::Output>,
1192{
1193    let inner = station.inner.clone();
1194    let key = key.clone();
1195    let storage_root = inner.storage_root.clone();
1196    let persistence = inner.persistence.clone();
1197    let warm = inner.warm_start_capacity;
1198    let exchange = key.exchange;
1199
1200    {
1201        let derived_fut = Box::pin(async move {
1202            // Open disk store if persistence is on for this kind (native only).
1203            #[cfg(not(target_arch = "wasm32"))]
1204            let mut disk: Option<DiskStore<D::Output>> = None;
1205            #[cfg(not(target_arch = "wasm32"))]
1206            if persistence.is_enabled_for(&key.kind) {
1207                match DiskStore::<D::Output>::new(&storage_root, key.clone()).await {
1208                    Ok(store) => disk = Some(store),
1209                    Err(e) => tracing::warn!(?e, ?key, "derived: disk store open failed"),
1210                }
1211            }
1212            // On wasm, no disk store.
1213            #[cfg(target_arch = "wasm32")]
1214            let _ = (&storage_root, &persistence);
1215
1216            let mut series = Series::<D::Output>::new(warm);
1217
1218            // Derived streams start with no warm-start / backfill — see spec §11.
1219            let mut state = D::new_for_key(&key);
1220
1221            // Convert each upstream Receiver into a tagged BroadcastStream so
1222            // the state machine can branch by dep_idx cheaply.
1223            let tagged: Vec<_> = upstream_rxs
1224                .into_iter()
1225                .enumerate()
1226                .map(|(idx, rx)| {
1227                    tokio_stream::wrappers::BroadcastStream::new(rx)
1228                        .filter_map(move |res| async move {
1229                            match res {
1230                                Ok(ev) => Some((idx, ev)),
1231                                Err(tokio_stream::wrappers::errors::BroadcastStreamRecvError::Lagged(n)) => {
1232                                    tracing::warn!(dep_idx = idx, lagged = n, "derived: upstream lagged — events dropped");
1233                                    None
1234                                }
1235                            }
1236                        })
1237                        // Box to make all stream types uniform for select_all.
1238                        .boxed()
1239                })
1240                .collect();
1241
1242            let mut merged = futures_util::stream::select_all(tagged);
1243
1244            loop {
1245                let item_opt = tokio::select! {
1246                    _ = &mut shutdown_rx => break,
1247                    item = merged.next() => item,
1248                };
1249
1250                let Some((dep_idx, ev)) = item_opt else {
1251                    // All upstreams closed their senders — derived stream is done.
1252                    tracing::info!(?key, "derived: all upstreams closed — exiting");
1253                    break;
1254                };
1255
1256                if let Some(point) = state.on_upstream_event(&ev, dep_idx) {
1257                    #[cfg(not(target_arch = "wasm32"))]
1258                    if let Some(d) = disk.as_mut() {
1259                        if let Err(e) = d.append(&point) {
1260                            tracing::warn!(?e, "derived: disk store append failed");
1261                        }
1262                    }
1263                    series.push(point.clone());
1264                    let _ = bcast_tx.send(Event::from_point(exchange, key.account_type, &symbol_label, &key.kind, point));
1265                }
1266            }
1267
1268            #[cfg(not(target_arch = "wasm32"))]
1269            if let Some(mut d) = disk { let _ = d.flush().await; }
1270            let _ = series;
1271
1272            // Release upstream consumer refs — propagates shutdown upward if the
1273            // derived forwarder was the only consumer of its upstream muxes.
1274            for up_key in &upstream_keys {
1275                inner.release_consumer(up_key);
1276            }
1277
1278            // Remove own mux entry if no consumers remain.
1279            let still_consumers = inner
1280                .muxes
1281                .get(&key)
1282                .map(|m| m.consumers.load(Ordering::SeqCst))
1283                .unwrap_or(0);
1284            if still_consumers == 0 {
1285                inner.muxes.remove(&key);
1286            }
1287        });
1288        #[cfg(not(target_arch = "wasm32"))]
1289        tokio::spawn(derived_fut);
1290        #[cfg(target_arch = "wasm32")]
1291        wasm_bindgen_futures::spawn_local(derived_fut);
1292    }
1293}
1294
1295/// Generic per-kind forwarder. Owns:
1296/// - DiskStore<T> (Option; on if persistence enabled),
1297/// - in-memory Series<T> (capacity = warm_start_capacity, kept as scratch),
1298/// - WS event stream.
1299///
1300/// On spawn: emits warm-start tail from DiskStore (if any) as `Event`s to
1301/// broadcast. Then transitions to live mode: each StreamEvent → DataPoint::from
1302/// → write disk → push memory → emit broadcast Event.
1303fn spawn_forwarder<T: DataPoint + 'static>(
1304    station: &Station,
1305    key: &SeriesKey,
1306    ws: Arc<dyn digdigdig3::core::traits::WebSocketConnector>,
1307    bcast_tx: broadcast::Sender<Event>,
1308    mut shutdown_rx: oneshot::Receiver<()>,
1309    symbol_label: String,
1310    // REST-backfill seed used when on-disk history is empty. Empty Vec
1311    // disables the REST fallback.
1312    rest_seed: Vec<T>,
1313    // Original subscribe request. Held so the forwarder can issue
1314    // unsubscribe + subscribe on disconnect to force a fresh subscription
1315    // state at the exchange.
1316    sub_req: SubscriptionRequest,
1317) where
1318    Event: EventFrom<T>,
1319{
1320    let inner = station.inner.clone();
1321    let key = key.clone();
1322    let storage_root = inner.storage_root.clone();
1323    let persistence = inner.persistence.clone();
1324    let warm = inner.warm_start_capacity;
1325    let exchange = key.exchange;
1326    let gap_cfg = inner.gap_heal;
1327    let hub_for_heal = inner.hub.clone();
1328
1329    // Create the shared series arc and register it in series_handles so
1330    // Station::series<T>() can hand it to render-time consumers synchronously.
1331    let shared_series: Arc<RwLock<Series<T>>> = Arc::new(RwLock::new(Series::new(warm)));
1332    // Type-erase: store Arc<RwLock<Series<T>>> inside an Arc<dyn Any+Send+Sync>.
1333    // The outer Arc is what Any::downcast_ref will find the concrete type on.
1334    let erased: Arc<dyn Any + Send + Sync> = Arc::new(Arc::clone(&shared_series));
1335    inner.series_handles.insert(key.clone(), erased);
1336
1337    {
1338    let forwarder_fut = Box::pin(async move {
1339        // Open disk store if persistence is on for this kind.
1340        // Native: std::fs-backed DiskStore. Wasm: OPFS-backed DiskStore.
1341        #[cfg(not(target_arch = "wasm32"))]
1342        let mut disk: Option<DiskStore<T>> = None;
1343        #[cfg(not(target_arch = "wasm32"))]
1344        if persistence.is_enabled_for(&key.kind) {
1345            match DiskStore::<T>::new(&storage_root, key.clone()).await {
1346                Ok(store) => disk = Some(store),
1347                Err(e) => tracing::warn!(?e, ?key, "disk store open failed"),
1348            }
1349        }
1350        // Wasm: OPFS DiskStore (Wave 4-E).
1351        #[cfg(target_arch = "wasm32")]
1352        let mut disk: Option<DiskStore<T>> = None;
1353        #[cfg(target_arch = "wasm32")]
1354        if persistence.is_enabled_for(&key.kind) {
1355            match DiskStore::<T>::new(key.clone()).await {
1356                Ok(store) => disk = Some(store),
1357                Err(e) => tracing::warn!(?e, ?key, "wasm OPFS disk store open failed"),
1358            }
1359        }
1360        // Suppress unused warning on wasm when persistence is disabled.
1361        #[cfg(target_arch = "wasm32")]
1362        let _ = &storage_root;
1363
1364        // In-memory ring (warm capacity) — shared with Station::series<T>().
1365        // The forwarder is the sole writer; render-time consumers hold read
1366        // guards for snapshot access without awaiting an Event.
1367        let mut last_emitted_ms: i64 = 0;
1368
1369        // Warm-start. Priority: disk tail > REST seed.
1370        if warm > 0 {
1371            // Wave 4-D: both native and wasm read real disk/OPFS tail.
1372            let disk_tail: Vec<T> = if let Some(d) = disk.as_ref() {
1373                d.read_tail(warm).await.unwrap_or_default()
1374            } else {
1375                Vec::new()
1376            };
1377            let seed_points: Vec<T> = if disk_tail.is_empty() && !rest_seed.is_empty() {
1378                rest_seed
1379            } else {
1380                disk_tail
1381            };
1382            for p in &seed_points {
1383                let _ = bcast_tx.send(Event::from_point(exchange, key.account_type, &symbol_label, &key.kind, p.clone()));
1384                last_emitted_ms = last_emitted_ms.max(p.timestamp_ms());
1385            }
1386            shared_series.write().await.seed(seed_points);
1387        }
1388
1389        let mut stream = ws.event_stream();
1390        // Silence threshold: if `event_stream().next()` produces no event for
1391        // this long, the underlying WS is presumed dropped. Mirrors MLC
1392        // `ws_manager` behaviour (60s). Tunable via env for tests.
1393        #[cfg(not(target_arch = "wasm32"))]
1394        let silence_timeout = std::time::Duration::from_secs(
1395            std::env::var("DIG3_WS_SILENCE_SECS").ok().and_then(|s| s.parse().ok()).unwrap_or(60),
1396        );
1397        // Wasm (Wave 4-F): 60s silence watchdog via gloo_timers.
1398        // DIG3_WS_SILENCE_SECS is not readable on wasm32 (std::env absent);
1399        // default 60 s is hardcoded. Configurable at compile time if needed.
1400        #[cfg(target_arch = "wasm32")]
1401        let silence_timeout_ms: u32 = 60_000;
1402        // Debug-only: artificially slow down the per-event loop. Used by e2e
1403        // tests to force broadcast-channel overflow → `Lagged` error →
1404        // `stream_err` branch. Production callers leave this unset (0 ms).
1405        #[cfg(not(target_arch = "wasm32"))]
1406        let debug_slow_ms: u64 = std::env::var("DIG3_DEBUG_SLOW_CONSUMER_MS")
1407            .ok()
1408            .and_then(|s| s.parse().ok())
1409            .unwrap_or(0);
1410        // Wasm (Wave 4-E): flush OPFS every N events to bound data loss.
1411        // A periodic gloo interval is not used here — instead a simple
1412        // flush-every-N-appends strategy matches the forwarder's sync
1413        // append pattern without an extra concurrent task.
1414        #[cfg(target_arch = "wasm32")]
1415        let mut wasm_flush_counter: u32 = 0;
1416        #[cfg(target_arch = "wasm32")]
1417        const WASM_FLUSH_EVERY: u32 = 64;
1418
1419        loop {
1420            // Single select arm — exits via shutdown or detects disconnect
1421            // (None / Err / silence). All three cases = disconnect = heal.
1422            // Native: tokio::time::timeout for silence detection.
1423            // Wasm (4-F): gloo_timers::future::sleep race for silence detection.
1424            #[cfg(not(target_arch = "wasm32"))]
1425            let item_opt = tokio::select! {
1426                _ = &mut shutdown_rx => break,
1427                res = tokio::time::timeout(silence_timeout, stream.next()) => res,
1428            };
1429            #[cfg(target_arch = "wasm32")]
1430            // On wasm: race stream.next() against a gloo_timers sleep.
1431            // Returns Ok(Some(...)) for a real event, Ok(None) for stream end,
1432            // Err(()) for the silence timeout expiring.
1433            let item_opt: std::result::Result<
1434                Option<std::result::Result<_, digdigdig3::core::types::WebSocketError>>,
1435                (),
1436            > = tokio::select! {
1437                _ = &mut shutdown_rx => break,
1438                _ = gloo_timers::future::sleep(std::time::Duration::from_millis(silence_timeout_ms as u64)) => Err(()),
1439                item = stream.next() => Ok(item),
1440            };
1441
1442            let trigger_heal_reason: Option<&'static str> = match &item_opt {
1443                Err(_) => Some("silence_timeout"),
1444                Ok(None) => Some("stream_ended"),
1445                Ok(Some(Err(_))) => Some("stream_err"),
1446                Ok(Some(Ok(_))) => None,
1447            };
1448
1449            if let Some(reason) = trigger_heal_reason {
1450                // Heal + resub is kline-only. For non-kline kinds:
1451                // - REST cannot bridge the gap (no public endpoint for
1452                //   trade/OB/ticker/mark/funding/OI/liq history live-feed).
1453                // - Resub spam on a NotSupported stream was the trigger for
1454                //   MLI's 0.3.6 OOM — see release-0.3.7-plan.md.
1455                // - The transport-level UniversalWsTransport auto-reconnects
1456                //   internally; the forwarder does not need to resub manually.
1457                //
1458                // Non-kline behavior: log + exit the forwarder. The mux entry
1459                // is removed below so a later subscribe for the same key can
1460                // re-spawn cleanly.
1461                let is_kline_family = matches!(
1462                    &key.kind,
1463                    Kind::Kline(_) | Kind::MarkPriceKline(_)
1464                    | Kind::IndexPriceKline(_) | Kind::PremiumIndexKline(_)
1465                );
1466
1467                if !is_kline_family {
1468                    tracing::info!(
1469                        target: "dig3::gap_heal",
1470                        ?key, reason,
1471                        "non-kline stream disconnect — forwarder exiting (no resub for non-kline kinds)"
1472                    );
1473                    break;
1474                }
1475
1476                tracing::info!(target: "dig3::gap_heal", ?key, reason, "ws disconnect detected → heal + resub");
1477                // 1. REST heal (kline-only; no-op for non-kline kinds, which
1478                //    have already returned above). Wave 4-B: enabled on both
1479                //    targets. On wasm REST succeeds for the 9 proxy-override
1480                //    venues; silently returns empty for others until Wave 4-C.
1481                {
1482                    let mut series_guard = shared_series.write().await;
1483                    run_kline_heal::<T>(
1484                        &hub_for_heal, &key, &gap_cfg, &symbol_label,
1485                        last_emitted_ms, exchange,
1486                        &mut *series_guard, &mut disk, &bcast_tx,
1487                    ).await;
1488                    last_emitted_ms = last_emitted_ms.max(
1489                        series_guard.last().map(|p| p.timestamp_ms()).unwrap_or(0)
1490                    );
1491                }
1492                // 2. Force a fresh subscription state at the exchange.
1493                //    Unsubscribe is best-effort (the server may have already
1494                //    dropped us). Resubscribe must succeed or we log + retry
1495                //    on the next disconnect cycle.
1496                let unsub_res = ws.unsubscribe(sub_req.clone()).await;
1497                let sub_res = ws.subscribe(sub_req.clone()).await;
1498                tracing::info!(
1499                    target: "dig3::gap_heal",
1500                    ?key,
1501                    unsub_ok = unsub_res.is_ok(),
1502                    sub_ok = sub_res.is_ok(),
1503                    "resub cycle complete"
1504                );
1505                if let Err(e) = unsub_res {
1506                    tracing::debug!(target: "dig3::gap_heal", ?key, ?e, "unsubscribe failed (best-effort)");
1507                }
1508                if let Err(e) = sub_res {
1509                    tracing::warn!(target: "dig3::gap_heal", ?key, ?e, "resubscribe failed");
1510                }
1511                // 3. Re-attach broadcast receiver — picks up post-resub events.
1512                //    Explicit drop of the old stream first: BroadcastStream
1513                //    holds a Receiver whose internal ring buffer occupies
1514                //    `event_tx` capacity until dropped. Letting the old one
1515                //    go before subscribing a new one keeps receiver_count
1516                //    minimal across heal cycles.
1517                drop(stream);
1518                stream = ws.event_stream();
1519                continue;
1520            }
1521
1522            let ev = match item_opt {
1523                Ok(Some(Ok(ev))) => ev,
1524                _ => unreachable!(),
1525            };
1526
1527            if !event_matches_key(&ev, &key) {
1528                continue;
1529            }
1530            let Some(point) = T::from_stream_event(&ev) else {
1531                continue;
1532            };
1533
1534            // Wave 4-E: append on both targets (native std::fs, wasm OPFS).
1535            // Native append returns Result; wasm append returns () (infallible — buffers in memory).
1536            #[cfg(not(target_arch = "wasm32"))]
1537            if let Some(d) = disk.as_mut() {
1538                if let Err(e) = d.append(&point) {
1539                    tracing::warn!(?e, "disk store append failed");
1540                }
1541            }
1542            #[cfg(target_arch = "wasm32")]
1543            if let Some(d) = disk.as_mut() {
1544                d.append(&point);
1545            }
1546            // Wasm (Wave 4-E): flush OPFS buffer periodically to bound data loss.
1547            #[cfg(target_arch = "wasm32")]
1548            {
1549                wasm_flush_counter = wasm_flush_counter.wrapping_add(1);
1550                if wasm_flush_counter % WASM_FLUSH_EVERY == 0 {
1551                    if let Some(d) = disk.as_mut() {
1552                        if let Err(e) = d.flush().await {
1553                            tracing::warn!(?e, "wasm OPFS periodic flush failed");
1554                        }
1555                    }
1556                }
1557            }
1558            let pt_ts = point.timestamp_ms();
1559            // Klines: multiple in-flight updates share open_time — upsert
1560            // keeps the ring deduplicated. Other kinds are monotonic.
1561            {
1562                let mut series_guard = shared_series.write().await;
1563                if matches!(&key.kind, Kind::Kline(_) | Kind::MarkPriceKline(_) | Kind::IndexPriceKline(_) | Kind::PremiumIndexKline(_)) {
1564                    series_guard.upsert_by_ts(point.clone());
1565                } else {
1566                    series_guard.push(point.clone());
1567                }
1568            }
1569            last_emitted_ms = last_emitted_ms.max(pt_ts);
1570
1571            let _ = bcast_tx.send(Event::from_point(exchange, key.account_type, &symbol_label, &key.kind, point));
1572
1573            #[cfg(not(target_arch = "wasm32"))]
1574            if debug_slow_ms > 0 {
1575                tokio::time::sleep(std::time::Duration::from_millis(debug_slow_ms)).await;
1576            }
1577        }
1578
1579        // Final flush on both targets (Wave 4-E: wasm flushes OPFS on shutdown).
1580        if let Some(mut d) = disk { let _ = d.flush().await; }
1581        // Remove the mux entry so a subsequent `subscribe` for the same key
1582        // can re-spawn a fresh forwarder. Without this, the dead mux would
1583        // sit in `inner.muxes`, and re-subscribe would attach to a broadcast
1584        // tx whose forwarder has already exited (no events ever arrive).
1585        //
1586        // Only remove if no consumer is left attached — otherwise a still-
1587        // alive consumer would think it has a working stream while we
1588        // silently tore it down. (`release_consumer` already removes on
1589        // refcount==0; here we cover the other path: forwarder ended on
1590        // its own before all consumers dropped.)
1591        let still_consumers = inner
1592            .muxes
1593            .get(&key)
1594            .map(|m| m.consumers.load(Ordering::SeqCst))
1595            .unwrap_or(0);
1596        if still_consumers == 0 {
1597            inner.muxes.remove(&key);
1598        }
1599        // Remove the series handle so Station::series<T>() returns None once
1600        // the forwarder is gone (same lifecycle as the mux entry).
1601        inner.series_handles.remove(&key);
1602    });
1603    #[cfg(not(target_arch = "wasm32"))]
1604    tokio::spawn(forwarder_fut);
1605    #[cfg(target_arch = "wasm32")]
1606    wasm_bindgen_futures::spawn_local(forwarder_fut);
1607    }
1608}
1609
1610/// Run a kline auto-heal triggered by WS disconnect. Called only for
1611/// `Kind::Kline`; no-op for other kinds. Wave 4-B: enabled on both targets.
1612async fn run_kline_heal<T: DataPoint + 'static>(
1613    hub: &Arc<ExchangeHub>,
1614    key: &SeriesKey,
1615    cfg: &crate::GapHealConfig,
1616    symbol_label: &str,
1617    last_emitted_ms: i64,
1618    exchange: digdigdig3::core::types::ExchangeId,
1619    series: &mut Series<T>,
1620    disk: &mut Option<DiskStore<T>>,
1621    bcast_tx: &broadcast::Sender<Event>,
1622) where
1623    Event: EventFrom<T>,
1624{
1625    if !cfg.enabled {
1626        return;
1627    }
1628    let Kind::Kline(iv) = &key.kind else { return; };
1629
1630    let now_ms = chrono::Utc::now().timestamp_millis();
1631    let limit = crate::gap_heal::heal_limit(cfg, iv.as_str(), last_emitted_ms, now_ms);
1632
1633    tracing::info!(
1634        target: "dig3::gap_heal",
1635        ?key,
1636        last_emitted_ms,
1637        limit,
1638        "kline heal: pulling REST"
1639    );
1640
1641    let pulled: Vec<T> = cast_vec(
1642        crate::gap_heal::heal_klines(hub, key.exchange, key.account_type, &key.symbol, iv.as_str(), last_emitted_ms, limit).await
1643    );
1644    let pulled_count = pulled.len();
1645
1646    // ALL pulled bars get upserted (last-write-wins overwrites any in-flight
1647    // broken live bar). Only bars strictly newer than last_emitted_ms are
1648    // EMITTED to consumers (the older ones already reached them as live).
1649    let new_to_emit = crate::gap_heal::select_heal_window(pulled.clone(), last_emitted_ms);
1650    let emitted_count = new_to_emit.len();
1651
1652    for p in pulled {
1653        if let Some(d) = disk.as_mut() {
1654            let _ = d.append(&p);
1655        }
1656        series.upsert_by_ts(p);
1657    }
1658    for p in new_to_emit {
1659        let _ = bcast_tx.send(Event::from_point(exchange, key.account_type, symbol_label, &key.kind, p));
1660    }
1661    if let Some(d) = disk.as_mut() { let _ = d.flush().await; }
1662
1663    tracing::info!(
1664        target: "dig3::gap_heal",
1665        ?key,
1666        pulled_count,
1667        emitted_count,
1668        "kline heal: applied"
1669    );
1670}
1671
1672/// Cast `Vec<A>` to `Vec<B>` when A == B at runtime via TypeId. Used to
1673/// bridge the kind-specific REST return type (`Vec<BarPoint>`) back to the
1674/// generic forwarder's `Vec<T>`. Safe when called at a site where the kind
1675/// match arm guarantees A == B.
1676fn cast_vec<A: 'static, B: 'static>(v: Vec<A>) -> Vec<B> {
1677    if std::any::TypeId::of::<A>() == std::any::TypeId::of::<B>() {
1678        // SAFETY: confirmed TypeId equality immediately above; memory layout
1679        // and ownership are identical between `Vec<A>` and `Vec<B>`.
1680        let mut v = std::mem::ManuallyDrop::new(v);
1681        let (ptr, len, cap) = (v.as_mut_ptr() as *mut B, v.len(), v.capacity());
1682        unsafe { Vec::from_raw_parts(ptr, len, cap) }
1683    } else {
1684        Vec::new()
1685    }
1686}
1687
1688/// Symbol-level routing: drop events whose `symbol` field doesn't match our key.
1689/// Every public-data variant now carries `symbol: String` on the variant itself.
1690fn event_matches_key(ev: &StreamEvent, key: &SeriesKey) -> bool {
1691    let want = key.symbol.as_str();
1692    let got: Option<&str> = event_raw_symbol(ev);
1693    match got {
1694        // Empty string = parser couldn't extract; let event through (dispatch is by SeriesKey at the channel level).
1695        Some("") => true,
1696        Some(s) => s.eq_ignore_ascii_case(want),
1697        None => true,
1698    }
1699}
1700
1701/// Extract the raw exchange-native symbol carried on a `StreamEvent` variant,
1702/// or `None` for private events that don't carry one in this dispatch model.
1703fn event_raw_symbol(ev: &StreamEvent) -> Option<&str> {
1704    match ev {
1705        StreamEvent::Trade { symbol, .. } => Some(symbol),
1706        StreamEvent::AggTrade { symbol, .. } => Some(symbol),
1707        StreamEvent::Ticker { symbol, .. } => Some(symbol),
1708        StreamEvent::Kline { symbol, .. } => Some(symbol),
1709        StreamEvent::OrderbookSnapshot { symbol, .. } => Some(symbol),
1710        StreamEvent::OrderbookDelta { symbol, .. } => Some(symbol),
1711        StreamEvent::MarkPrice { symbol, .. } => Some(symbol),
1712        StreamEvent::FundingRate { symbol, .. } => Some(symbol),
1713        StreamEvent::OpenInterestUpdate { symbol, .. } => Some(symbol),
1714        StreamEvent::Liquidation { symbol, .. } => Some(symbol),
1715        StreamEvent::LongShortRatio { symbol, .. } => Some(symbol),
1716        StreamEvent::MarkPriceKline { symbol, .. } => Some(symbol),
1717        StreamEvent::IndexPriceKline { symbol, .. } => Some(symbol),
1718        StreamEvent::PremiumIndexKline { symbol, .. } => Some(symbol),
1719        StreamEvent::IndexPrice { symbol, .. } => Some(symbol),
1720        StreamEvent::HistoricalVolatility { symbol, .. } => Some(symbol),
1721        StreamEvent::InsuranceFund { symbol, .. } => Some(symbol),
1722        StreamEvent::Basis { symbol, .. } => Some(symbol),
1723        StreamEvent::OptionGreeks { symbol, .. } => Some(symbol),
1724        StreamEvent::VolatilityIndex { symbol, .. } => Some(symbol),
1725        StreamEvent::BlockTrade { symbol, .. } => Some(symbol),
1726        StreamEvent::AuctionEvent { symbol, .. } => Some(symbol),
1727        StreamEvent::MarketWarning { symbol, .. } => symbol.as_deref(),
1728        StreamEvent::OrderbookL3 { symbol, .. } => Some(symbol),
1729        StreamEvent::SettlementEvent { symbol, .. } => Some(symbol),
1730        StreamEvent::RiskLimit { symbol, .. } => Some(symbol),
1731        StreamEvent::PredictedFunding { symbol, .. } => Some(symbol),
1732        StreamEvent::FundingSettlement { symbol, .. } => Some(symbol),
1733        StreamEvent::CompositeIndex { symbol, .. } => Some(symbol),
1734        // Private events — private dispatch isn't symbol-routed at the SeriesKey level.
1735        StreamEvent::OrderUpdate { symbol: _, event: _ }
1736        | StreamEvent::BalanceUpdate(_)
1737        | StreamEvent::PositionUpdate { symbol: _, event: _ } => None,
1738    }
1739}
1740
1741/// Trait wired by each `DataPoint` so the forwarder can build the right Event
1742/// variant. `pub(crate)` so `polling.rs` can use it in `spawn_poller`.
1743pub(crate) trait EventFrom<T> {
1744    fn from_point(
1745        exchange: digdigdig3::core::types::ExchangeId,
1746        account_type: digdigdig3::core::types::AccountType,
1747        symbol: &str,
1748        kind: &Kind,
1749        p: T,
1750    ) -> Self;
1751}
1752
1753impl EventFrom<TradePoint> for Event {
1754    fn from_point(exchange: digdigdig3::core::types::ExchangeId, _account_type: digdigdig3::core::types::AccountType, symbol: &str, _kind: &Kind, point: TradePoint) -> Self {
1755        Event::Trade { exchange, symbol: symbol.to_string(), point }
1756    }
1757}
1758impl EventFrom<AggTradePoint> for Event {
1759    fn from_point(exchange: digdigdig3::core::types::ExchangeId, _account_type: digdigdig3::core::types::AccountType, symbol: &str, _kind: &Kind, point: AggTradePoint) -> Self {
1760        Event::AggTrade { exchange, symbol: symbol.to_string(), point }
1761    }
1762}
1763impl EventFrom<BarPoint> for Event {
1764    fn from_point(exchange: digdigdig3::core::types::ExchangeId, _account_type: digdigdig3::core::types::AccountType, symbol: &str, kind: &Kind, point: BarPoint) -> Self {
1765        let timeframe = match kind { Kind::Kline(iv) => iv.clone(), _ => KlineInterval::new("") };
1766        Event::Bar { exchange, symbol: symbol.to_string(), timeframe, point }
1767    }
1768}
1769impl EventFrom<TickerPoint> for Event {
1770    fn from_point(exchange: digdigdig3::core::types::ExchangeId, _account_type: digdigdig3::core::types::AccountType, symbol: &str, _kind: &Kind, point: TickerPoint) -> Self {
1771        Event::Ticker { exchange, symbol: symbol.to_string(), point }
1772    }
1773}
1774impl EventFrom<ObSnapshotPoint> for Event {
1775    fn from_point(exchange: digdigdig3::core::types::ExchangeId, _account_type: digdigdig3::core::types::AccountType, symbol: &str, _kind: &Kind, point: ObSnapshotPoint) -> Self {
1776        Event::OrderbookSnapshot { exchange, symbol: symbol.to_string(), point }
1777    }
1778}
1779impl EventFrom<ObDeltaPoint> for Event {
1780    fn from_point(exchange: digdigdig3::core::types::ExchangeId, _account_type: digdigdig3::core::types::AccountType, symbol: &str, _kind: &Kind, point: ObDeltaPoint) -> Self {
1781        Event::OrderbookDelta { exchange, symbol: symbol.to_string(), point }
1782    }
1783}
1784impl EventFrom<MarkPricePoint> for Event {
1785    fn from_point(exchange: digdigdig3::core::types::ExchangeId, _account_type: digdigdig3::core::types::AccountType, symbol: &str, _kind: &Kind, point: MarkPricePoint) -> Self {
1786        Event::MarkPrice { exchange, symbol: symbol.to_string(), point }
1787    }
1788}
1789impl EventFrom<FundingRatePoint> for Event {
1790    fn from_point(exchange: digdigdig3::core::types::ExchangeId, _account_type: digdigdig3::core::types::AccountType, symbol: &str, _kind: &Kind, point: FundingRatePoint) -> Self {
1791        Event::FundingRate { exchange, symbol: symbol.to_string(), point }
1792    }
1793}
1794impl EventFrom<OpenInterestPoint> for Event {
1795    fn from_point(exchange: digdigdig3::core::types::ExchangeId, _account_type: digdigdig3::core::types::AccountType, symbol: &str, _kind: &Kind, point: OpenInterestPoint) -> Self {
1796        Event::OpenInterest { exchange, symbol: symbol.to_string(), point }
1797    }
1798}
1799impl EventFrom<LiquidationPoint> for Event {
1800    fn from_point(exchange: digdigdig3::core::types::ExchangeId, _account_type: digdigdig3::core::types::AccountType, symbol: &str, _kind: &Kind, point: LiquidationPoint) -> Self {
1801        Event::Liquidation { exchange, symbol: symbol.to_string(), point }
1802    }
1803}
1804impl EventFrom<BlockTradePoint> for Event {
1805    fn from_point(exchange: digdigdig3::core::types::ExchangeId, _account_type: digdigdig3::core::types::AccountType, symbol: &str, _kind: &Kind, point: BlockTradePoint) -> Self {
1806        Event::BlockTrade { exchange, symbol: symbol.to_string(), point }
1807    }
1808}
1809impl EventFrom<IndexPricePoint> for Event {
1810    fn from_point(exchange: digdigdig3::core::types::ExchangeId, _account_type: digdigdig3::core::types::AccountType, symbol: &str, _kind: &Kind, point: IndexPricePoint) -> Self {
1811        Event::IndexPrice { exchange, symbol: symbol.to_string(), point }
1812    }
1813}
1814impl EventFrom<CompositeIndexPoint> for Event {
1815    fn from_point(exchange: digdigdig3::core::types::ExchangeId, _account_type: digdigdig3::core::types::AccountType, symbol: &str, _kind: &Kind, point: CompositeIndexPoint) -> Self {
1816        Event::CompositeIndex { exchange, symbol: symbol.to_string(), point }
1817    }
1818}
1819impl EventFrom<OptionGreeksPoint> for Event {
1820    fn from_point(exchange: digdigdig3::core::types::ExchangeId, _account_type: digdigdig3::core::types::AccountType, symbol: &str, _kind: &Kind, point: OptionGreeksPoint) -> Self {
1821        Event::OptionGreeks { exchange, symbol: symbol.to_string(), point }
1822    }
1823}
1824impl EventFrom<VolatilityIndexPoint> for Event {
1825    fn from_point(exchange: digdigdig3::core::types::ExchangeId, _account_type: digdigdig3::core::types::AccountType, symbol: &str, _kind: &Kind, point: VolatilityIndexPoint) -> Self {
1826        Event::VolatilityIndex { exchange, symbol: symbol.to_string(), point }
1827    }
1828}
1829impl EventFrom<HistoricalVolatilityPoint> for Event {
1830    fn from_point(exchange: digdigdig3::core::types::ExchangeId, _account_type: digdigdig3::core::types::AccountType, symbol: &str, _kind: &Kind, point: HistoricalVolatilityPoint) -> Self {
1831        Event::HistoricalVolatility { exchange, symbol: symbol.to_string(), point }
1832    }
1833}
1834impl EventFrom<LongShortRatioPoint> for Event {
1835    fn from_point(exchange: digdigdig3::core::types::ExchangeId, _account_type: digdigdig3::core::types::AccountType, symbol: &str, _kind: &Kind, point: LongShortRatioPoint) -> Self {
1836        Event::LongShortRatio { exchange, symbol: symbol.to_string(), point }
1837    }
1838}
1839impl EventFrom<BasisPoint> for Event {
1840    fn from_point(exchange: digdigdig3::core::types::ExchangeId, _account_type: digdigdig3::core::types::AccountType, symbol: &str, _kind: &Kind, point: BasisPoint) -> Self {
1841        Event::Basis { exchange, symbol: symbol.to_string(), point }
1842    }
1843}
1844impl EventFrom<InsuranceFundPoint> for Event {
1845    fn from_point(exchange: digdigdig3::core::types::ExchangeId, _account_type: digdigdig3::core::types::AccountType, symbol: &str, _kind: &Kind, point: InsuranceFundPoint) -> Self {
1846        Event::InsuranceFund { exchange, symbol: symbol.to_string(), point }
1847    }
1848}
1849impl EventFrom<OrderbookL3Point> for Event {
1850    fn from_point(exchange: digdigdig3::core::types::ExchangeId, _account_type: digdigdig3::core::types::AccountType, symbol: &str, _kind: &Kind, point: OrderbookL3Point) -> Self {
1851        Event::OrderbookL3 { exchange, symbol: symbol.to_string(), point }
1852    }
1853}
1854impl EventFrom<SettlementEventPoint> for Event {
1855    fn from_point(exchange: digdigdig3::core::types::ExchangeId, _account_type: digdigdig3::core::types::AccountType, symbol: &str, _kind: &Kind, point: SettlementEventPoint) -> Self {
1856        Event::SettlementEvent { exchange, symbol: symbol.to_string(), point }
1857    }
1858}
1859impl EventFrom<MarketWarningPoint> for Event {
1860    fn from_point(exchange: digdigdig3::core::types::ExchangeId, _account_type: digdigdig3::core::types::AccountType, symbol: &str, _kind: &Kind, point: MarketWarningPoint) -> Self {
1861        Event::MarketWarning { exchange, symbol: symbol.to_string(), point }
1862    }
1863}
1864impl EventFrom<RiskLimitPoint> for Event {
1865    fn from_point(exchange: digdigdig3::core::types::ExchangeId, _account_type: digdigdig3::core::types::AccountType, symbol: &str, _kind: &Kind, point: RiskLimitPoint) -> Self {
1866        Event::RiskLimit { exchange, symbol: symbol.to_string(), point }
1867    }
1868}
1869impl EventFrom<PredictedFundingPoint> for Event {
1870    fn from_point(exchange: digdigdig3::core::types::ExchangeId, _account_type: digdigdig3::core::types::AccountType, symbol: &str, _kind: &Kind, point: PredictedFundingPoint) -> Self {
1871        Event::PredictedFunding { exchange, symbol: symbol.to_string(), point }
1872    }
1873}
1874impl EventFrom<FundingSettlementPoint> for Event {
1875    fn from_point(exchange: digdigdig3::core::types::ExchangeId, _account_type: digdigdig3::core::types::AccountType, symbol: &str, _kind: &Kind, point: FundingSettlementPoint) -> Self {
1876        Event::FundingSettlement { exchange, symbol: symbol.to_string(), point }
1877    }
1878}
1879impl EventFrom<MarkPriceKlinePoint> for Event {
1880    fn from_point(exchange: digdigdig3::core::types::ExchangeId, _account_type: digdigdig3::core::types::AccountType, symbol: &str, kind: &Kind, point: MarkPriceKlinePoint) -> Self {
1881        let timeframe = match kind { Kind::MarkPriceKline(iv) => iv.clone(), _ => KlineInterval::new("") };
1882        Event::MarkPriceKline { exchange, symbol: symbol.to_string(), timeframe, point }
1883    }
1884}
1885impl EventFrom<IndexPriceKlinePoint> for Event {
1886    fn from_point(exchange: digdigdig3::core::types::ExchangeId, _account_type: digdigdig3::core::types::AccountType, symbol: &str, kind: &Kind, point: IndexPriceKlinePoint) -> Self {
1887        let timeframe = match kind { Kind::IndexPriceKline(iv) => iv.clone(), _ => KlineInterval::new("") };
1888        Event::IndexPriceKline { exchange, symbol: symbol.to_string(), timeframe, point }
1889    }
1890}
1891impl EventFrom<PremiumIndexKlinePoint> for Event {
1892    fn from_point(exchange: digdigdig3::core::types::ExchangeId, _account_type: digdigdig3::core::types::AccountType, symbol: &str, kind: &Kind, point: PremiumIndexKlinePoint) -> Self {
1893        let timeframe = match kind { Kind::PremiumIndexKline(iv) => iv.clone(), _ => KlineInterval::new("") };
1894        Event::PremiumIndexKline { exchange, symbol: symbol.to_string(), timeframe, point }
1895    }
1896}
1897impl EventFrom<OrderUpdatePoint> for Event {
1898    fn from_point(exchange: digdigdig3::core::types::ExchangeId, account_type: digdigdig3::core::types::AccountType, symbol: &str, _kind: &Kind, point: OrderUpdatePoint) -> Self {
1899        Event::OrderUpdate { exchange, account_type, symbol: symbol.to_string(), point }
1900    }
1901}
1902impl EventFrom<BalanceUpdatePoint> for Event {
1903    fn from_point(exchange: digdigdig3::core::types::ExchangeId, account_type: digdigdig3::core::types::AccountType, symbol: &str, _kind: &Kind, point: BalanceUpdatePoint) -> Self {
1904        Event::BalanceUpdate { exchange, account_type, symbol: symbol.to_string(), point }
1905    }
1906}
1907impl EventFrom<PositionUpdatePoint> for Event {
1908    fn from_point(exchange: digdigdig3::core::types::ExchangeId, account_type: digdigdig3::core::types::AccountType, symbol: &str, _kind: &Kind, point: PositionUpdatePoint) -> Self {
1909        Event::PositionUpdate { exchange, account_type, symbol: symbol.to_string(), point }
1910    }
1911}
1912impl EventFrom<FootprintPoint> for Event {
1913    fn from_point(exchange: digdigdig3::core::types::ExchangeId, _account_type: digdigdig3::core::types::AccountType, symbol: &str, _kind: &Kind, point: FootprintPoint) -> Self {
1914        Event::Footprint { exchange, symbol: symbol.to_string(), point }
1915    }
1916}
1917
1918/// Fetch a REST orderbook snapshot and convert to `Vec<ObSnapshotPoint>`.
1919/// Returns an empty Vec on any failure (no REST, exchange error, empty book).
1920async fn ob_rest_seed(
1921    hub: &Arc<digdigdig3::connector_manager::ExchangeHub>,
1922    exchange: digdigdig3::core::types::ExchangeId,
1923    account: digdigdig3::core::types::AccountType,
1924    symbol: &str,
1925    depth: usize,
1926) -> Vec<ObSnapshotPoint> {
1927    let Some(rest) = hub.rest(exchange) else {
1928        tracing::warn!(
1929            target: "dig3::ob_seed",
1930            ?exchange, symbol,
1931            "orderbook REST seed: connector not initialized — continuing WS-only"
1932        );
1933        return Vec::new();
1934    };
1935    let depth_u16 = depth.min(u16::MAX as usize) as u16;
1936    match rest
1937        .get_orderbook(
1938            digdigdig3::core::types::SymbolInput::Raw(symbol),
1939            Some(depth_u16),
1940            account,
1941        )
1942        .await
1943    {
1944        Ok(ob) if ob.bids.is_empty() && ob.asks.is_empty() => {
1945            tracing::warn!(
1946                target: "dig3::ob_seed",
1947                ?exchange, symbol,
1948                "orderbook REST seed returned empty snapshot — continuing WS-only"
1949            );
1950            Vec::new()
1951        }
1952        Ok(ob) => {
1953            tracing::debug!(
1954                target: "dig3::ob_seed",
1955                ?exchange, symbol,
1956                bids = ob.bids.len(),
1957                asks = ob.asks.len(),
1958                "orderbook REST seed ok"
1959            );
1960            vec![ObSnapshotPoint::from_orderbook(&ob)]
1961        }
1962        Err(e) => {
1963            tracing::warn!(
1964                target: "dig3::ob_seed",
1965                ?exchange, symbol, ?e,
1966                "orderbook REST seed failed — continuing WS-only"
1967            );
1968            Vec::new()
1969        }
1970    }
1971}
1972
1973fn ws_request_for(
1974    kind: &Kind,
1975    sym: Symbol,
1976    account: digdigdig3::core::types::AccountType,
1977) -> SubscriptionRequest {
1978    let stream_type = match kind {
1979        Kind::Trade => StreamType::Trade,
1980        Kind::AggTrade => StreamType::AggTrade,
1981        Kind::Kline(iv) => StreamType::Kline { interval: iv.as_str().to_string() },
1982        Kind::Ticker => StreamType::Ticker,
1983        Kind::Orderbook => StreamType::Orderbook,
1984        Kind::OrderbookDelta => StreamType::OrderbookDelta,
1985        Kind::MarkPrice => StreamType::MarkPrice,
1986        Kind::FundingRate => StreamType::FundingRate,
1987        Kind::OpenInterest => StreamType::OpenInterest,
1988        Kind::Liquidation => StreamType::Liquidation,
1989        Kind::BlockTrade => StreamType::BlockTrade,
1990        Kind::IndexPrice => StreamType::IndexPrice,
1991        Kind::CompositeIndex => StreamType::CompositeIndex,
1992        Kind::OptionGreeks => StreamType::OptionGreeks,
1993        Kind::VolatilityIndex => StreamType::VolatilityIndex,
1994        Kind::HistoricalVolatility => StreamType::HistoricalVolatility,
1995        // LongShortRatio: poll-only kind. The WS arm is unreachable in normal
1996        // operation (acquire_or_spawn_polled handles it first), but kept as a
1997        // defensive fallback so the match is exhaustive.
1998        Kind::LongShortRatio => StreamType::LongShortRatio,
1999        Kind::Basis => StreamType::Basis,
2000        Kind::InsuranceFund => StreamType::InsuranceFund,
2001        Kind::OrderbookL3 => StreamType::OrderbookL3,
2002        Kind::SettlementEvent => StreamType::SettlementEvent,
2003        Kind::MarketWarning => StreamType::MarketWarning,
2004        Kind::RiskLimit => StreamType::RiskLimit,
2005        Kind::PredictedFunding => StreamType::PredictedFunding,
2006        Kind::FundingSettlement => StreamType::FundingSettlement,
2007        Kind::MarkPriceKline(iv) => StreamType::MarkPriceKline { interval: iv.as_str().to_string() },
2008        Kind::IndexPriceKline(iv) => StreamType::IndexPriceKline { interval: iv.as_str().to_string() },
2009        Kind::PremiumIndexKline(iv) => StreamType::PremiumIndexKline { interval: iv.as_str().to_string() },
2010        Kind::OrderUpdate => StreamType::OrderUpdate,
2011        Kind::BalanceUpdate => StreamType::BalanceUpdate,
2012        Kind::PositionUpdate => StreamType::PositionUpdate,
2013        // Derived kinds — never reach ws_request_for (acquire_or_spawn dispatches
2014        // them through acquire_or_spawn_derived before calling this function).
2015        Kind::RangeBar(_) | Kind::TickBar(_) | Kind::VolumeBar(_) | Kind::Footprint(_) => {
2016            unreachable!("derived kinds must not call ws_request_for")
2017        }
2018    };
2019    SubscriptionRequest {
2020        symbol: sym,
2021        stream_type,
2022        account_type: account,
2023        depth: None,
2024        update_speed_ms: None,
2025    }
2026}
2027
2028fn parse_symbol(s: &str) -> Symbol {
2029    if let Some((b, q)) = s.split_once(['-', '/', '_']) {
2030        return Symbol::new(b, q);
2031    }
2032    let upper = s.to_uppercase();
2033    for q in ["USDT", "USDC", "USD", "BTC", "ETH", "BUSD", "EUR", "JPY"] {
2034        if let Some(base) = upper.strip_suffix(q) {
2035            if !base.is_empty() {
2036                return Symbol::new(base, q);
2037            }
2038        }
2039    }
2040    Symbol::new(&upper, "")
2041}