Skip to main content

digdigdig3_station/
subscription.rs

1use digdigdig3::core::traits::Credentials;
2use digdigdig3::core::types::{AccountType, ExchangeId, SymbolInfo};
3use digdigdig3::core::websocket::KlineInterval;
4
5use crate::data::{
6    AggTradePoint, AuctionEventPoint, BalanceUpdatePoint, BarPoint, BasisPoint, BlockTradePoint,
7    CompositeIndexPoint,
8    FootprintPoint, FundingRatePoint, FundingSettlementPoint, HistoricalVolatilityPoint,
9    IndexPriceKlinePoint, IndexPricePoint, InsuranceFundPoint, LiquidationPoint,
10    LiquidationBucketPoint,
11    LongShortRatioPoint, MarkPriceKlinePoint, MarkPricePoint,
12    MarketWarningPoint, ObDeltaPoint, ObSnapshotPoint, OpenInterestPoint, OptionGreeksPoint,
13    OrderUpdatePoint, OrderbookL3Point, PositionUpdatePoint, PredictedFundingPoint,
14    PremiumIndexKlinePoint, RiskLimitPoint,
15    SettlementEventPoint, TakerVolumePoint, TickerPoint, TradePoint, VolatilityIndexPoint,
16    KagiSegmentPoint, PnfColumnPoint, RenkoBrickPoint, ScalarBarPoint,
17    ThreeLineBreakLinePoint, TpoSessionPoint,
18};
19use crate::series::{Kind, SeriesKey};
20
21/// User-facing stream class to request in a `SubscriptionSet`.
22///
23/// `Kline` carries a typed `KlineInterval` (e.g. `KlineInterval::new("1m")`).
24/// All other variants are parameterless.
25#[derive(Debug, Clone, PartialEq, Eq, Hash)]
26pub enum Stream {
27    Ticker,
28    Trade,
29    Orderbook,
30    OrderbookDelta,
31    Kline(KlineInterval),
32    MarkPrice,
33    FundingRate,
34    OpenInterest,
35    Liquidation,
36    AggTrade,
37    // --- extended stream types ---
38    BlockTrade,
39    AuctionEvent,
40    IndexPrice,
41    CompositeIndex,
42    OptionGreeks,
43    VolatilityIndex,
44    HistoricalVolatility,
45    LongShortRatio,
46    TakerVolume,
47    LiquidationBucket,
48    Basis,
49    InsuranceFund,
50    OrderbookL3,
51    SettlementEvent,
52    MarketWarning,
53    RiskLimit,
54    PredictedFunding,
55    FundingSettlement,
56    MarkPriceKline(KlineInterval),
57    IndexPriceKline(KlineInterval),
58    PremiumIndexKline(KlineInterval),
59    // --- derived bar aggregators ---
60    /// Range bar: close when |trade.price − bar_open| ≥ range.
61    /// `range` = price × 1e8 (fixed-point integer, same unit as `Kind::RangeBar`).
62    RangeBar(u64),
63    /// Tick bar: close every `n` trades.
64    TickBar(u32),
65    /// Volume bar: close when cumulative volume ≥ threshold.
66    /// `threshold` = volume × 1e8 (fixed-point integer, same unit as `Kind::VolumeBar`).
67    VolumeBar(u64),
68    /// Footprint bar: time-bucketed OHLCV with per-price buy/sell breakdown.
69    Footprint(KlineInterval),
70    /// Renko brick stream — `(box_size_e8, reversal_count)`.
71    /// See [`crate::series::Kind::RenkoBar`] for semantics.
72    RenkoBar(u64, u8),
73    /// Point-and-Figure column stream — `(box_size_e8, reversal_count)`.
74    PnfBar(u64, u8),
75    /// Kagi segment stream — `reversal_e8`.
76    KagiBar(u64),
77    /// Cumulative Volume Delta line.
78    CvdLine,
79    /// Three Line Break (san-sen-ashi) stream. `lines_back` controls how
80    /// many recent lines must be exceeded before a new line prints
81    /// (industry default = 3).
82    ThreeLineBreak { lines_back: u8 },
83    /// TPO Market Profile session stream.
84    /// See [`crate::series::TpoSource`] for the source-selector enum.
85    TpoProfile(u16, crate::series::TpoSource),
86    /// Dollar bar (López de Prado, AFML ch.2): close when cumulative
87    /// `price × quantity` ≥ `dollar_threshold`. See [`crate::series::Kind::DollarBar`].
88    DollarBar { dollar_threshold: u64 },
89    /// Tick Imbalance Bar (López de Prado, AFML ch.2).
90    /// `alpha_x100`: EMA smoothing × 100. `min_ticks`: floor.
91    /// See [`crate::series::Kind::TickImbalanceBar`].
92    TickImbalanceBar { alpha_x100: u16, min_ticks: u32 },
93    /// Volume Imbalance Bar (López de Prado, AFML ch.2).
94    /// See [`crate::series::Kind::VolumeImbalanceBar`].
95    VolumeImbalanceBar { alpha_x100: u16, min_ticks: u32 },
96    /// Run Bar (López de Prado, AFML ch.2).
97    /// See [`crate::series::Kind::RunBar`].
98    RunBar { alpha_x100: u16, min_ticks: u32 },
99    // --- private (auth-required) streams ---
100    /// Order lifecycle events (create/fill/cancel/expire).  Requires credentials.
101    OrderUpdate,
102    /// Account balance changes.  Requires credentials.
103    BalanceUpdate,
104    /// Futures position changes.  Requires credentials.
105    PositionUpdate,
106}
107
108impl Stream {
109    pub(crate) fn to_kind(&self) -> Kind {
110        match self {
111            Stream::Trade => Kind::Trade,
112            Stream::AggTrade => Kind::AggTrade,
113            Stream::Kline(iv) => Kind::Kline(iv.clone()),
114            Stream::Ticker => Kind::Ticker,
115            Stream::Orderbook => Kind::Orderbook,
116            Stream::OrderbookDelta => Kind::OrderbookDelta,
117            Stream::MarkPrice => Kind::MarkPrice,
118            Stream::FundingRate => Kind::FundingRate,
119            Stream::OpenInterest => Kind::OpenInterest,
120            Stream::Liquidation => Kind::Liquidation,
121            Stream::BlockTrade => Kind::BlockTrade,
122            Stream::AuctionEvent => Kind::AuctionEvent,
123            Stream::IndexPrice => Kind::IndexPrice,
124            Stream::CompositeIndex => Kind::CompositeIndex,
125            Stream::OptionGreeks => Kind::OptionGreeks,
126            Stream::VolatilityIndex => Kind::VolatilityIndex,
127            Stream::HistoricalVolatility => Kind::HistoricalVolatility,
128            Stream::LongShortRatio => Kind::LongShortRatio,
129            Stream::TakerVolume => Kind::TakerVolume,
130            Stream::LiquidationBucket => Kind::LiquidationBucket,
131            Stream::Basis => Kind::Basis,
132            Stream::InsuranceFund => Kind::InsuranceFund,
133            Stream::OrderbookL3 => Kind::OrderbookL3,
134            Stream::SettlementEvent => Kind::SettlementEvent,
135            Stream::MarketWarning => Kind::MarketWarning,
136            Stream::RiskLimit => Kind::RiskLimit,
137            Stream::PredictedFunding => Kind::PredictedFunding,
138            Stream::FundingSettlement => Kind::FundingSettlement,
139            Stream::MarkPriceKline(iv) => Kind::MarkPriceKline(iv.clone()),
140            Stream::IndexPriceKline(iv) => Kind::IndexPriceKline(iv.clone()),
141            Stream::PremiumIndexKline(iv) => Kind::PremiumIndexKline(iv.clone()),
142            Stream::RangeBar(r) => Kind::RangeBar(*r),
143            Stream::TickBar(n) => Kind::TickBar(*n),
144            Stream::VolumeBar(v) => Kind::VolumeBar(*v),
145            Stream::Footprint(iv) => Kind::Footprint(iv.clone()),
146            Stream::RenkoBar(b, r) => Kind::RenkoBar(*b, *r),
147            Stream::PnfBar(b, r) => Kind::PnfBar(*b, *r),
148            Stream::KagiBar(r) => Kind::KagiBar(*r),
149            Stream::CvdLine => Kind::CvdLine,
150            Stream::ThreeLineBreak { lines_back } => Kind::ThreeLineBreak { lines_back: *lines_back },
151            Stream::TpoProfile(freq, src) => Kind::TpoProfile(*freq, *src),
152            Stream::DollarBar { dollar_threshold } => Kind::DollarBar { dollar_threshold: *dollar_threshold },
153            Stream::TickImbalanceBar { alpha_x100, min_ticks } => Kind::TickImbalanceBar { alpha_x100: *alpha_x100, min_ticks: *min_ticks },
154            Stream::VolumeImbalanceBar { alpha_x100, min_ticks } => Kind::VolumeImbalanceBar { alpha_x100: *alpha_x100, min_ticks: *min_ticks },
155            Stream::RunBar { alpha_x100, min_ticks } => Kind::RunBar { alpha_x100: *alpha_x100, min_ticks: *min_ticks },
156            Stream::OrderUpdate => Kind::OrderUpdate,
157            Stream::BalanceUpdate => Kind::BalanceUpdate,
158            Stream::PositionUpdate => Kind::PositionUpdate,
159        }
160    }
161
162    /// True if this stream requires authentication credentials.
163    pub fn is_private(&self) -> bool {
164        matches!(self, Stream::OrderUpdate | Stream::BalanceUpdate | Stream::PositionUpdate)
165    }
166}
167
168#[derive(Debug, Clone)]
169pub(crate) struct Entry {
170    pub(crate) exchange: ExchangeId,
171    pub(crate) symbol: String,
172    pub(crate) account_type: AccountType,
173    pub(crate) streams: Vec<Stream>,
174    /// If true, `symbol` is the raw exchange-native string and must be
175    /// passed through to the WS connector verbatim — no `SymbolNormalizer`
176    /// translation. Set by `SubscriptionSet::add_raw`. Used for exotic
177    /// instrument IDs that don't fit the canonical BASE-QUOTE shape
178    /// (Deribit options like `BTC-23MAY26-86000-C`, exchange-specific
179    /// suffixes, etc.).
180    pub(crate) is_raw: bool,
181    /// Credentials for private (auth-required) streams.  `None` for public
182    /// streams.  Set by [`SubscriptionSet::add_authenticated`].
183    pub(crate) credentials: Option<Credentials>,
184    /// Per-subscribe override for the derived-stream cold-start warm depth
185    /// (aggTrade page count / kline-approx page count). `None` = fall back
186    /// to the Station-wide `warm_start_capacity`. Set by
187    /// [`SubscriptionSet::add_with_warm`]. Ignored for non-derived kinds.
188    pub(crate) warm_override: Option<usize>,
189}
190
191/// Declarative subscription request — built up fluently, consumed by
192/// [`crate::Station::subscribe`].
193#[derive(Debug, Default, Clone)]
194pub struct SubscriptionSet {
195    pub(crate) entries: Vec<Entry>,
196}
197
198impl SubscriptionSet {
199    pub fn new() -> Self { Self::default() }
200
201    /// Add a subscription. `symbol` is canonical (e.g. `"BTC-USDT"`,
202    /// `"BTCUSDT"`, `"BTC/USDT"`) — it is parsed into a canonical
203    /// `Symbol` and translated to the exchange-native form via
204    /// `SymbolNormalizer`. Use [`Self::add_raw`] for instrument IDs that
205    /// don't fit the canonical BASE-QUOTE shape (Deribit options,
206    /// exchange-specific futures suffixes, etc.).
207    pub fn add(
208        mut self,
209        exchange: ExchangeId,
210        symbol: impl Into<String>,
211        account_type: AccountType,
212        streams: impl IntoIterator<Item = Stream>,
213    ) -> Self {
214        self.entries.push(Entry {
215            exchange,
216            symbol: symbol.into(),
217            account_type,
218            streams: streams.into_iter().collect(),
219            is_raw: false,
220            credentials: None,
221            warm_override: None,
222        });
223        self
224    }
225
226    /// Add a subscription with an explicit warm-start depth override.
227    ///
228    /// `warm_n` replaces the Station-wide `warm_start_capacity` for this
229    /// entry's derived-stream cold-start seed (aggTrade page count for
230    /// count/volume-triggered bars; kline-approx page count for
231    /// price-path-triggered bars — see `station::acquire_or_spawn_derived_body`).
232    /// Non-derived (plain WS-backed) kinds ignore this — they always use
233    /// the Station-wide warm-start depth for their disk/REST seed.
234    ///
235    /// Otherwise identical to [`Self::add`] — `symbol` is canonical and
236    /// translated via `SymbolNormalizer`.
237    pub fn add_with_warm(
238        mut self,
239        exchange: ExchangeId,
240        symbol: impl Into<String>,
241        account_type: AccountType,
242        streams: impl IntoIterator<Item = Stream>,
243        warm_n: usize,
244    ) -> Self {
245        self.entries.push(Entry {
246            exchange,
247            symbol: symbol.into(),
248            account_type,
249            streams: streams.into_iter().collect(),
250            is_raw: false,
251            credentials: None,
252            warm_override: Some(warm_n),
253        });
254        self
255    }
256
257    /// Add a subscription with a raw exchange-native symbol. `symbol` is
258    /// passed through to the connector verbatim — no `SymbolNormalizer`
259    /// translation. Use for instrument IDs that don't fit the canonical
260    /// BASE-QUOTE shape:
261    /// - Deribit options: `"BTC-23MAY26-86000-C"`
262    /// - Futures with date suffix: `"BTCUSDT_240329"`
263    /// - Index symbols: `".DEFI"`, `"BTCUSD-PERP"`
264    ///
265    /// The caller is responsible for using the exact wire format the
266    /// exchange expects — `Event.symbol` on the handle will mirror the
267    /// raw string back.
268    pub fn add_raw(
269        mut self,
270        exchange: ExchangeId,
271        symbol: impl Into<String>,
272        account_type: AccountType,
273        streams: impl IntoIterator<Item = Stream>,
274    ) -> Self {
275        self.entries.push(Entry {
276            exchange,
277            symbol: symbol.into(),
278            account_type,
279            streams: streams.into_iter().collect(),
280            is_raw: true,
281            credentials: None,
282            warm_override: None,
283        });
284        self
285    }
286
287    /// Add authenticated (private) streams for `(exchange, account_type)`.
288    ///
289    /// Credentials are forwarded to the WS connector so it can open an
290    /// authenticated channel.  Multiple `add_authenticated` calls for the
291    /// same `(exchange, account_type)` pair will create separate entries;
292    /// Station's `acquire_or_spawn` reuses an already-connected authenticated
293    /// WS if one is present in the hub for that pair.
294    ///
295    /// `symbol` is ignored for account-wide private streams (`BalanceUpdate`).
296    /// Pass an empty string (`""`) or any placeholder — `Event::symbol()` for
297    /// those variants returns `""` or the asset identifier.
298    pub fn add_authenticated(
299        mut self,
300        exchange: ExchangeId,
301        account_type: AccountType,
302        credentials: Credentials,
303        streams: impl IntoIterator<Item = Stream>,
304    ) -> Self {
305        self.entries.push(Entry {
306            exchange,
307            symbol: String::new(),
308            account_type,
309            streams: streams.into_iter().collect(),
310            is_raw: true,
311            credentials: Some(credentials),
312            warm_override: None,
313        });
314        self
315    }
316
317    pub fn len(&self) -> usize { self.entries.len() }
318    pub fn is_empty(&self) -> bool { self.entries.is_empty() }
319}
320
321/// Events forwarded to consumers. One variant per market-data class.
322#[derive(Debug, Clone)]
323pub enum Event {
324    Trade {
325        exchange: ExchangeId,
326        symbol: String,
327        point: TradePoint,
328    },
329    AggTrade {
330        exchange: ExchangeId,
331        symbol: String,
332        point: AggTradePoint,
333    },
334    Bar {
335        exchange: ExchangeId,
336        symbol: String,
337        timeframe: KlineInterval,
338        point: BarPoint,
339    },
340    Ticker {
341        exchange: ExchangeId,
342        symbol: String,
343        point: TickerPoint,
344    },
345    OrderbookSnapshot {
346        exchange: ExchangeId,
347        symbol: String,
348        point: ObSnapshotPoint,
349    },
350    OrderbookDelta {
351        exchange: ExchangeId,
352        symbol: String,
353        point: ObDeltaPoint,
354    },
355    MarkPrice {
356        exchange: ExchangeId,
357        symbol: String,
358        point: MarkPricePoint,
359    },
360    FundingRate {
361        exchange: ExchangeId,
362        symbol: String,
363        point: FundingRatePoint,
364    },
365    OpenInterest {
366        exchange: ExchangeId,
367        symbol: String,
368        point: OpenInterestPoint,
369    },
370    Liquidation {
371        exchange: ExchangeId,
372        symbol: String,
373        point: LiquidationPoint,
374    },
375    // --- extended stream types ---
376    BlockTrade {
377        exchange: ExchangeId,
378        symbol: String,
379        point: BlockTradePoint,
380    },
381    AuctionEvent {
382        exchange: ExchangeId,
383        symbol: String,
384        point: AuctionEventPoint,
385    },
386    IndexPrice {
387        exchange: ExchangeId,
388        symbol: String,
389        point: IndexPricePoint,
390    },
391    CompositeIndex {
392        exchange: ExchangeId,
393        symbol: String,
394        point: CompositeIndexPoint,
395    },
396    OptionGreeks {
397        exchange: ExchangeId,
398        symbol: String,
399        point: OptionGreeksPoint,
400    },
401    VolatilityIndex {
402        exchange: ExchangeId,
403        symbol: String,
404        point: VolatilityIndexPoint,
405    },
406    HistoricalVolatility {
407        exchange: ExchangeId,
408        symbol: String,
409        point: HistoricalVolatilityPoint,
410    },
411    LongShortRatio {
412        exchange: ExchangeId,
413        symbol: String,
414        point: LongShortRatioPoint,
415    },
416    TakerVolume {
417        exchange: ExchangeId,
418        symbol: String,
419        point: TakerVolumePoint,
420    },
421    LiquidationBucket {
422        exchange: ExchangeId,
423        symbol: String,
424        point: LiquidationBucketPoint,
425    },
426    Basis {
427        exchange: ExchangeId,
428        symbol: String,
429        point: BasisPoint,
430    },
431    InsuranceFund {
432        exchange: ExchangeId,
433        symbol: String,
434        point: InsuranceFundPoint,
435    },
436    OrderbookL3 {
437        exchange: ExchangeId,
438        symbol: String,
439        point: OrderbookL3Point,
440    },
441    SettlementEvent {
442        exchange: ExchangeId,
443        symbol: String,
444        point: SettlementEventPoint,
445    },
446    MarketWarning {
447        exchange: ExchangeId,
448        symbol: String,
449        point: MarketWarningPoint,
450    },
451    RiskLimit {
452        exchange: ExchangeId,
453        symbol: String,
454        point: RiskLimitPoint,
455    },
456    PredictedFunding {
457        exchange: ExchangeId,
458        symbol: String,
459        point: PredictedFundingPoint,
460    },
461    FundingSettlement {
462        exchange: ExchangeId,
463        symbol: String,
464        point: FundingSettlementPoint,
465    },
466    MarkPriceKline {
467        exchange: ExchangeId,
468        symbol: String,
469        timeframe: KlineInterval,
470        point: MarkPriceKlinePoint,
471    },
472    IndexPriceKline {
473        exchange: ExchangeId,
474        symbol: String,
475        timeframe: KlineInterval,
476        point: IndexPriceKlinePoint,
477    },
478    PremiumIndexKline {
479        exchange: ExchangeId,
480        symbol: String,
481        timeframe: KlineInterval,
482        point: PremiumIndexKlinePoint,
483    },
484    // --- derived bar aggregator events ---
485    /// Footprint bar update. Emitted on every trade (open bar, same upsert
486    /// semantics as `Event::Bar`). `Kind::Footprint` carries the interval.
487    Footprint {
488        exchange: ExchangeId,
489        symbol: String,
490        point: FootprintPoint,
491    },
492    /// Renko brick close. Each event is one completed brick (no
493    /// in-progress emit — Renko bricks are atomic).
494    RenkoBar {
495        exchange: ExchangeId,
496        symbol: String,
497        point: RenkoBrickPoint,
498    },
499    /// Point-and-Figure column update — emitted on every trade that
500    /// touches the current column (in-progress upsert) plus on column
501    /// roll. Use `column_id` on the point to group emits into columns.
502    PnfBar {
503        exchange: ExchangeId,
504        symbol: String,
505        point: PnfColumnPoint,
506    },
507    /// Kagi segment / connector emit. One event per closed segment.
508    KagiBar {
509        exchange: ExchangeId,
510        symbol: String,
511        point: KagiSegmentPoint,
512    },
513    /// Running cumulative volume delta sample. Emitted per upstream
514    /// trade.
515    CvdLine {
516        exchange: ExchangeId,
517        symbol: String,
518        point: ScalarBarPoint,
519    },
520    /// Three Line Break line close. Each event is one completed line
521    /// (no in-progress emit — lines are atomic once a breakout or
522    /// reversal condition is met).
523    ThreeLineBreakUpdate {
524        exchange: ExchangeId,
525        symbol: String,
526        point: ThreeLineBreakLinePoint,
527    },
528    /// TPO Market Profile session snapshot. Emitted on every upstream
529    /// source event (trade or 1m kline); Series upserts on
530    /// `open_time = session_date_ms` so the on-disk record at any time
531    /// reflects the current intraday view of that session.
532    TpoProfile {
533        exchange: ExchangeId,
534        symbol: String,
535        point: TpoSessionPoint,
536    },
537    // --- connector lifecycle events (meta, not data stream) ---
538    /// Emitted once when `hub.connect_public(exchange)` succeeds, or
539    /// immediately if it was already connected when `warmup()` called.
540    ConnectorReady {
541        exchange: ExchangeId,
542    },
543    /// Emitted once per `(exchange, account_type)` after REST
544    /// `get_exchange_info` succeeds. Multiple emits per exchange if both
545    /// Spot and Futures resolve. `symbols` is the raw exchange response.
546    SymbolsLoaded {
547        exchange: ExchangeId,
548        account_type: AccountType,
549        symbols: Vec<SymbolInfo>,
550    },
551    // --- private (auth-required) events ---
552    /// Order lifecycle event (create/fill/cancel/expire).
553    /// `symbol` is the instrument symbol from the order, or `""` if the
554    /// exchange did not include it in this event.
555    OrderUpdate {
556        exchange: ExchangeId,
557        account_type: AccountType,
558        symbol: String,
559        point: OrderUpdatePoint,
560    },
561    /// Account balance change event.
562    /// `symbol` is the asset ticker (e.g. `"USDT"`), not a trading pair.
563    BalanceUpdate {
564        exchange: ExchangeId,
565        account_type: AccountType,
566        symbol: String,
567        point: BalanceUpdatePoint,
568    },
569    /// Futures position change event.
570    /// `symbol` is the instrument symbol for the position.
571    PositionUpdate {
572        exchange: ExchangeId,
573        account_type: AccountType,
574        symbol: String,
575        point: PositionUpdatePoint,
576    },
577}
578
579impl Event {
580    pub fn exchange(&self) -> ExchangeId {
581        match self {
582            Event::Trade { exchange, .. } | Event::AggTrade { exchange, .. } |
583            Event::Bar { exchange, .. } | Event::Ticker { exchange, .. } |
584            Event::OrderbookSnapshot { exchange, .. } | Event::OrderbookDelta { exchange, .. } |
585            Event::MarkPrice { exchange, .. } |
586            Event::FundingRate { exchange, .. } | Event::OpenInterest { exchange, .. } |
587            Event::Liquidation { exchange, .. } |
588            Event::BlockTrade { exchange, .. } | Event::AuctionEvent { exchange, .. } |
589            Event::IndexPrice { exchange, .. } |
590            Event::CompositeIndex { exchange, .. } | Event::OptionGreeks { exchange, .. } |
591            Event::VolatilityIndex { exchange, .. } | Event::HistoricalVolatility { exchange, .. } |
592            Event::LongShortRatio { exchange, .. } |
593            Event::TakerVolume { exchange, .. } | Event::LiquidationBucket { exchange, .. } |
594            Event::Basis { exchange, .. } | Event::InsuranceFund { exchange, .. } |
595            Event::OrderbookL3 { exchange, .. } | Event::SettlementEvent { exchange, .. } |
596            Event::MarketWarning { exchange, .. } |
597            Event::RiskLimit { exchange, .. } | Event::PredictedFunding { exchange, .. } |
598            Event::FundingSettlement { exchange, .. } |
599            Event::MarkPriceKline { exchange, .. } | Event::IndexPriceKline { exchange, .. } |
600            Event::PremiumIndexKline { exchange, .. } |
601            Event::Footprint { exchange, .. } |
602            Event::RenkoBar { exchange, .. } | Event::PnfBar { exchange, .. } |
603            Event::KagiBar { exchange, .. } | Event::CvdLine { exchange, .. } |
604            Event::ThreeLineBreakUpdate { exchange, .. } |
605            Event::TpoProfile { exchange, .. } => *exchange,
606            Event::OrderUpdate { exchange, .. } | Event::BalanceUpdate { exchange, .. } |
607            Event::PositionUpdate { exchange, .. } => *exchange,
608            Event::ConnectorReady { exchange } => *exchange,
609            Event::SymbolsLoaded { exchange, .. } => *exchange,
610        }
611    }
612    pub fn symbol(&self) -> &str {
613        match self {
614            Event::Trade { symbol, .. } | Event::AggTrade { symbol, .. } |
615            Event::Bar { symbol, .. } | Event::Ticker { symbol, .. } |
616            Event::OrderbookSnapshot { symbol, .. } | Event::OrderbookDelta { symbol, .. } |
617            Event::MarkPrice { symbol, .. } |
618            Event::FundingRate { symbol, .. } | Event::OpenInterest { symbol, .. } |
619            Event::Liquidation { symbol, .. } |
620            Event::BlockTrade { symbol, .. } | Event::AuctionEvent { symbol, .. } |
621            Event::IndexPrice { symbol, .. } |
622            Event::CompositeIndex { symbol, .. } | Event::OptionGreeks { symbol, .. } |
623            Event::VolatilityIndex { symbol, .. } | Event::HistoricalVolatility { symbol, .. } |
624            Event::LongShortRatio { symbol, .. } |
625            Event::TakerVolume { symbol, .. } | Event::LiquidationBucket { symbol, .. } |
626            Event::Basis { symbol, .. } | Event::InsuranceFund { symbol, .. } |
627            Event::OrderbookL3 { symbol, .. } | Event::SettlementEvent { symbol, .. } |
628            Event::MarketWarning { symbol, .. } |
629            Event::RiskLimit { symbol, .. } | Event::PredictedFunding { symbol, .. } |
630            Event::FundingSettlement { symbol, .. } |
631            Event::MarkPriceKline { symbol, .. } | Event::IndexPriceKline { symbol, .. } |
632            Event::PremiumIndexKline { symbol, .. } |
633            Event::Footprint { symbol, .. } |
634            Event::RenkoBar { symbol, .. } | Event::PnfBar { symbol, .. } |
635            Event::KagiBar { symbol, .. } | Event::CvdLine { symbol, .. } |
636            Event::ThreeLineBreakUpdate { symbol, .. } |
637            Event::TpoProfile { symbol, .. } => symbol,
638            Event::OrderUpdate { symbol, .. } | Event::BalanceUpdate { symbol, .. } |
639            Event::PositionUpdate { symbol, .. } => symbol,
640            // Lifecycle events carry no symbol.
641            Event::ConnectorReady { .. } | Event::SymbolsLoaded { .. } => "",
642        }
643    }
644
645    /// Replace the symbol label on this event in-place.
646    ///
647    /// Used by `Station::subscribe` so each `SubscriptionHandle` sees the
648    /// user-input symbol it passed in `SubscriptionSet::add(...)`, regardless
649    /// of which other consumer first established the underlying multiplex.
650    /// The routing key (raw exchange-native) is unaffected; this only changes
651    /// the cosmetic label that `Event.symbol()` returns to the consumer.
652    pub(crate) fn set_symbol(&mut self, new_symbol: String) {
653        match self {
654            Event::Trade { symbol, .. }
655            | Event::AggTrade { symbol, .. }
656            | Event::Bar { symbol, .. }
657            | Event::Ticker { symbol, .. }
658            | Event::OrderbookSnapshot { symbol, .. }
659            | Event::OrderbookDelta { symbol, .. }
660            | Event::MarkPrice { symbol, .. }
661            | Event::FundingRate { symbol, .. }
662            | Event::OpenInterest { symbol, .. }
663            | Event::Liquidation { symbol, .. }
664            | Event::BlockTrade { symbol, .. }
665            | Event::AuctionEvent { symbol, .. }
666            | Event::IndexPrice { symbol, .. }
667            | Event::CompositeIndex { symbol, .. }
668            | Event::OptionGreeks { symbol, .. }
669            | Event::VolatilityIndex { symbol, .. }
670            | Event::HistoricalVolatility { symbol, .. }
671            | Event::LongShortRatio { symbol, .. }
672            | Event::TakerVolume { symbol, .. }
673            | Event::LiquidationBucket { symbol, .. }
674            | Event::Basis { symbol, .. }
675            | Event::InsuranceFund { symbol, .. }
676            | Event::OrderbookL3 { symbol, .. }
677            | Event::SettlementEvent { symbol, .. }
678            | Event::MarketWarning { symbol, .. }
679            | Event::RiskLimit { symbol, .. }
680            | Event::PredictedFunding { symbol, .. }
681            | Event::FundingSettlement { symbol, .. }
682            | Event::MarkPriceKline { symbol, .. }
683            | Event::IndexPriceKline { symbol, .. }
684            | Event::PremiumIndexKline { symbol, .. }
685            | Event::Footprint { symbol, .. }
686            | Event::RenkoBar { symbol, .. }
687            | Event::PnfBar { symbol, .. }
688            | Event::KagiBar { symbol, .. }
689            | Event::CvdLine { symbol, .. }
690            | Event::ThreeLineBreakUpdate { symbol, .. }
691            | Event::TpoProfile { symbol, .. }
692            | Event::OrderUpdate { symbol, .. }
693            | Event::BalanceUpdate { symbol, .. }
694            | Event::PositionUpdate { symbol, .. } => *symbol = new_symbol,
695            // Lifecycle events have no symbol field — no-op.
696            Event::ConnectorReady { .. } | Event::SymbolsLoaded { .. } => {}
697        }
698    }
699    pub fn timestamp_ms(&self) -> i64 {
700        use crate::series::DataPoint;
701        match self {
702            Event::Trade { point, .. } => point.timestamp_ms(),
703            Event::AggTrade { point, .. } => point.timestamp_ms(),
704            Event::Bar { point, .. } => point.timestamp_ms(),
705            Event::Ticker { point, .. } => point.timestamp_ms(),
706            Event::OrderbookSnapshot { point, .. } => point.timestamp_ms(),
707            Event::OrderbookDelta { point, .. } => point.timestamp_ms(),
708            Event::MarkPrice { point, .. } => point.timestamp_ms(),
709            Event::FundingRate { point, .. } => point.timestamp_ms(),
710            Event::OpenInterest { point, .. } => point.timestamp_ms(),
711            Event::Liquidation { point, .. } => point.timestamp_ms(),
712            Event::BlockTrade { point, .. } => point.timestamp_ms(),
713            Event::AuctionEvent { point, .. } => point.timestamp_ms(),
714            Event::IndexPrice { point, .. } => point.timestamp_ms(),
715            Event::CompositeIndex { point, .. } => point.timestamp_ms(),
716            Event::OptionGreeks { point, .. } => point.timestamp_ms(),
717            Event::VolatilityIndex { point, .. } => point.timestamp_ms(),
718            Event::HistoricalVolatility { point, .. } => point.timestamp_ms(),
719            Event::LongShortRatio { point, .. } => point.timestamp_ms(),
720            Event::TakerVolume { point, .. } => point.timestamp_ms(),
721            Event::LiquidationBucket { point, .. } => point.timestamp_ms(),
722            Event::Basis { point, .. } => point.timestamp_ms(),
723            Event::InsuranceFund { point, .. } => point.timestamp_ms(),
724            Event::OrderbookL3 { point, .. } => point.timestamp_ms(),
725            Event::SettlementEvent { point, .. } => point.timestamp_ms(),
726            Event::MarketWarning { point, .. } => point.timestamp_ms(),
727            Event::RiskLimit { point, .. } => point.timestamp_ms(),
728            Event::PredictedFunding { point, .. } => point.timestamp_ms(),
729            Event::FundingSettlement { point, .. } => point.timestamp_ms(),
730            Event::MarkPriceKline { point, .. } => point.timestamp_ms(),
731            Event::IndexPriceKline { point, .. } => point.timestamp_ms(),
732            Event::PremiumIndexKline { point, .. } => point.timestamp_ms(),
733            Event::Footprint { point, .. } => point.timestamp_ms(),
734            Event::RenkoBar { point, .. } => point.timestamp_ms(),
735            Event::PnfBar { point, .. } => point.timestamp_ms(),
736            Event::KagiBar { point, .. } => point.timestamp_ms(),
737            Event::CvdLine { point, .. } => point.timestamp_ms(),
738            Event::ThreeLineBreakUpdate { point, .. } => point.timestamp_ms(),
739            Event::TpoProfile { point, .. } => point.timestamp_ms(),
740            Event::OrderUpdate { point, .. } => point.timestamp_ms(),
741            Event::BalanceUpdate { point, .. } => point.timestamp_ms(),
742            Event::PositionUpdate { point, .. } => point.timestamp_ms(),
743            // Lifecycle events: use current epoch ms.
744            Event::ConnectorReady { .. } | Event::SymbolsLoaded { .. } => {
745                chrono::Utc::now().timestamp_millis()
746            }
747        }
748    }
749}
750
751/// Outcome of [`crate::Station::warmup`].
752///
753/// `ok` holds every exchange that connected and had its exchange-info fetched
754/// successfully. `failed` holds exchanges that failed at either the connect or
755/// the REST stage.
756#[derive(Debug, Clone)]
757pub struct WarmupReport {
758    /// Exchanges that connected (and had `get_exchange_info` succeed for at
759    /// least one `AccountType`, if the connector supports it).
760    pub ok: Vec<ExchangeId>,
761    /// Exchanges that failed at connect or REST stage.
762    pub failed: Vec<(ExchangeId, String)>,
763}
764
765/// RAII handle returned from `Station::subscribe`. Dropping releases the
766/// per-StreamKey consumer ref count; when count hits zero the multiplexer
767/// shuts down.
768pub struct SubscriptionHandle {
769    pub(crate) rx: tokio::sync::mpsc::UnboundedReceiver<Event>,
770    pub(crate) _refs: Vec<MultiplexRef>,
771}
772
773impl std::fmt::Debug for SubscriptionHandle {
774    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
775        f.debug_struct("SubscriptionHandle").finish()
776    }
777}
778
779impl SubscriptionHandle {
780    pub async fn recv(&mut self) -> Option<Event> {
781        self.rx.recv().await
782    }
783}
784
785/// Per-stream subscribe failure reported by [`crate::Station::subscribe`].
786///
787/// Carries everything a consumer needs to log + skip without forcing them
788/// to parse a `Display` string. `error.is_not_supported()` distinguishes
789/// venue-doesn't-expose-this-stream (architectural, quiet) from transient
790/// failures (worth surfacing).
791#[derive(Debug)]
792pub struct FailedStream {
793    pub exchange: ExchangeId,
794    pub account_type: AccountType,
795    /// User-input symbol form (NOT the normalized exchange-native form).
796    pub symbol: String,
797    pub stream: Stream,
798    pub error: crate::StationError,
799}
800
801/// Outcome of [`crate::Station::subscribe`] in continue-on-error mode.
802///
803/// `handle` always exists and carries events for every stream in `ok`.
804/// `failed` is a per-stream list of subscribes that did not produce a
805/// live forwarder. The most common entry there is
806/// `StationError::StreamNotSupported` — the venue genuinely does not
807/// expose the requested stream on the WS wire. Other errors (transport,
808/// REST, symbol normalize) also land here so the consumer can log them
809/// without aborting the whole subscribe batch.
810///
811/// `failed` is empty on success — callers that want fail-fast semantics
812/// can simply `if !report.failed.is_empty() { return Err(...) }`.
813pub struct SubscribeReport {
814    pub handle: SubscriptionHandle,
815    pub ok: Vec<SeriesKey>,
816    pub failed: Vec<FailedStream>,
817    /// Cold-seed [`crate::SeedOutcome`] recorded for every `ok` key whose
818    /// acquire path ran a trade-history seed fetch (derived kinds that
819    /// consume `Kind::Trade` — footprint/counted-bars/price-path/CVD/TPO
820    /// families). Absent entries mean either no seed fetch happened for
821    /// that key (WS-only cold start, poll-only stream, or a re-acquire of
822    /// an already-live mux) or the key is not in `ok`.
823    ///
824    /// Not abused for `failed` — a truncated-but-nonempty seed is still a
825    /// successful subscribe (the key is in `ok`); check
826    /// `SeedOutcome::truncated_by` to see whether the venue capped it.
827    pub seed_outcomes: Vec<(SeriesKey, crate::SeedOutcome)>,
828}
829
830impl SubscribeReport {
831    /// True if every requested stream produced a live forwarder.
832    pub fn is_fully_ok(&self) -> bool { self.failed.is_empty() }
833    /// Convenience: total streams requested (`ok.len() + failed.len()`).
834    pub fn total(&self) -> usize { self.ok.len() + self.failed.len() }
835
836    /// Move all [`MultiplexRef`]s out of the inner [`SubscriptionHandle`]
837    /// into `dest`, returning `Self` with an empty `_refs` list.
838    ///
839    /// Used by [`crate::quota::ConsumerHandle::subscribe`] to take ownership
840    /// of the refs so that dropping the `ConsumerHandle` releases all the
841    /// consumer's subscriptions, even when the caller retains the
842    /// `SubscriptionHandle` for event `recv()`. The upstream broadcast keeps
843    /// emitting as long as any consumer holds a ref — ref extraction does not
844    /// interrupt the event stream.
845    pub(crate) fn take_refs_into(mut self, dest: &mut Vec<MultiplexRef>) -> Self {
846        dest.extend(std::mem::take(&mut self.handle._refs));
847        self
848    }
849}
850
851impl std::fmt::Debug for SubscribeReport {
852    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
853        f.debug_struct("SubscribeReport")
854            .field("ok", &self.ok.len())
855            .field("failed", &self.failed.len())
856            .field("seed_outcomes", &self.seed_outcomes.len())
857            .finish()
858    }
859}
860
861pub(crate) struct MultiplexRef {
862    pub(crate) station: std::sync::Weak<crate::station::StationInner>,
863    pub(crate) key: SeriesKey,
864}
865
866impl Drop for MultiplexRef {
867    fn drop(&mut self) {
868        if let Some(inner) = self.station.upgrade() {
869            inner.release_consumer(&self.key);
870        }
871    }
872}