Skip to main content

rustrade_data/subscription/
mod.rs

1use crate::{
2    exchange::Connector, instrument::InstrumentData, subscription::candle::CandleInterval,
3};
4use derive_more::Display;
5use fnv::FnvHashMap;
6use rustrade_instrument::{
7    Keyed,
8    asset::name::AssetNameInternal,
9    exchange::ExchangeId,
10    instrument::market_data::{MarketDataInstrument, kind::MarketDataInstrumentKind},
11};
12use rustrade_integration::{
13    Validator, error::SocketError, protocol::websocket::WsMessage, subscription::SubscriptionId,
14};
15use serde::{Deserialize, Serialize};
16use smol_str::{ToSmolStr, format_smolstr};
17use std::{borrow::Borrow, fmt::Debug, hash::Hash};
18
19/// OrderBook [`SubscriptionKind`]s and the associated Barter output data models.
20pub mod book;
21
22/// Candle [`SubscriptionKind`] and the associated Barter output data model.
23pub mod candle;
24
25/// Option Greeks data model for options analytics.
26pub mod greeks;
27
28/// Liquidation [`SubscriptionKind`] and the associated Barter output data model.
29pub mod liquidation;
30
31/// Quote [`SubscriptionKind`] and the associated Barter output data model.
32pub mod quote;
33
34/// Public trade [`SubscriptionKind`] and the associated Barter output data model.
35pub mod trade;
36
37/// Defines kind of a [`Subscription`], and the output [`Self::Event`] that it yields.
38pub trait SubscriptionKind
39where
40    Self: Debug + Clone,
41{
42    type Event: Debug;
43    fn as_str(&self) -> &'static str;
44}
45
46/// Barter [`Subscription`] used to subscribe to a [`SubscriptionKind`] for a particular exchange
47/// [`MarketDataInstrument`].
48#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
49pub struct Subscription<Exchange = ExchangeId, Inst = MarketDataInstrument, Kind = SubKind> {
50    pub exchange: Exchange,
51    #[serde(flatten)]
52    pub instrument: Inst,
53    #[serde(alias = "type")]
54    pub kind: Kind,
55}
56
57pub fn display_subscriptions_without_exchange<Exchange, Instrument, Kind>(
58    subscriptions: &[Subscription<Exchange, Instrument, Kind>],
59) -> String
60where
61    Instrument: std::fmt::Display,
62    Kind: std::fmt::Display,
63{
64    subscriptions
65        .iter()
66        .map(
67            |Subscription {
68                 exchange: _,
69                 instrument,
70                 kind,
71             }| { format_smolstr!("({instrument}, {kind})") },
72        )
73        .collect::<Vec<_>>()
74        .join(",")
75}
76
77impl<Exchange, Instrument, Kind> std::fmt::Display for Subscription<Exchange, Instrument, Kind>
78where
79    Exchange: std::fmt::Display,
80    Instrument: std::fmt::Display,
81    Kind: std::fmt::Display,
82{
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        write!(f, "({}|{}|{})", self.exchange, self.kind, self.instrument)
85    }
86}
87
88#[derive(
89    Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Display, Deserialize, Serialize,
90)]
91pub enum SubKind {
92    PublicTrades,
93    OrderBooksL1,
94    OrderBooksL2,
95    OrderBooksL3,
96    Liquidations,
97    /// Candle (kline) subscription at a specific [`CandleInterval`] resolution.
98    ///
99    /// Unlike the other [`SubKind`]s, a candle subscription is **not** fully specified
100    /// without a resolution — there is no venue-agnostic "default" candle stream — so the
101    /// [`interval`](CandleInterval) is carried in the variant. The `derive_more::Display`
102    /// tag is the fixed `"candles"` (independent of the interval), matching the typed
103    /// [`Candles`](candle::Candles) kind tag.
104    #[display("candles")]
105    Candles {
106        interval: CandleInterval,
107    },
108    /// Real-time top-of-book quotes (best bid/ask). Generic subscription kind
109    /// that may be supported by multiple exchanges providing quote data.
110    Quotes,
111}
112
113impl<Exchange, S, Kind> From<(Exchange, S, S, MarketDataInstrumentKind, Kind)>
114    for Subscription<Exchange, MarketDataInstrument, Kind>
115where
116    S: Into<AssetNameInternal>,
117{
118    fn from(
119        (exchange, base, quote, instrument_kind, kind): (
120            Exchange,
121            S,
122            S,
123            MarketDataInstrumentKind,
124            Kind,
125        ),
126    ) -> Self {
127        Self::new(exchange, (base, quote, instrument_kind), kind)
128    }
129}
130
131impl<InstrumentKey, Exchange, S, Kind>
132    From<(
133        InstrumentKey,
134        Exchange,
135        S,
136        S,
137        MarketDataInstrumentKind,
138        Kind,
139    )> for Subscription<Exchange, Keyed<InstrumentKey, MarketDataInstrument>, Kind>
140where
141    S: Into<AssetNameInternal>,
142{
143    fn from(
144        (instrument_id, exchange, base, quote, instrument_kind, kind): (
145            InstrumentKey,
146            Exchange,
147            S,
148            S,
149            MarketDataInstrumentKind,
150            Kind,
151        ),
152    ) -> Self {
153        let instrument = Keyed::new(instrument_id, (base, quote, instrument_kind).into());
154
155        Self::new(exchange, instrument, kind)
156    }
157}
158
159impl<Exchange, I, Instrument, Kind> From<(Exchange, I, Kind)>
160    for Subscription<Exchange, Instrument, Kind>
161where
162    I: Into<Instrument>,
163{
164    fn from((exchange, instrument, kind): (Exchange, I, Kind)) -> Self {
165        Self::new(exchange, instrument, kind)
166    }
167}
168
169impl<Instrument, Exchange, Kind> Subscription<Exchange, Instrument, Kind> {
170    /// Constructs a new [`Subscription`] using the provided configuration.
171    pub fn new<I>(exchange: Exchange, instrument: I, kind: Kind) -> Self
172    where
173        I: Into<Instrument>,
174    {
175        Self {
176            exchange,
177            instrument: instrument.into(),
178            kind,
179        }
180    }
181}
182
183impl<Exchange, Instrument, Kind> Validator for Subscription<Exchange, Instrument, Kind>
184where
185    Exchange: Connector,
186    Instrument: InstrumentData,
187{
188    type Error = SocketError;
189
190    fn validate(self) -> Result<Self, Self::Error>
191    where
192        Self: Sized,
193    {
194        // Validate the Exchange supports the Subscription InstrumentKind
195        if exchange_supports_instrument_kind(Exchange::ID, self.instrument.kind()) {
196            Ok(self)
197        } else {
198            Err(SocketError::Unsupported {
199                entity: Exchange::ID.to_string(),
200                item: self.instrument.kind().to_string(),
201            })
202        }
203    }
204}
205
206/// Determines whether the [`Connector`] associated with this [`ExchangeId`] supports the
207/// ingestion of market data for the provided [`MarketDataInstrumentKind`].
208// The arms are grouped by `MarketDataInstrumentKind`, each group ending in a default whose
209// open-vs-closed choice is documented in place (see the `Spot` note below). Collapsing to
210// `matches!` would fold every group into one boolean expression, leaving nowhere to record which
211// defaults are deliberate and which exchange lists were verified against what.
212#[allow(clippy::match_like_matches_macro)]
213pub fn exchange_supports_instrument_kind(
214    exchange: ExchangeId,
215    instrument_kind: &MarketDataInstrumentKind,
216) -> bool {
217    use rustrade_instrument::{
218        exchange::ExchangeId::*, instrument::market_data::kind::MarketDataInstrumentKind::*,
219    };
220
221    match (exchange, instrument_kind) {
222        // Spot
223        (
224            BinanceFuturesUsd | Bitmex | BybitPerpetualsUsd | GateioPerpetualsUsd
225            | GateioPerpetualsBtc,
226            Spot,
227        ) => false,
228        (LseFx | LseCrypto | LseEquities, Spot) => true,
229        (LseCfd | LseFutures | LseOptions, Spot) => false,
230        (MassiveStocks | MassiveCrypto | MassiveForex, Spot) => true,
231        (MassiveOptions, Spot) => false,
232        // NOTE: this default is OPEN -- an exchange that serves no spot market claims spot support
233        // unless it is denied above, with no compile error to prompt the edit. It is left open
234        // deliberately: closing it means writing an explicit arm for every existing variant and
235        // re-verifying each one's spot coverage, which is tracked separately rather than smuggled
236        // in here. Deny explicitly above when adding a non-spot exchange.
237        (_, Spot) => true,
238
239        // Future
240        (GateioFuturesUsd | GateioFuturesBtc | Okx, Future { .. }) => true,
241        (_, Future { .. }) => false,
242
243        // Perpetual
244        (
245            BinanceFuturesUsd | Bitmex | Okx | BybitPerpetualsUsd | GateioPerpetualsUsd
246            | GateioPerpetualsBtc | HyperliquidPerp,
247            Perpetual,
248        ) => true,
249        (_, Perpetual) => false,
250
251        // Option
252        (GateioOptions | Okx | LseOptions | MassiveOptions, Option { .. }) => true,
253        (_, Option { .. }) => false,
254
255        // Cfd
256        (LseCfd | LseFutures, Cfd) => true,
257        (_, Cfd) => false,
258    }
259}
260
261impl<Instrument> Validator for Subscription<ExchangeId, Instrument, SubKind>
262where
263    Instrument: InstrumentData,
264{
265    type Error = SocketError;
266
267    fn validate(self) -> Result<Self, Self::Error>
268    where
269        Self: Sized,
270    {
271        // Validate the Exchange supports the Subscription InstrumentKind
272        if exchange_supports_instrument_kind_sub_kind(
273            &self.exchange,
274            self.instrument.kind(),
275            self.kind,
276        ) {
277            Ok(self)
278        } else {
279            Err(SocketError::Unsupported {
280                entity: self.exchange.to_string(),
281                item: format!("({}, {})", self.instrument.kind(), self.kind),
282            })
283        }
284    }
285}
286
287/// Determines whether the [`Connector`] associated with this [`ExchangeId`] supports the
288/// ingestion of market data for the provided [`MarketDataInstrumentKind`] and [`SubKind`] combination.
289///
290/// [`SubKind::Candles`] carries its resolution, so support is checked **per interval**:
291/// [`CandleInterval`] is the venue-agnostic union of every resolution any connector
292/// serves, and no venue serves all of it (Binance publishes no `5s`/`15s`/`30s` kline).
293pub fn exchange_supports_instrument_kind_sub_kind(
294    exchange_id: &ExchangeId,
295    instrument_kind: &MarketDataInstrumentKind,
296    sub_kind: SubKind,
297) -> bool {
298    use ExchangeId::*;
299    use MarketDataInstrumentKind::*;
300    use SubKind::*;
301
302    match (exchange_id, instrument_kind, sub_kind) {
303        (BinanceSpot, Spot, PublicTrades | OrderBooksL1 | OrderBooksL2) => true,
304        (BinanceSpot, Spot, Candles { interval }) => {
305            crate::exchange::binance::supports_candle_interval(interval)
306        }
307        (
308            BinanceFuturesUsd,
309            Perpetual,
310            PublicTrades | OrderBooksL1 | OrderBooksL2 | Liquidations,
311        ) => true,
312        (BinanceFuturesUsd, Perpetual, Candles { interval }) => {
313            crate::exchange::binance::supports_candle_interval(interval)
314        }
315        (Bitfinex, Spot, PublicTrades) => true,
316        (Bitmex, Perpetual, PublicTrades) => true,
317        (BybitSpot, Spot, PublicTrades | OrderBooksL1 | OrderBooksL2) => true,
318        (BybitPerpetualsUsd, Perpetual, PublicTrades | OrderBooksL1 | OrderBooksL2) => true,
319        (Coinbase, Spot, PublicTrades) => true,
320        (GateioSpot, Spot, PublicTrades) => true,
321        (GateioFuturesUsd, Future { .. }, PublicTrades) => true,
322        (GateioFuturesBtc, Future { .. }, PublicTrades) => true,
323        (GateioPerpetualsUsd, Perpetual, PublicTrades) => true,
324        (GateioPerpetualsBtc, Perpetual, PublicTrades) => true,
325        (GateioOptions, Option { .. }, PublicTrades) => true,
326        (Kraken, Spot, PublicTrades | OrderBooksL1) => true,
327        (Okx, Spot | Future { .. } | Perpetual | Option { .. }, PublicTrades) => true,
328        (HyperliquidPerp, Perpetual, PublicTrades | OrderBooksL2) => true,
329
330        // Alpaca's top of book is its quote: `OrderBooksL1` reads the `quotes` channel, which the
331        // typed `Quotes` kind also serves. `Quotes` itself has no arm because `DynamicStreams`, the
332        // one caller of this matrix, carries no quote channel, and the engine's market data no quote
333        // event. IEX quotes are IEX's own top of book rather than the NBBO; SIP quotes are the NBBO.
334        //
335        // ⚠️ `AlpacaSip` is declared on the strength of the code path, which it shares with IEX, and
336        // has never been run against the real feed: it needs a paid subscription. See
337        // `exchange::alpaca`.
338        (AlpacaCrypto | AlpacaIex | AlpacaSip, Spot, PublicTrades | OrderBooksL1) => true,
339
340        // London Strategic Edge serves both kinds from ONE frame: its WebSocket publishes a single
341        // tick carrying `price`, `bid`, `ask` and `volume` together, so a dataset that serves one
342        // of these kinds necessarily serves the other. The two arms differ only in instrument kind,
343        // which is fixed per dataset.
344        //
345        // ⚠️ A `PublicTrade` from this feed MAY NOT BE A PRINT. The tick's `price` equals its `bid`
346        // on every sample taken -- 3,966 of 3,966 ticks spanning every dataset family -- so a trade
347        // decoded from it is a bid-side quote wearing a trade's shape, and its arrival is not
348        // evidence that a transaction occurred, at that price or at all.
349        //
350        // ⚠️ And its `amount` is genuine on two of these venues and FABRICATED on the other two,
351        // with no in-band signal separating them. `LseCrypto` and `LseEquities` carry a real
352        // per-tick size, which reconciles against the provider's own one-minute candles to the last
353        // decimal. `LseFx` and `LseCfd` carry a hard-coded `1.0` on every tick of every symbol
354        // sampled -- a placeholder that, being a plausible positive number, aggregates into a
355        // legitimate-looking total at any resolution, leaving volume-weighted prices and size
356        // filters on those two meaningless rather than merely imprecise.
357        //
358        // Support is declared regardless, and uniformly: the frame is identical across datasets, so
359        // withholding the kind would hide the feed rather than the hazard. Saying so is the useful
360        // answer. The full reasoning lives on the trade decoder in the `lse` module.
361        (LseFx | LseCrypto | LseEquities, Spot, PublicTrades | OrderBooksL1) => true,
362        (LseCfd | LseFutures, Cfd, PublicTrades | OrderBooksL1) => true,
363        // Option contracts tick on the same frame, subscribed per underlying, but with `bid` and
364        // `ask` null on every one -- there is no quote to serve, so `PublicTrades` is the only
365        // kind. Unlike the venues above, these trades ARE prints: they reconcile one-for-one
366        // against the provider's REST option-print tape.
367        (LseOptions, Option { .. }, PublicTrades) => true,
368        // No `Candles` arm exists for any London Strategic Edge venue, deliberately: the provider's
369        // WebSocket carries no candle channel at all -- the tick above is its only data frame, so
370        // there is nothing to subscribe to. Its candles are served exclusively over the REST vault,
371        // by a path that never reaches this matrix. The absence is a decision, not an omission.
372
373        // Massive: a cluster per `ExchangeId`. The top of book is the cluster's quote channel, read
374        // as `OrderBooksL1`; `Quotes` itself has no arm, for the reason given for Alpaca above.
375        // Forex publishes no trades. Candles are Massive's per-second and per-minute aggregates,
376        // the only intervals it aggregates at; the connectors refuse any other, and this matrix
377        // refuses it first. `Massive` alone is the REST client's id and streams nothing.
378        //
379        // ⚠️ `MassiveOptions` is declared on the strength of the code path, which it shares with
380        // stocks, and has never been run against the real feed: it needs an options subscription.
381        (MassiveStocks | MassiveCrypto, Spot, PublicTrades | OrderBooksL1) => true,
382        (MassiveForex, Spot, OrderBooksL1) => true,
383        (MassiveOptions, Option { .. }, PublicTrades | OrderBooksL1) => true,
384        (
385            MassiveStocks | MassiveCrypto | MassiveForex,
386            Spot,
387            Candles {
388                interval: CandleInterval::Sec1 | CandleInterval::Min1,
389            },
390        ) => true,
391        (
392            MassiveOptions,
393            Option { .. },
394            Candles {
395                interval: CandleInterval::Sec1 | CandleInterval::Min1,
396            },
397        ) => true,
398
399        (_, _, _) => false,
400    }
401}
402
403/// Metadata generated from a collection of Barter [`Subscription`]s, including the exchange
404/// specific subscription payloads that are sent to the exchange.
405#[derive(Clone, Eq, PartialEq, Debug)]
406pub struct SubscriptionMeta<InstrumentKey> {
407    /// `HashMap` containing the mapping between a [`SubscriptionId`] and
408    /// it's associated Barter [`MarketDataInstrument`].
409    pub instrument_map: Map<InstrumentKey>,
410    /// Collection of [`WsMessage`]s containing exchange specific subscription payloads to be sent.
411    pub ws_subscriptions: Vec<WsMessage>,
412}
413
414/// New type`HashMap` that maps a [`SubscriptionId`] to some associated type `T`.
415///
416/// Used by [`ExchangeTransformer`](crate::transformer::ExchangeTransformer)s to identify the
417/// Barter [`MarketDataInstrument`] associated with incoming exchange messages.
418#[derive(Clone, Eq, PartialEq, Debug, Deserialize, Serialize)]
419pub struct Map<T>(pub FnvHashMap<SubscriptionId, T>);
420
421impl<T> FromIterator<(SubscriptionId, T)> for Map<T> {
422    fn from_iter<Iter>(iter: Iter) -> Self
423    where
424        Iter: IntoIterator<Item = (SubscriptionId, T)>,
425    {
426        Self(iter.into_iter().collect::<FnvHashMap<SubscriptionId, T>>())
427    }
428}
429
430impl<T> Map<T> {
431    /// Find the `InstrumentKey` associated with the provided [`SubscriptionId`].
432    pub fn find<SubId>(&self, id: &SubId) -> Result<&T, SocketError>
433    where
434        SubscriptionId: Borrow<SubId>,
435        SubId: AsRef<str> + Hash + Eq + ?Sized,
436    {
437        self.0
438            .get(id)
439            .ok_or_else(|| SocketError::Unidentifiable(SubscriptionId(id.as_ref().to_smolstr())))
440    }
441
442    /// Find the mutable reference to `T` associated with the provided [`SubscriptionId`].
443    pub fn find_mut<SubId>(&mut self, id: &SubId) -> Result<&mut T, SocketError>
444    where
445        SubscriptionId: Borrow<SubId>,
446        SubId: AsRef<str> + Hash + Eq + ?Sized,
447    {
448        self.0
449            .get_mut(id)
450            .ok_or_else(|| SocketError::Unidentifiable(SubscriptionId(id.as_ref().to_smolstr())))
451    }
452}
453
454#[cfg(test)]
455#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
456mod tests {
457    use super::*;
458
459    mod supports {
460        use super::*;
461        use chrono::{DateTime, Utc};
462        use rust_decimal::Decimal;
463        use rustrade_instrument::{
464            exchange::ExchangeId,
465            instrument::{
466                kind::option::{OptionExercise, OptionKind},
467                market_data::kind::MarketDataOptionContract,
468            },
469        };
470
471        /// The London Strategic Edge datasets, paired with the single
472        /// [`MarketDataInstrumentKind`] each one serves.
473        const LSE: [(ExchangeId, MarketDataInstrumentKind); 5] = [
474            (ExchangeId::LseFx, MarketDataInstrumentKind::Spot),
475            (ExchangeId::LseCrypto, MarketDataInstrumentKind::Spot),
476            (ExchangeId::LseEquities, MarketDataInstrumentKind::Spot),
477            (ExchangeId::LseFutures, MarketDataInstrumentKind::Cfd),
478            (ExchangeId::LseCfd, MarketDataInstrumentKind::Cfd),
479        ];
480
481        #[test]
482        fn test_lse_supports_exactly_its_own_kind() {
483            // Each dataset serves one kind and must deny the others. The denials matter as much as
484            // the grants: `exchange_supports_instrument_kind`'s `(_, Spot) => true` default means
485            // a CFD-only dataset would otherwise claim spot support with no edit and no error.
486            for (exchange, supported) in LSE {
487                assert!(
488                    exchange_supports_instrument_kind(exchange, &supported),
489                    "{exchange:?} should support {supported:?}"
490                );
491
492                for denied in [
493                    MarketDataInstrumentKind::Spot,
494                    MarketDataInstrumentKind::Cfd,
495                    MarketDataInstrumentKind::Perpetual,
496                ] {
497                    if denied == supported {
498                        continue;
499                    }
500                    assert!(
501                        !exchange_supports_instrument_kind(exchange, &denied),
502                        "{exchange:?} should not support {denied:?}"
503                    );
504                }
505            }
506        }
507
508        fn option_kind() -> MarketDataInstrumentKind {
509            MarketDataInstrumentKind::Option(MarketDataOptionContract {
510                kind: OptionKind::Call,
511                exercise: OptionExercise::American,
512                expiry: DateTime::<Utc>::MAX_UTC,
513                strike: Decimal::ONE,
514            })
515        }
516
517        #[test]
518        fn test_lse_options_supports_only_the_option_kind() {
519            assert!(exchange_supports_instrument_kind(
520                ExchangeId::LseOptions,
521                &option_kind()
522            ));
523            for denied in [
524                MarketDataInstrumentKind::Spot,
525                MarketDataInstrumentKind::Cfd,
526                MarketDataInstrumentKind::Perpetual,
527            ] {
528                assert!(
529                    !exchange_supports_instrument_kind(ExchangeId::LseOptions, &denied),
530                    "LseOptions should not support {denied:?}"
531                );
532            }
533        }
534
535        #[test]
536        fn test_lse_options_serves_public_trades_only() {
537            assert!(exchange_supports_instrument_kind_sub_kind(
538                &ExchangeId::LseOptions,
539                &option_kind(),
540                SubKind::PublicTrades
541            ));
542
543            // Every option tick publishes `bid` and `ask` as null, so there is no quote to serve.
544            for sub_kind in [
545                SubKind::OrderBooksL1,
546                SubKind::OrderBooksL2,
547                SubKind::OrderBooksL3,
548            ] {
549                assert!(
550                    !exchange_supports_instrument_kind_sub_kind(
551                        &ExchangeId::LseOptions,
552                        &option_kind(),
553                        sub_kind
554                    ),
555                    "LseOptions should not serve {sub_kind}"
556                );
557            }
558
559            assert!(
560                !exchange_supports_instrument_kind_sub_kind(
561                    &ExchangeId::LseOptions,
562                    &MarketDataInstrumentKind::Spot,
563                    SubKind::PublicTrades
564                ),
565                "LseOptions serves option contracts only"
566            );
567        }
568
569        #[test]
570        fn test_cfd_denied_for_non_cfd_exchanges() {
571            for exchange in [
572                ExchangeId::BinanceSpot,
573                ExchangeId::Coinbase,
574                ExchangeId::Okx,
575                ExchangeId::GateioOptions,
576                ExchangeId::Mock,
577            ] {
578                assert!(
579                    !exchange_supports_instrument_kind(exchange, &MarketDataInstrumentKind::Cfd),
580                    "{exchange:?} should not support Cfd"
581                );
582            }
583        }
584
585        #[test]
586        fn test_lse_supports_both_tick_derived_sub_kinds_for_its_own_kind_only() {
587            // One tick frame carries price, bid, ask and size together, so every dataset serves
588            // both kinds decoded from it -- and serves neither under an instrument kind it does
589            // not model.
590            for (exchange, supported) in LSE {
591                for sub_kind in [SubKind::PublicTrades, SubKind::OrderBooksL1] {
592                    assert!(
593                        exchange_supports_instrument_kind_sub_kind(&exchange, &supported, sub_kind),
594                        "{exchange:?} should support ({supported:?}, {sub_kind})"
595                    );
596
597                    for denied in [
598                        MarketDataInstrumentKind::Spot,
599                        MarketDataInstrumentKind::Cfd,
600                        MarketDataInstrumentKind::Perpetual,
601                    ] {
602                        if denied == supported {
603                            continue;
604                        }
605                        assert!(
606                            !exchange_supports_instrument_kind_sub_kind(
607                                &exchange, &denied, sub_kind
608                            ),
609                            "{exchange:?} should not support ({denied:?}, {sub_kind})"
610                        );
611                    }
612                }
613            }
614        }
615
616        #[test]
617        fn test_lse_serves_no_candle_stream_at_any_interval() {
618            // Pins the absent `Candles` arm as a decision: the provider's WebSocket has no candle
619            // channel, and its candles reach users over the REST vault, which never consults this
620            // matrix. A future edit that "completes" the LSE rows fails here.
621            for (exchange, kind) in LSE {
622                for interval in CandleInterval::ALL {
623                    assert!(
624                        !exchange_supports_instrument_kind_sub_kind(
625                            &exchange,
626                            &kind,
627                            SubKind::Candles { interval },
628                        ),
629                        "{exchange:?} serves no candle stream, including {interval:?}"
630                    );
631                }
632            }
633        }
634
635        #[test]
636        fn test_lse_denies_sub_kinds_its_tick_cannot_serve() {
637            // The tick is top-of-book, so there is no depth to serve; the feed publishes no
638            // liquidations at all. `Quotes` is denied here as it is for every other venue -- no
639            // exchange declares it in this matrix.
640            for (exchange, kind) in LSE {
641                for sub_kind in [
642                    SubKind::OrderBooksL2,
643                    SubKind::OrderBooksL3,
644                    SubKind::Liquidations,
645                    SubKind::Quotes,
646                ] {
647                    assert!(
648                        !exchange_supports_instrument_kind_sub_kind(&exchange, &kind, sub_kind),
649                        "{exchange:?} should not support ({kind:?}, {sub_kind})"
650                    );
651                }
652            }
653        }
654    }
655
656    mod supports_massive {
657        use super::*;
658        use rustrade_instrument::instrument::{
659            kind::option::{OptionExercise, OptionKind},
660            market_data::kind::MarketDataOptionContract,
661        };
662
663        fn option_kind() -> MarketDataInstrumentKind {
664            MarketDataInstrumentKind::Option(MarketDataOptionContract {
665                kind: OptionKind::Call,
666                exercise: OptionExercise::American,
667                expiry: chrono::DateTime::<chrono::Utc>::MAX_UTC,
668                strike: rust_decimal::Decimal::ONE,
669            })
670        }
671
672        /// Each cluster, paired with the one instrument kind it streams.
673        fn clusters() -> [(ExchangeId, MarketDataInstrumentKind); 4] {
674            [
675                (ExchangeId::MassiveStocks, MarketDataInstrumentKind::Spot),
676                (ExchangeId::MassiveCrypto, MarketDataInstrumentKind::Spot),
677                (ExchangeId::MassiveForex, MarketDataInstrumentKind::Spot),
678                (ExchangeId::MassiveOptions, option_kind()),
679            ]
680        }
681
682        fn other_kinds() -> [MarketDataInstrumentKind; 4] {
683            [
684                MarketDataInstrumentKind::Spot,
685                MarketDataInstrumentKind::Perpetual,
686                MarketDataInstrumentKind::Cfd,
687                option_kind(),
688            ]
689        }
690
691        #[test]
692        fn each_cluster_supports_exactly_its_own_instrument_kind() {
693            for (exchange, supported) in clusters() {
694                assert!(
695                    exchange_supports_instrument_kind(exchange, &supported),
696                    "{exchange:?} should support {supported:?}"
697                );
698                for denied in other_kinds().into_iter().filter(|kind| *kind != supported) {
699                    assert!(
700                        !exchange_supports_instrument_kind(exchange, &denied),
701                        "{exchange:?} should not support {denied:?}"
702                    );
703                    assert!(
704                        !exchange_supports_instrument_kind_sub_kind(
705                            &exchange,
706                            &denied,
707                            SubKind::OrderBooksL1
708                        ),
709                        "{exchange:?} should not serve {denied:?}"
710                    );
711                }
712            }
713        }
714
715        #[test]
716        fn every_cluster_but_forex_serves_trades_and_every_cluster_serves_top_of_book() {
717            for (exchange, kind) in clusters() {
718                assert_eq!(
719                    exchange_supports_instrument_kind_sub_kind(
720                        &exchange,
721                        &kind,
722                        SubKind::PublicTrades
723                    ),
724                    exchange != ExchangeId::MassiveForex,
725                    "{exchange:?} trades"
726                );
727                assert!(
728                    exchange_supports_instrument_kind_sub_kind(
729                        &exchange,
730                        &kind,
731                        SubKind::OrderBooksL1
732                    ),
733                    "{exchange:?} top of book"
734                );
735            }
736        }
737
738        #[test]
739        fn candles_are_served_at_one_second_and_one_minute_only() {
740            for (exchange, kind) in clusters() {
741                for interval in CandleInterval::ALL {
742                    assert_eq!(
743                        exchange_supports_instrument_kind_sub_kind(
744                            &exchange,
745                            &kind,
746                            SubKind::Candles { interval }
747                        ),
748                        matches!(interval, CandleInterval::Sec1 | CandleInterval::Min1),
749                        "{exchange:?} candles at {interval:?}"
750                    );
751                }
752            }
753        }
754
755        #[test]
756        fn quotes_depth_and_liquidations_are_refused() {
757            for (exchange, kind) in clusters() {
758                for sub_kind in [
759                    SubKind::Quotes,
760                    SubKind::OrderBooksL2,
761                    SubKind::OrderBooksL3,
762                    SubKind::Liquidations,
763                ] {
764                    assert!(
765                        !exchange_supports_instrument_kind_sub_kind(&exchange, &kind, sub_kind),
766                        "{exchange:?} should not serve {sub_kind}"
767                    );
768                }
769            }
770        }
771
772        #[test]
773        fn the_rest_client_id_streams_nothing() {
774            for kind in other_kinds() {
775                for sub_kind in [SubKind::PublicTrades, SubKind::OrderBooksL1] {
776                    assert!(!exchange_supports_instrument_kind_sub_kind(
777                        &ExchangeId::Massive,
778                        &kind,
779                        sub_kind
780                    ));
781                }
782            }
783        }
784    }
785
786    mod subscription {
787        use super::*;
788        use crate::{
789            exchange::{coinbase::Coinbase, okx::Okx},
790            subscription::trade::PublicTrades,
791        };
792        use rustrade_instrument::instrument::market_data::MarketDataInstrument;
793
794        mod de {
795            use super::*;
796            use crate::{
797                exchange::{
798                    binance::{futures::BinanceFuturesUsd, spot::BinanceSpot},
799                    gateio::perpetual::GateioPerpetualsUsd,
800                    okx::Okx,
801                },
802                subscription::{book::OrderBooksL2, trade::PublicTrades},
803            };
804            use rustrade_instrument::instrument::market_data::MarketDataInstrument;
805
806            #[test]
807            fn test_subscription_okx_spot_public_trades() {
808                let input = r#"
809                {
810                    "exchange": "okx",
811                    "base": "btc",
812                    "quote": "usdt",
813                    "instrument_kind": "spot",
814                    "kind": "public_trades"
815                }
816                "#;
817
818                serde_json::from_str::<Subscription<Okx, MarketDataInstrument, PublicTrades>>(
819                    input,
820                )
821                .unwrap();
822            }
823
824            #[test]
825            fn test_subscription_binance_spot_public_trades() {
826                let input = r#"
827                {
828                    "exchange": "binance_spot",
829                    "base": "btc",
830                    "quote": "usdt",
831                    "instrument_kind": "spot",
832                    "kind": "public_trades"
833                }
834                "#;
835
836                serde_json::from_str::<Subscription<BinanceSpot, MarketDataInstrument, PublicTrades>>(input)
837                    .unwrap();
838            }
839
840            #[test]
841            fn test_subscription_binance_futures_usd_order_books_l2() {
842                let input = r#"
843                {
844                    "exchange": "binance_futures_usd",
845                    "base": "btc",
846                    "quote": "usdt",
847                    "instrument_kind": "perpetual",
848                    "kind": "order_books_l2"
849                }
850                "#;
851
852                serde_json::from_str::<
853                    Subscription<BinanceFuturesUsd, MarketDataInstrument, OrderBooksL2>,
854                >(input)
855                .unwrap();
856            }
857
858            #[test]
859            fn subscription_gateio_futures_usd_public_trades() {
860                let input = r#"
861                {
862                    "exchange": "gateio_perpetuals_usd",
863                    "base": "btc",
864                    "quote": "usdt",
865                    "instrument_kind": "perpetual",
866                    "kind": "public_trades"
867                }
868                "#;
869
870                serde_json::from_str::<
871                    Subscription<GateioPerpetualsUsd, MarketDataInstrument, PublicTrades>,
872                >(input)
873                .unwrap();
874            }
875        }
876
877        #[test]
878        fn test_validate_bitfinex_public_trades() {
879            struct TestCase {
880                input: Subscription<Coinbase, MarketDataInstrument, PublicTrades>,
881                expected:
882                    Result<Subscription<Coinbase, MarketDataInstrument, PublicTrades>, SocketError>,
883            }
884
885            let tests = vec![
886                TestCase {
887                    // TC0: Valid Coinbase Spot PublicTrades subscription
888                    input: Subscription::from((
889                        Coinbase,
890                        "base",
891                        "quote",
892                        MarketDataInstrumentKind::Spot,
893                        PublicTrades,
894                    )),
895                    expected: Ok(Subscription::from((
896                        Coinbase,
897                        "base",
898                        "quote",
899                        MarketDataInstrumentKind::Spot,
900                        PublicTrades,
901                    ))),
902                },
903                TestCase {
904                    // TC1: Invalid Coinbase FuturePerpetual PublicTrades subscription
905                    input: Subscription::from((
906                        Coinbase,
907                        "base",
908                        "quote",
909                        MarketDataInstrumentKind::Perpetual,
910                        PublicTrades,
911                    )),
912                    expected: Err(SocketError::Unsupported {
913                        entity: "".to_string(),
914                        item: "".to_string(),
915                    }),
916                },
917            ];
918
919            for (index, test) in tests.into_iter().enumerate() {
920                let actual = test.input.validate();
921                match (actual, test.expected) {
922                    (Ok(actual), Ok(expected)) => {
923                        assert_eq!(actual, expected, "TC{} failed", index)
924                    }
925                    (Err(_), Err(_)) => {
926                        // Test passed
927                    }
928                    (actual, expected) => {
929                        // Test failed
930                        panic!(
931                            "TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"
932                        );
933                    }
934                }
935            }
936        }
937
938        #[test]
939        fn test_validate_okx_public_trades() {
940            struct TestCase {
941                input: Subscription<Okx, MarketDataInstrument, PublicTrades>,
942                expected:
943                    Result<Subscription<Okx, MarketDataInstrument, PublicTrades>, SocketError>,
944            }
945
946            let tests = vec![
947                TestCase {
948                    // TC0: Valid Okx Spot PublicTrades subscription
949                    input: Subscription::from((
950                        Okx,
951                        "base",
952                        "quote",
953                        MarketDataInstrumentKind::Spot,
954                        PublicTrades,
955                    )),
956                    expected: Ok(Subscription::from((
957                        Okx,
958                        "base",
959                        "quote",
960                        MarketDataInstrumentKind::Spot,
961                        PublicTrades,
962                    ))),
963                },
964                TestCase {
965                    // TC1: Valid Okx FuturePerpetual PublicTrades subscription
966                    input: Subscription::from((
967                        Okx,
968                        "base",
969                        "quote",
970                        MarketDataInstrumentKind::Perpetual,
971                        PublicTrades,
972                    )),
973                    expected: Ok(Subscription::from((
974                        Okx,
975                        "base",
976                        "quote",
977                        MarketDataInstrumentKind::Perpetual,
978                        PublicTrades,
979                    ))),
980                },
981            ];
982
983            for (index, test) in tests.into_iter().enumerate() {
984                let actual = test.input.validate();
985                match (actual, test.expected) {
986                    (Ok(actual), Ok(expected)) => {
987                        assert_eq!(actual, expected, "TC{} failed", index)
988                    }
989                    (Err(_), Err(_)) => {
990                        // Test passed
991                    }
992                    (actual, expected) => {
993                        // Test failed
994                        panic!(
995                            "TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"
996                        );
997                    }
998                }
999            }
1000        }
1001    }
1002
1003    mod sub_kind {
1004        use super::*;
1005
1006        #[test]
1007        fn candles_variant_serde_round_trips() {
1008            let kind = SubKind::Candles {
1009                interval: CandleInterval::Min1,
1010            };
1011
1012            let json = serde_json::to_string(&kind).unwrap();
1013            let back = serde_json::from_str::<SubKind>(&json).unwrap();
1014
1015            assert_eq!(kind, back, "SubKind::Candles must serde round-trip");
1016        }
1017
1018        #[test]
1019        fn candles_variant_serde_shape_is_externally_tagged() {
1020            // `SubKind` is an externally-tagged enum (no `#[serde(tag = ...)]`), so the struct
1021            // variant serialises as `{"Candles":{"interval":"1m"}}` — NOT an internally-tagged
1022            // `{"type":"candles",...}` form. Pin the wire shape so a future tag/rename change is
1023            // caught here rather than silently shipped to config-driven consumers.
1024            let json = serde_json::to_string(&SubKind::Candles {
1025                interval: CandleInterval::Min1,
1026            })
1027            .unwrap();
1028            assert_eq!(json, r#"{"Candles":{"interval":"1m"}}"#);
1029        }
1030
1031        #[test]
1032        fn candles_display_tag_is_interval_independent() {
1033            // The `derive_more::Display` tag is the fixed kind name, never the interval.
1034            assert_eq!(
1035                SubKind::Candles {
1036                    interval: CandleInterval::Sec1
1037                }
1038                .to_string(),
1039                "candles"
1040            );
1041            assert_eq!(
1042                SubKind::Candles {
1043                    interval: CandleInterval::Month1
1044                }
1045                .to_string(),
1046                "candles"
1047            );
1048        }
1049
1050        #[test]
1051        fn candles_supported_per_interval_on_both_binance_venues() {
1052            // `CandleInterval` is a venue-agnostic union, so candle support is decided
1053            // per interval, not per `SubKind`: both Binance venues serve every interval
1054            // Binance publishes a kline for, and neither serves `5s`/`15s`/`30s`.
1055            for interval in CandleInterval::ALL {
1056                let expected = crate::exchange::binance::supports_candle_interval(interval);
1057                assert_eq!(
1058                    exchange_supports_instrument_kind_sub_kind(
1059                        &ExchangeId::BinanceSpot,
1060                        &MarketDataInstrumentKind::Spot,
1061                        SubKind::Candles { interval },
1062                    ),
1063                    expected,
1064                    "BinanceSpot/Spot Candles {interval:?}"
1065                );
1066                assert_eq!(
1067                    exchange_supports_instrument_kind_sub_kind(
1068                        &ExchangeId::BinanceFuturesUsd,
1069                        &MarketDataInstrumentKind::Perpetual,
1070                        SubKind::Candles { interval },
1071                    ),
1072                    expected,
1073                    "BinanceFuturesUsd/Perpetual Candles {interval:?}"
1074                );
1075            }
1076        }
1077
1078        #[test]
1079        fn candles_rejected_for_unsupported_venue_kind_pairing() {
1080            // A supported venue with the wrong instrument kind is still rejected.
1081            assert!(
1082                !exchange_supports_instrument_kind_sub_kind(
1083                    &ExchangeId::BinanceSpot,
1084                    &MarketDataInstrumentKind::Perpetual,
1085                    SubKind::Candles {
1086                        interval: CandleInterval::Min1,
1087                    },
1088                ),
1089                "BinanceSpot does not serve Perpetual candles"
1090            );
1091        }
1092    }
1093
1094    mod instrument_map {
1095        use super::*;
1096        use rustrade_instrument::instrument::market_data::MarketDataInstrument;
1097
1098        #[test]
1099        fn test_find_instrument() {
1100            // Initialise SubscriptionId-InstrumentKey HashMap
1101            let ids = Map(FnvHashMap::from_iter([(
1102                SubscriptionId::from("present"),
1103                MarketDataInstrument::from(("base", "quote", MarketDataInstrumentKind::Spot)),
1104            )]));
1105
1106            struct TestCase {
1107                input: SubscriptionId,
1108                expected: Result<MarketDataInstrument, SocketError>,
1109            }
1110
1111            let cases = vec![
1112                TestCase {
1113                    // TC0: SubscriptionId (channel) is present in the HashMap
1114                    input: SubscriptionId::from("present"),
1115                    expected: Ok(MarketDataInstrument::from((
1116                        "base",
1117                        "quote",
1118                        MarketDataInstrumentKind::Spot,
1119                    ))),
1120                },
1121                TestCase {
1122                    // TC1: SubscriptionId (channel) is not present in the HashMap
1123                    input: SubscriptionId::from("not present"),
1124                    expected: Err(SocketError::Unidentifiable(SubscriptionId::from(
1125                        "not present",
1126                    ))),
1127                },
1128            ];
1129
1130            for (index, test) in cases.into_iter().enumerate() {
1131                let actual = ids.find(&test.input);
1132                match (actual, test.expected) {
1133                    (Ok(actual), Ok(expected)) => {
1134                        assert_eq!(*actual, expected, "TC{} failed", index)
1135                    }
1136                    (Err(_), Err(_)) => {
1137                        // Test passed
1138                    }
1139                    (actual, expected) => {
1140                        // Test failed
1141                        panic!(
1142                            "TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"
1143                        );
1144                    }
1145                }
1146            }
1147        }
1148    }
1149}