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 granting the exchanges whose
209// connector serves that kind and denying the rest. Collapsing to `matches!` would fold the groups
210// into one boolean expression, leaving nowhere to record which exchanges were verified.
211#[allow(clippy::match_like_matches_macro)]
212pub fn exchange_supports_instrument_kind(
213    exchange: ExchangeId,
214    instrument_kind: &MarketDataInstrumentKind,
215) -> bool {
216    use rustrade_instrument::{
217        exchange::ExchangeId::*, instrument::market_data::kind::MarketDataInstrumentKind::*,
218    };
219
220    match (exchange, instrument_kind) {
221        // Spot: granted per exchange whose connector serves a spot market, denied to every other,
222        // as the other kinds are. A new connector serving spot must be added here, or its spot
223        // subscriptions fail validation, which says so.
224        (
225            AlpacaCrypto | AlpacaIex | AlpacaSip | BinanceSpot | Bitfinex | BybitSpot | Coinbase
226            | GateioSpot | HyperliquidSpot | Kraken | LseFx | LseCrypto | LseEquities
227            | MassiveStocks | MassiveCrypto | MassiveForex | Okx,
228            Spot,
229        ) => true,
230        (_, Spot) => false,
231
232        // Future
233        (GateioFuturesUsd | GateioFuturesBtc | Okx, Future { .. }) => true,
234        (_, Future { .. }) => false,
235
236        // Perpetual
237        (
238            BinanceFuturesUsd | Bitmex | Okx | BybitPerpetualsUsd | GateioPerpetualsUsd
239            | GateioPerpetualsBtc | HyperliquidPerp,
240            Perpetual,
241        ) => true,
242        (_, Perpetual) => false,
243
244        // Option
245        (GateioOptions | Okx | LseOptions | MassiveOptions, Option { .. }) => true,
246        (_, Option { .. }) => false,
247
248        // Cfd
249        (LseCfd | LseFutures, Cfd) => true,
250        (_, Cfd) => false,
251    }
252}
253
254impl<Instrument> Validator for Subscription<ExchangeId, Instrument, SubKind>
255where
256    Instrument: InstrumentData,
257{
258    type Error = SocketError;
259
260    fn validate(self) -> Result<Self, Self::Error>
261    where
262        Self: Sized,
263    {
264        // Validate the Exchange supports the Subscription InstrumentKind
265        if exchange_supports_instrument_kind_sub_kind(
266            &self.exchange,
267            self.instrument.kind(),
268            self.kind,
269        ) {
270            Ok(self)
271        } else {
272            Err(SocketError::Unsupported {
273                entity: self.exchange.to_string(),
274                item: format!("({}, {})", self.instrument.kind(), self.kind),
275            })
276        }
277    }
278}
279
280/// Determines whether the [`Connector`] associated with this [`ExchangeId`] supports the
281/// ingestion of market data for the provided [`MarketDataInstrumentKind`] and [`SubKind`] combination.
282///
283/// [`SubKind::Candles`] carries its resolution, so support is checked **per interval**:
284/// [`CandleInterval`] is the venue-agnostic union of every resolution any connector
285/// serves, and no venue serves all of it (Binance publishes no `5s`/`15s`/`30s` kline).
286pub fn exchange_supports_instrument_kind_sub_kind(
287    exchange_id: &ExchangeId,
288    instrument_kind: &MarketDataInstrumentKind,
289    sub_kind: SubKind,
290) -> bool {
291    use ExchangeId::*;
292    use MarketDataInstrumentKind::*;
293    use SubKind::*;
294
295    match (exchange_id, instrument_kind, sub_kind) {
296        (BinanceSpot, Spot, PublicTrades | OrderBooksL1 | OrderBooksL2) => true,
297        (BinanceSpot, Spot, Candles { interval }) => {
298            crate::exchange::binance::supports_candle_interval(interval)
299        }
300        (
301            BinanceFuturesUsd,
302            Perpetual,
303            PublicTrades | OrderBooksL1 | OrderBooksL2 | Liquidations,
304        ) => true,
305        (BinanceFuturesUsd, Perpetual, Candles { interval }) => {
306            crate::exchange::binance::supports_candle_interval(interval)
307        }
308        (Bitfinex, Spot, PublicTrades) => true,
309        (Bitmex, Perpetual, PublicTrades) => true,
310        (BybitSpot, Spot, PublicTrades | OrderBooksL1 | OrderBooksL2) => true,
311        (BybitPerpetualsUsd, Perpetual, PublicTrades | OrderBooksL1 | OrderBooksL2) => true,
312        (Coinbase, Spot, PublicTrades) => true,
313        (GateioSpot, Spot, PublicTrades) => true,
314        (GateioFuturesUsd, Future { .. }, PublicTrades) => true,
315        (GateioFuturesBtc, Future { .. }, PublicTrades) => true,
316        (GateioPerpetualsUsd, Perpetual, PublicTrades) => true,
317        (GateioPerpetualsBtc, Perpetual, PublicTrades) => true,
318        (GateioOptions, Option { .. }, PublicTrades) => true,
319        (Kraken, Spot, PublicTrades | OrderBooksL1) => true,
320        (Okx, Spot | Future { .. } | Perpetual | Option { .. }, PublicTrades) => true,
321        (HyperliquidPerp, Perpetual, PublicTrades | OrderBooksL2) => true,
322        (HyperliquidSpot, Spot, PublicTrades | OrderBooksL2) => true,
323
324        // Alpaca's top of book is its quote: `OrderBooksL1` reads the `quotes` channel, which the
325        // typed `Quotes` kind also serves. `Quotes` itself has no arm because `DynamicStreams`, the
326        // one caller of this matrix, carries no quote channel, and the engine's market data no quote
327        // event. IEX quotes are IEX's own top of book rather than the NBBO; SIP quotes are the NBBO.
328        //
329        // ⚠️ `AlpacaSip` is declared on the strength of the code path, which it shares with IEX, and
330        // has never been run against the real feed: it needs a paid subscription. See
331        // `exchange::alpaca`.
332        (AlpacaCrypto | AlpacaIex | AlpacaSip, Spot, PublicTrades | OrderBooksL1) => true,
333
334        // London Strategic Edge serves both kinds from ONE frame: its WebSocket publishes a single
335        // tick carrying `price`, `bid`, `ask` and `volume` together, so a dataset that serves one
336        // of these kinds necessarily serves the other. The two arms differ only in instrument kind,
337        // which is fixed per dataset.
338        //
339        // ⚠️ A `PublicTrade` from this feed MAY NOT BE A PRINT. The tick's `price` equals its `bid`
340        // on every sample taken -- 3,966 of 3,966 ticks spanning every dataset family -- so a trade
341        // decoded from it is a bid-side quote wearing a trade's shape, and its arrival is not
342        // evidence that a transaction occurred, at that price or at all.
343        //
344        // ⚠️ And its `amount` is genuine on two of these venues and FABRICATED on the other two,
345        // with no in-band signal separating them. `LseCrypto` and `LseEquities` carry a real
346        // per-tick size, which reconciles against the provider's own one-minute candles to the last
347        // decimal. `LseFx` and `LseCfd` carry a hard-coded `1.0` on every tick of every symbol
348        // sampled -- a placeholder that, being a plausible positive number, aggregates into a
349        // legitimate-looking total at any resolution, leaving volume-weighted prices and size
350        // filters on those two meaningless rather than merely imprecise.
351        //
352        // Support is declared regardless, and uniformly: the frame is identical across datasets, so
353        // withholding the kind would hide the feed rather than the hazard. Saying so is the useful
354        // answer. The full reasoning lives on the trade decoder in the `lse` module.
355        (LseFx | LseCrypto | LseEquities, Spot, PublicTrades | OrderBooksL1) => true,
356        (LseCfd | LseFutures, Cfd, PublicTrades | OrderBooksL1) => true,
357        // Option contracts tick on the same frame, subscribed per underlying, but with `bid` and
358        // `ask` null on every one -- there is no quote to serve, so `PublicTrades` is the only
359        // kind. Unlike the venues above, these trades ARE prints: they reconcile one-for-one
360        // against the provider's REST option-print tape.
361        (LseOptions, Option { .. }, PublicTrades) => true,
362        // No `Candles` arm exists for any London Strategic Edge venue, deliberately: the provider's
363        // WebSocket carries no candle channel at all -- the tick above is its only data frame, so
364        // there is nothing to subscribe to. Its candles are served exclusively over the REST vault,
365        // by a path that never reaches this matrix. The absence is a decision, not an omission.
366
367        // Massive: a cluster per `ExchangeId`. The top of book is the cluster's quote channel, read
368        // as `OrderBooksL1`; `Quotes` itself has no arm, for the reason given for Alpaca above.
369        // Forex publishes no trades. Candles are Massive's per-second and per-minute aggregates,
370        // the only intervals it aggregates at; the connectors refuse any other, and this matrix
371        // refuses it first. `Massive` alone is the REST client's id and streams nothing.
372        //
373        // ⚠️ `MassiveOptions` is declared on the strength of the code path, which it shares with
374        // stocks, and has never been run against the real feed: it needs an options subscription.
375        (MassiveStocks | MassiveCrypto, Spot, PublicTrades | OrderBooksL1) => true,
376        (MassiveForex, Spot, OrderBooksL1) => true,
377        (MassiveOptions, Option { .. }, PublicTrades | OrderBooksL1) => true,
378        (
379            MassiveStocks | MassiveCrypto | MassiveForex,
380            Spot,
381            Candles {
382                interval: CandleInterval::Sec1 | CandleInterval::Min1,
383            },
384        ) => true,
385        (
386            MassiveOptions,
387            Option { .. },
388            Candles {
389                interval: CandleInterval::Sec1 | CandleInterval::Min1,
390            },
391        ) => true,
392
393        (_, _, _) => false,
394    }
395}
396
397/// Metadata generated from a collection of Barter [`Subscription`]s, including the exchange
398/// specific subscription payloads that are sent to the exchange.
399#[derive(Clone, Eq, PartialEq, Debug)]
400pub struct SubscriptionMeta<InstrumentKey> {
401    /// `HashMap` containing the mapping between a [`SubscriptionId`] and
402    /// it's associated Barter [`MarketDataInstrument`].
403    pub instrument_map: Map<InstrumentKey>,
404    /// Collection of [`WsMessage`]s containing exchange specific subscription payloads to be sent.
405    pub ws_subscriptions: Vec<WsMessage>,
406}
407
408/// New type`HashMap` that maps a [`SubscriptionId`] to some associated type `T`.
409///
410/// Used by [`ExchangeTransformer`](crate::transformer::ExchangeTransformer)s to identify the
411/// Barter [`MarketDataInstrument`] associated with incoming exchange messages.
412#[derive(Clone, Eq, PartialEq, Debug, Deserialize, Serialize)]
413pub struct Map<T>(pub FnvHashMap<SubscriptionId, T>);
414
415impl<T> FromIterator<(SubscriptionId, T)> for Map<T> {
416    fn from_iter<Iter>(iter: Iter) -> Self
417    where
418        Iter: IntoIterator<Item = (SubscriptionId, T)>,
419    {
420        Self(iter.into_iter().collect::<FnvHashMap<SubscriptionId, T>>())
421    }
422}
423
424impl<T> Map<T> {
425    /// Find the `InstrumentKey` associated with the provided [`SubscriptionId`].
426    pub fn find<SubId>(&self, id: &SubId) -> Result<&T, SocketError>
427    where
428        SubscriptionId: Borrow<SubId>,
429        SubId: AsRef<str> + Hash + Eq + ?Sized,
430    {
431        self.0
432            .get(id)
433            .ok_or_else(|| SocketError::Unidentifiable(SubscriptionId(id.as_ref().to_smolstr())))
434    }
435
436    /// Find the mutable reference to `T` associated with the provided [`SubscriptionId`].
437    pub fn find_mut<SubId>(&mut self, id: &SubId) -> Result<&mut T, SocketError>
438    where
439        SubscriptionId: Borrow<SubId>,
440        SubId: AsRef<str> + Hash + Eq + ?Sized,
441    {
442        self.0
443            .get_mut(id)
444            .ok_or_else(|| SocketError::Unidentifiable(SubscriptionId(id.as_ref().to_smolstr())))
445    }
446}
447
448#[cfg(test)]
449#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
450mod tests {
451    use super::*;
452
453    mod supports {
454        use super::*;
455        use chrono::{DateTime, Utc};
456        use rust_decimal::Decimal;
457        use rustrade_instrument::{
458            exchange::ExchangeId,
459            instrument::{
460                kind::option::{OptionExercise, OptionKind},
461                market_data::kind::MarketDataOptionContract,
462            },
463        };
464
465        /// Spot is granted to exactly the exchanges whose connector serves a spot market, and
466        /// denied to every other, connector or not, rather than claimed by default.
467        #[test]
468        fn spot_is_granted_only_where_a_connector_serves_it() {
469            use ExchangeId::*;
470            for exchange in [
471                AlpacaCrypto,
472                AlpacaIex,
473                AlpacaSip,
474                BinanceSpot,
475                Bitfinex,
476                BybitSpot,
477                Coinbase,
478                GateioSpot,
479                HyperliquidSpot,
480                Kraken,
481                LseFx,
482                LseCrypto,
483                LseEquities,
484                MassiveStocks,
485                MassiveCrypto,
486                MassiveForex,
487                Okx,
488            ] {
489                assert!(
490                    exchange_supports_instrument_kind(exchange, &MarketDataInstrumentKind::Spot),
491                    "{exchange:?}"
492                );
493            }
494            for exchange in [
495                BinanceFuturesUsd,
496                Bitmex,
497                BybitPerpetualsUsd,
498                GateioFuturesBtc,
499                GateioFuturesUsd,
500                GateioOptions,
501                GateioPerpetualsBtc,
502                GateioPerpetualsUsd,
503                HyperliquidPerp,
504                LseCfd,
505                LseFutures,
506                LseOptions,
507                MassiveOptions,
508                // No connector at all: a sample, as `ExchangeId` cannot be enumerated. Add a new
509                // connector-backed exchange to one of the two lists.
510                BinanceMargin,
511                Deribit,
512                Other,
513            ] {
514                assert!(
515                    !exchange_supports_instrument_kind(exchange, &MarketDataInstrumentKind::Spot),
516                    "{exchange:?}"
517                );
518            }
519        }
520
521        /// The London Strategic Edge datasets, paired with the single
522        /// [`MarketDataInstrumentKind`] each one serves.
523        const LSE: [(ExchangeId, MarketDataInstrumentKind); 5] = [
524            (ExchangeId::LseFx, MarketDataInstrumentKind::Spot),
525            (ExchangeId::LseCrypto, MarketDataInstrumentKind::Spot),
526            (ExchangeId::LseEquities, MarketDataInstrumentKind::Spot),
527            (ExchangeId::LseFutures, MarketDataInstrumentKind::Cfd),
528            (ExchangeId::LseCfd, MarketDataInstrumentKind::Cfd),
529        ];
530
531        #[test]
532        fn test_lse_supports_exactly_its_own_kind() {
533            // Each dataset serves one kind and must deny the others.
534            for (exchange, supported) in LSE {
535                assert!(
536                    exchange_supports_instrument_kind(exchange, &supported),
537                    "{exchange:?} should support {supported:?}"
538                );
539
540                for denied in [
541                    MarketDataInstrumentKind::Spot,
542                    MarketDataInstrumentKind::Cfd,
543                    MarketDataInstrumentKind::Perpetual,
544                ] {
545                    if denied == supported {
546                        continue;
547                    }
548                    assert!(
549                        !exchange_supports_instrument_kind(exchange, &denied),
550                        "{exchange:?} should not support {denied:?}"
551                    );
552                }
553            }
554        }
555
556        fn option_kind() -> MarketDataInstrumentKind {
557            MarketDataInstrumentKind::Option(MarketDataOptionContract {
558                kind: OptionKind::Call,
559                exercise: OptionExercise::American,
560                expiry: DateTime::<Utc>::MAX_UTC,
561                strike: Decimal::ONE,
562            })
563        }
564
565        #[test]
566        fn test_lse_options_supports_only_the_option_kind() {
567            assert!(exchange_supports_instrument_kind(
568                ExchangeId::LseOptions,
569                &option_kind()
570            ));
571            for denied in [
572                MarketDataInstrumentKind::Spot,
573                MarketDataInstrumentKind::Cfd,
574                MarketDataInstrumentKind::Perpetual,
575            ] {
576                assert!(
577                    !exchange_supports_instrument_kind(ExchangeId::LseOptions, &denied),
578                    "LseOptions should not support {denied:?}"
579                );
580            }
581        }
582
583        #[test]
584        fn test_lse_options_serves_public_trades_only() {
585            assert!(exchange_supports_instrument_kind_sub_kind(
586                &ExchangeId::LseOptions,
587                &option_kind(),
588                SubKind::PublicTrades
589            ));
590
591            // Every option tick publishes `bid` and `ask` as null, so there is no quote to serve.
592            for sub_kind in [
593                SubKind::OrderBooksL1,
594                SubKind::OrderBooksL2,
595                SubKind::OrderBooksL3,
596            ] {
597                assert!(
598                    !exchange_supports_instrument_kind_sub_kind(
599                        &ExchangeId::LseOptions,
600                        &option_kind(),
601                        sub_kind
602                    ),
603                    "LseOptions should not serve {sub_kind}"
604                );
605            }
606
607            assert!(
608                !exchange_supports_instrument_kind_sub_kind(
609                    &ExchangeId::LseOptions,
610                    &MarketDataInstrumentKind::Spot,
611                    SubKind::PublicTrades
612                ),
613                "LseOptions serves option contracts only"
614            );
615        }
616
617        #[test]
618        fn test_cfd_denied_for_non_cfd_exchanges() {
619            for exchange in [
620                ExchangeId::BinanceSpot,
621                ExchangeId::Coinbase,
622                ExchangeId::Okx,
623                ExchangeId::GateioOptions,
624                ExchangeId::Mock,
625            ] {
626                assert!(
627                    !exchange_supports_instrument_kind(exchange, &MarketDataInstrumentKind::Cfd),
628                    "{exchange:?} should not support Cfd"
629                );
630            }
631        }
632
633        #[test]
634        fn test_lse_supports_both_tick_derived_sub_kinds_for_its_own_kind_only() {
635            // One tick frame carries price, bid, ask and size together, so every dataset serves
636            // both kinds decoded from it -- and serves neither under an instrument kind it does
637            // not model.
638            for (exchange, supported) in LSE {
639                for sub_kind in [SubKind::PublicTrades, SubKind::OrderBooksL1] {
640                    assert!(
641                        exchange_supports_instrument_kind_sub_kind(&exchange, &supported, sub_kind),
642                        "{exchange:?} should support ({supported:?}, {sub_kind})"
643                    );
644
645                    for denied in [
646                        MarketDataInstrumentKind::Spot,
647                        MarketDataInstrumentKind::Cfd,
648                        MarketDataInstrumentKind::Perpetual,
649                    ] {
650                        if denied == supported {
651                            continue;
652                        }
653                        assert!(
654                            !exchange_supports_instrument_kind_sub_kind(
655                                &exchange, &denied, sub_kind
656                            ),
657                            "{exchange:?} should not support ({denied:?}, {sub_kind})"
658                        );
659                    }
660                }
661            }
662        }
663
664        #[test]
665        fn test_lse_serves_no_candle_stream_at_any_interval() {
666            // Pins the absent `Candles` arm as a decision: the provider's WebSocket has no candle
667            // channel, and its candles reach users over the REST vault, which never consults this
668            // matrix. A future edit that "completes" the LSE rows fails here.
669            for (exchange, kind) in LSE {
670                for interval in CandleInterval::ALL {
671                    assert!(
672                        !exchange_supports_instrument_kind_sub_kind(
673                            &exchange,
674                            &kind,
675                            SubKind::Candles { interval },
676                        ),
677                        "{exchange:?} serves no candle stream, including {interval:?}"
678                    );
679                }
680            }
681        }
682
683        #[test]
684        fn test_lse_denies_sub_kinds_its_tick_cannot_serve() {
685            // The tick is top-of-book, so there is no depth to serve; the feed publishes no
686            // liquidations at all. `Quotes` is denied here as it is for every other venue -- no
687            // exchange declares it in this matrix.
688            for (exchange, kind) in LSE {
689                for sub_kind in [
690                    SubKind::OrderBooksL2,
691                    SubKind::OrderBooksL3,
692                    SubKind::Liquidations,
693                    SubKind::Quotes,
694                ] {
695                    assert!(
696                        !exchange_supports_instrument_kind_sub_kind(&exchange, &kind, sub_kind),
697                        "{exchange:?} should not support ({kind:?}, {sub_kind})"
698                    );
699                }
700            }
701        }
702
703        #[test]
704        fn hyperliquid_serves_trades_and_depth_on_each_connector_for_its_own_kind_only() {
705            for (exchange, kind) in [
706                (
707                    ExchangeId::HyperliquidPerp,
708                    MarketDataInstrumentKind::Perpetual,
709                ),
710                (ExchangeId::HyperliquidSpot, MarketDataInstrumentKind::Spot),
711            ] {
712                for sub_kind in [SubKind::PublicTrades, SubKind::OrderBooksL2] {
713                    assert!(
714                        exchange_supports_instrument_kind_sub_kind(&exchange, &kind, sub_kind),
715                        "{exchange:?} should support ({kind:?}, {sub_kind})"
716                    );
717                }
718                for sub_kind in [
719                    SubKind::OrderBooksL1,
720                    SubKind::OrderBooksL3,
721                    SubKind::Liquidations,
722                    SubKind::Quotes,
723                ] {
724                    assert!(
725                        !exchange_supports_instrument_kind_sub_kind(&exchange, &kind, sub_kind),
726                        "{exchange:?} should not support ({kind:?}, {sub_kind})"
727                    );
728                }
729            }
730            assert!(!exchange_supports_instrument_kind_sub_kind(
731                &ExchangeId::HyperliquidSpot,
732                &MarketDataInstrumentKind::Perpetual,
733                SubKind::PublicTrades
734            ));
735            assert!(!exchange_supports_instrument_kind_sub_kind(
736                &ExchangeId::HyperliquidPerp,
737                &MarketDataInstrumentKind::Spot,
738                SubKind::PublicTrades
739            ));
740        }
741    }
742
743    mod supports_massive {
744        use super::*;
745        use rustrade_instrument::instrument::{
746            kind::option::{OptionExercise, OptionKind},
747            market_data::kind::MarketDataOptionContract,
748        };
749
750        fn option_kind() -> MarketDataInstrumentKind {
751            MarketDataInstrumentKind::Option(MarketDataOptionContract {
752                kind: OptionKind::Call,
753                exercise: OptionExercise::American,
754                expiry: chrono::DateTime::<chrono::Utc>::MAX_UTC,
755                strike: rust_decimal::Decimal::ONE,
756            })
757        }
758
759        /// Each cluster, paired with the one instrument kind it streams.
760        fn clusters() -> [(ExchangeId, MarketDataInstrumentKind); 4] {
761            [
762                (ExchangeId::MassiveStocks, MarketDataInstrumentKind::Spot),
763                (ExchangeId::MassiveCrypto, MarketDataInstrumentKind::Spot),
764                (ExchangeId::MassiveForex, MarketDataInstrumentKind::Spot),
765                (ExchangeId::MassiveOptions, option_kind()),
766            ]
767        }
768
769        fn other_kinds() -> [MarketDataInstrumentKind; 4] {
770            [
771                MarketDataInstrumentKind::Spot,
772                MarketDataInstrumentKind::Perpetual,
773                MarketDataInstrumentKind::Cfd,
774                option_kind(),
775            ]
776        }
777
778        #[test]
779        fn each_cluster_supports_exactly_its_own_instrument_kind() {
780            for (exchange, supported) in clusters() {
781                assert!(
782                    exchange_supports_instrument_kind(exchange, &supported),
783                    "{exchange:?} should support {supported:?}"
784                );
785                for denied in other_kinds().into_iter().filter(|kind| *kind != supported) {
786                    assert!(
787                        !exchange_supports_instrument_kind(exchange, &denied),
788                        "{exchange:?} should not support {denied:?}"
789                    );
790                    assert!(
791                        !exchange_supports_instrument_kind_sub_kind(
792                            &exchange,
793                            &denied,
794                            SubKind::OrderBooksL1
795                        ),
796                        "{exchange:?} should not serve {denied:?}"
797                    );
798                }
799            }
800        }
801
802        #[test]
803        fn every_cluster_but_forex_serves_trades_and_every_cluster_serves_top_of_book() {
804            for (exchange, kind) in clusters() {
805                assert_eq!(
806                    exchange_supports_instrument_kind_sub_kind(
807                        &exchange,
808                        &kind,
809                        SubKind::PublicTrades
810                    ),
811                    exchange != ExchangeId::MassiveForex,
812                    "{exchange:?} trades"
813                );
814                assert!(
815                    exchange_supports_instrument_kind_sub_kind(
816                        &exchange,
817                        &kind,
818                        SubKind::OrderBooksL1
819                    ),
820                    "{exchange:?} top of book"
821                );
822            }
823        }
824
825        #[test]
826        fn candles_are_served_at_one_second_and_one_minute_only() {
827            for (exchange, kind) in clusters() {
828                for interval in CandleInterval::ALL {
829                    assert_eq!(
830                        exchange_supports_instrument_kind_sub_kind(
831                            &exchange,
832                            &kind,
833                            SubKind::Candles { interval }
834                        ),
835                        matches!(interval, CandleInterval::Sec1 | CandleInterval::Min1),
836                        "{exchange:?} candles at {interval:?}"
837                    );
838                }
839            }
840        }
841
842        #[test]
843        fn quotes_depth_and_liquidations_are_refused() {
844            for (exchange, kind) in clusters() {
845                for sub_kind in [
846                    SubKind::Quotes,
847                    SubKind::OrderBooksL2,
848                    SubKind::OrderBooksL3,
849                    SubKind::Liquidations,
850                ] {
851                    assert!(
852                        !exchange_supports_instrument_kind_sub_kind(&exchange, &kind, sub_kind),
853                        "{exchange:?} should not serve {sub_kind}"
854                    );
855                }
856            }
857        }
858
859        #[test]
860        fn the_rest_client_id_streams_nothing() {
861            for kind in other_kinds() {
862                for sub_kind in [SubKind::PublicTrades, SubKind::OrderBooksL1] {
863                    assert!(!exchange_supports_instrument_kind_sub_kind(
864                        &ExchangeId::Massive,
865                        &kind,
866                        sub_kind
867                    ));
868                }
869            }
870        }
871    }
872
873    mod subscription {
874        use super::*;
875        use crate::{
876            exchange::{coinbase::Coinbase, okx::Okx},
877            subscription::trade::PublicTrades,
878        };
879        use rustrade_instrument::instrument::market_data::MarketDataInstrument;
880
881        mod de {
882            use super::*;
883            use crate::{
884                exchange::{
885                    binance::{futures::BinanceFuturesUsd, spot::BinanceSpot},
886                    gateio::perpetual::GateioPerpetualsUsd,
887                    okx::Okx,
888                },
889                subscription::{book::OrderBooksL2, trade::PublicTrades},
890            };
891            use rustrade_instrument::instrument::market_data::MarketDataInstrument;
892
893            #[test]
894            fn test_subscription_okx_spot_public_trades() {
895                let input = r#"
896                {
897                    "exchange": "okx",
898                    "base": "btc",
899                    "quote": "usdt",
900                    "instrument_kind": "spot",
901                    "kind": "public_trades"
902                }
903                "#;
904
905                serde_json::from_str::<Subscription<Okx, MarketDataInstrument, PublicTrades>>(
906                    input,
907                )
908                .unwrap();
909            }
910
911            #[test]
912            fn test_subscription_binance_spot_public_trades() {
913                let input = r#"
914                {
915                    "exchange": "binance_spot",
916                    "base": "btc",
917                    "quote": "usdt",
918                    "instrument_kind": "spot",
919                    "kind": "public_trades"
920                }
921                "#;
922
923                serde_json::from_str::<Subscription<BinanceSpot, MarketDataInstrument, PublicTrades>>(input)
924                    .unwrap();
925            }
926
927            #[test]
928            fn test_subscription_binance_futures_usd_order_books_l2() {
929                let input = r#"
930                {
931                    "exchange": "binance_futures_usd",
932                    "base": "btc",
933                    "quote": "usdt",
934                    "instrument_kind": "perpetual",
935                    "kind": "order_books_l2"
936                }
937                "#;
938
939                serde_json::from_str::<
940                    Subscription<BinanceFuturesUsd, MarketDataInstrument, OrderBooksL2>,
941                >(input)
942                .unwrap();
943            }
944
945            #[test]
946            fn subscription_gateio_futures_usd_public_trades() {
947                let input = r#"
948                {
949                    "exchange": "gateio_perpetuals_usd",
950                    "base": "btc",
951                    "quote": "usdt",
952                    "instrument_kind": "perpetual",
953                    "kind": "public_trades"
954                }
955                "#;
956
957                serde_json::from_str::<
958                    Subscription<GateioPerpetualsUsd, MarketDataInstrument, PublicTrades>,
959                >(input)
960                .unwrap();
961            }
962        }
963
964        #[test]
965        fn test_validate_bitfinex_public_trades() {
966            struct TestCase {
967                input: Subscription<Coinbase, MarketDataInstrument, PublicTrades>,
968                expected:
969                    Result<Subscription<Coinbase, MarketDataInstrument, PublicTrades>, SocketError>,
970            }
971
972            let tests = vec![
973                TestCase {
974                    // TC0: Valid Coinbase Spot PublicTrades subscription
975                    input: Subscription::from((
976                        Coinbase,
977                        "base",
978                        "quote",
979                        MarketDataInstrumentKind::Spot,
980                        PublicTrades,
981                    )),
982                    expected: Ok(Subscription::from((
983                        Coinbase,
984                        "base",
985                        "quote",
986                        MarketDataInstrumentKind::Spot,
987                        PublicTrades,
988                    ))),
989                },
990                TestCase {
991                    // TC1: Invalid Coinbase FuturePerpetual PublicTrades subscription
992                    input: Subscription::from((
993                        Coinbase,
994                        "base",
995                        "quote",
996                        MarketDataInstrumentKind::Perpetual,
997                        PublicTrades,
998                    )),
999                    expected: Err(SocketError::Unsupported {
1000                        entity: "".to_string(),
1001                        item: "".to_string(),
1002                    }),
1003                },
1004            ];
1005
1006            for (index, test) in tests.into_iter().enumerate() {
1007                let actual = test.input.validate();
1008                match (actual, test.expected) {
1009                    (Ok(actual), Ok(expected)) => {
1010                        assert_eq!(actual, expected, "TC{} failed", index)
1011                    }
1012                    (Err(_), Err(_)) => {
1013                        // Test passed
1014                    }
1015                    (actual, expected) => {
1016                        // Test failed
1017                        panic!(
1018                            "TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"
1019                        );
1020                    }
1021                }
1022            }
1023        }
1024
1025        #[test]
1026        fn test_validate_okx_public_trades() {
1027            struct TestCase {
1028                input: Subscription<Okx, MarketDataInstrument, PublicTrades>,
1029                expected:
1030                    Result<Subscription<Okx, MarketDataInstrument, PublicTrades>, SocketError>,
1031            }
1032
1033            let tests = vec![
1034                TestCase {
1035                    // TC0: Valid Okx Spot PublicTrades subscription
1036                    input: Subscription::from((
1037                        Okx,
1038                        "base",
1039                        "quote",
1040                        MarketDataInstrumentKind::Spot,
1041                        PublicTrades,
1042                    )),
1043                    expected: Ok(Subscription::from((
1044                        Okx,
1045                        "base",
1046                        "quote",
1047                        MarketDataInstrumentKind::Spot,
1048                        PublicTrades,
1049                    ))),
1050                },
1051                TestCase {
1052                    // TC1: Valid Okx FuturePerpetual PublicTrades subscription
1053                    input: Subscription::from((
1054                        Okx,
1055                        "base",
1056                        "quote",
1057                        MarketDataInstrumentKind::Perpetual,
1058                        PublicTrades,
1059                    )),
1060                    expected: Ok(Subscription::from((
1061                        Okx,
1062                        "base",
1063                        "quote",
1064                        MarketDataInstrumentKind::Perpetual,
1065                        PublicTrades,
1066                    ))),
1067                },
1068            ];
1069
1070            for (index, test) in tests.into_iter().enumerate() {
1071                let actual = test.input.validate();
1072                match (actual, test.expected) {
1073                    (Ok(actual), Ok(expected)) => {
1074                        assert_eq!(actual, expected, "TC{} failed", index)
1075                    }
1076                    (Err(_), Err(_)) => {
1077                        // Test passed
1078                    }
1079                    (actual, expected) => {
1080                        // Test failed
1081                        panic!(
1082                            "TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"
1083                        );
1084                    }
1085                }
1086            }
1087        }
1088    }
1089
1090    mod sub_kind {
1091        use super::*;
1092
1093        #[test]
1094        fn candles_variant_serde_round_trips() {
1095            let kind = SubKind::Candles {
1096                interval: CandleInterval::Min1,
1097            };
1098
1099            let json = serde_json::to_string(&kind).unwrap();
1100            let back = serde_json::from_str::<SubKind>(&json).unwrap();
1101
1102            assert_eq!(kind, back, "SubKind::Candles must serde round-trip");
1103        }
1104
1105        #[test]
1106        fn candles_variant_serde_shape_is_externally_tagged() {
1107            // `SubKind` is an externally-tagged enum (no `#[serde(tag = ...)]`), so the struct
1108            // variant serialises as `{"Candles":{"interval":"1m"}}` — NOT an internally-tagged
1109            // `{"type":"candles",...}` form. Pin the wire shape so a future tag/rename change is
1110            // caught here rather than silently shipped to config-driven consumers.
1111            let json = serde_json::to_string(&SubKind::Candles {
1112                interval: CandleInterval::Min1,
1113            })
1114            .unwrap();
1115            assert_eq!(json, r#"{"Candles":{"interval":"1m"}}"#);
1116        }
1117
1118        #[test]
1119        fn candles_display_tag_is_interval_independent() {
1120            // The `derive_more::Display` tag is the fixed kind name, never the interval.
1121            assert_eq!(
1122                SubKind::Candles {
1123                    interval: CandleInterval::Sec1
1124                }
1125                .to_string(),
1126                "candles"
1127            );
1128            assert_eq!(
1129                SubKind::Candles {
1130                    interval: CandleInterval::Month1
1131                }
1132                .to_string(),
1133                "candles"
1134            );
1135        }
1136
1137        #[test]
1138        fn candles_supported_per_interval_on_both_binance_venues() {
1139            // `CandleInterval` is a venue-agnostic union, so candle support is decided
1140            // per interval, not per `SubKind`: both Binance venues serve every interval
1141            // Binance publishes a kline for, and neither serves `5s`/`15s`/`30s`.
1142            for interval in CandleInterval::ALL {
1143                let expected = crate::exchange::binance::supports_candle_interval(interval);
1144                assert_eq!(
1145                    exchange_supports_instrument_kind_sub_kind(
1146                        &ExchangeId::BinanceSpot,
1147                        &MarketDataInstrumentKind::Spot,
1148                        SubKind::Candles { interval },
1149                    ),
1150                    expected,
1151                    "BinanceSpot/Spot Candles {interval:?}"
1152                );
1153                assert_eq!(
1154                    exchange_supports_instrument_kind_sub_kind(
1155                        &ExchangeId::BinanceFuturesUsd,
1156                        &MarketDataInstrumentKind::Perpetual,
1157                        SubKind::Candles { interval },
1158                    ),
1159                    expected,
1160                    "BinanceFuturesUsd/Perpetual Candles {interval:?}"
1161                );
1162            }
1163        }
1164
1165        #[test]
1166        fn candles_rejected_for_unsupported_venue_kind_pairing() {
1167            // A supported venue with the wrong instrument kind is still rejected.
1168            assert!(
1169                !exchange_supports_instrument_kind_sub_kind(
1170                    &ExchangeId::BinanceSpot,
1171                    &MarketDataInstrumentKind::Perpetual,
1172                    SubKind::Candles {
1173                        interval: CandleInterval::Min1,
1174                    },
1175                ),
1176                "BinanceSpot does not serve Perpetual candles"
1177            );
1178        }
1179    }
1180
1181    mod instrument_map {
1182        use super::*;
1183        use rustrade_instrument::instrument::market_data::MarketDataInstrument;
1184
1185        #[test]
1186        fn test_find_instrument() {
1187            // Initialise SubscriptionId-InstrumentKey HashMap
1188            let ids = Map(FnvHashMap::from_iter([(
1189                SubscriptionId::from("present"),
1190                MarketDataInstrument::from(("base", "quote", MarketDataInstrumentKind::Spot)),
1191            )]));
1192
1193            struct TestCase {
1194                input: SubscriptionId,
1195                expected: Result<MarketDataInstrument, SocketError>,
1196            }
1197
1198            let cases = vec![
1199                TestCase {
1200                    // TC0: SubscriptionId (channel) is present in the HashMap
1201                    input: SubscriptionId::from("present"),
1202                    expected: Ok(MarketDataInstrument::from((
1203                        "base",
1204                        "quote",
1205                        MarketDataInstrumentKind::Spot,
1206                    ))),
1207                },
1208                TestCase {
1209                    // TC1: SubscriptionId (channel) is not present in the HashMap
1210                    input: SubscriptionId::from("not present"),
1211                    expected: Err(SocketError::Unidentifiable(SubscriptionId::from(
1212                        "not present",
1213                    ))),
1214                },
1215            ];
1216
1217            for (index, test) in cases.into_iter().enumerate() {
1218                let actual = ids.find(&test.input);
1219                match (actual, test.expected) {
1220                    (Ok(actual), Ok(expected)) => {
1221                        assert_eq!(*actual, expected, "TC{} failed", index)
1222                    }
1223                    (Err(_), Err(_)) => {
1224                        // Test passed
1225                    }
1226                    (actual, expected) => {
1227                        // Test failed
1228                        panic!(
1229                            "TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"
1230                        );
1231                    }
1232                }
1233            }
1234        }
1235    }
1236}