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
44pub 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 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 pub(crate) unsubscribe_grace: std::time::Duration,
68 pub(crate) orderbook_rest_seed: bool,
71 pub(crate) orderbook_seed_depth: usize,
73 pub(crate) connector_tx: broadcast::Sender<crate::subscription::Event>,
77 pub(crate) exchange_info_cache: DashMap<(ExchangeId, AccountType), Vec<SymbolInfo>>,
80}
81
82pub(crate) struct Multiplexer {
85 pub(crate) tx: broadcast::Sender<Event>,
86 pub(crate) consumers: Arc<AtomicUsize>,
87 pub(crate) shutdown: Option<oneshot::Sender<()>>,
88 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 pub fn hub(&self) -> Arc<ExchangeHub> {
122 self.inner.hub.clone()
123 }
124
125 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 erased
145 .downcast_ref::<Arc<RwLock<Series<T>>>>()
146 .map(Arc::clone)
147 }
148
149 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 #[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 pub fn connector_events(&self) -> broadcast::Receiver<crate::subscription::Event> {
208 self.inner.connector_tx.subscribe()
209 }
210
211 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 pub fn ws_health(&self, key: &SeriesKey) -> Option<WsHealth> {
238 self.inner.muxes.get(key)?;
240 Some(WsHealth {
241 connected: true,
242 rtt_ms: None,
243 last_message_ms: None,
244 })
245 }
246
247 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 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 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 #[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 #[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 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 continue;
383 };
384 for &at in ACCOUNT_TYPES {
385 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 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 }
408 Err(e) => {
409 use digdigdig3::core::types::ExchangeError;
410 match &e {
411 ExchangeError::NotSupported(_)
412 | ExchangeError::UnsupportedOperation(_) => {
413 }
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 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 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 let needs_ws = entry.streams.iter().any(|s| {
473 s.to_kind().is_poll_only().is_none()
474 });
475
476 if needs_ws {
477 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 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 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 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 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 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 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 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 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 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 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 #[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 #[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 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 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; 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 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 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 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 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 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 #[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 #[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 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 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 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 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 let (cancel_tx, cancel_rx) = oneshot::channel::<()>();
1121
1122 {
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 #[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 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
1174fn 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 #[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 #[cfg(target_arch = "wasm32")]
1214 let _ = (&storage_root, &persistence);
1215
1216 let mut series = Series::<D::Output>::new(warm);
1217
1218 let mut state = D::new_for_key(&key);
1220
1221 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 .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 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 for up_key in &upstream_keys {
1275 inner.release_consumer(up_key);
1276 }
1277
1278 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
1295fn 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_seed: Vec<T>,
1313 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 let shared_series: Arc<RwLock<Series<T>>> = Arc::new(RwLock::new(Series::new(warm)));
1332 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 #[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 #[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 #[cfg(target_arch = "wasm32")]
1362 let _ = &storage_root;
1363
1364 let mut last_emitted_ms: i64 = 0;
1368
1369 if warm > 0 {
1371 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 #[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 #[cfg(target_arch = "wasm32")]
1401 let silence_timeout_ms: u32 = 60_000;
1402 #[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 #[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 #[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 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 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 {
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 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 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 #[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 #[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 {
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 if let Some(mut d) = disk { let _ = d.flush().await; }
1581 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 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
1610async 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 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
1672fn cast_vec<A: 'static, B: 'static>(v: Vec<A>) -> Vec<B> {
1677 if std::any::TypeId::of::<A>() == std::any::TypeId::of::<B>() {
1678 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
1688fn 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 Some("") => true,
1696 Some(s) => s.eq_ignore_ascii_case(want),
1697 None => true,
1698 }
1699}
1700
1701fn 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 StreamEvent::OrderUpdate { symbol: _, event: _ }
1736 | StreamEvent::BalanceUpdate(_)
1737 | StreamEvent::PositionUpdate { symbol: _, event: _ } => None,
1738 }
1739}
1740
1741pub(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
1918async 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 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 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}