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, Spot) => false,
230        // NOTE: this default is OPEN -- an exchange that serves no spot market claims spot support
231        // unless it is denied above, with no compile error to prompt the edit. It is left open
232        // deliberately: closing it means writing an explicit arm for every existing variant and
233        // re-verifying each one's spot coverage, which is tracked separately rather than smuggled
234        // in here. Deny explicitly above when adding a non-spot exchange.
235        (_, Spot) => true,
236
237        // Future
238        (GateioFuturesUsd | GateioFuturesBtc | Okx, Future { .. }) => true,
239        (_, Future { .. }) => false,
240
241        // Perpetual
242        (
243            BinanceFuturesUsd | Bitmex | Okx | BybitPerpetualsUsd | GateioPerpetualsUsd
244            | GateioPerpetualsBtc | HyperliquidPerp,
245            Perpetual,
246        ) => true,
247        (_, Perpetual) => false,
248
249        // Option
250        (GateioOptions | Okx, Option { .. }) => true,
251        (_, Option { .. }) => false,
252
253        // Cfd
254        (LseCfd | LseFutures, Cfd) => true,
255        (_, Cfd) => false,
256    }
257}
258
259impl<Instrument> Validator for Subscription<ExchangeId, Instrument, SubKind>
260where
261    Instrument: InstrumentData,
262{
263    type Error = SocketError;
264
265    fn validate(self) -> Result<Self, Self::Error>
266    where
267        Self: Sized,
268    {
269        // Validate the Exchange supports the Subscription InstrumentKind
270        if exchange_supports_instrument_kind_sub_kind(
271            &self.exchange,
272            self.instrument.kind(),
273            self.kind,
274        ) {
275            Ok(self)
276        } else {
277            Err(SocketError::Unsupported {
278                entity: self.exchange.to_string(),
279                item: format!("({}, {})", self.instrument.kind(), self.kind),
280            })
281        }
282    }
283}
284
285/// Determines whether the [`Connector`] associated with this [`ExchangeId`] supports the
286/// ingestion of market data for the provided [`MarketDataInstrumentKind`] and [`SubKind`] combination.
287///
288/// [`SubKind::Candles`] carries its resolution, so support is checked **per interval**:
289/// [`CandleInterval`] is the venue-agnostic union of every resolution any connector
290/// serves, and no venue serves all of it (Binance publishes no `5s`/`15s`/`30s` kline).
291pub fn exchange_supports_instrument_kind_sub_kind(
292    exchange_id: &ExchangeId,
293    instrument_kind: &MarketDataInstrumentKind,
294    sub_kind: SubKind,
295) -> bool {
296    use ExchangeId::*;
297    use MarketDataInstrumentKind::*;
298    use SubKind::*;
299
300    match (exchange_id, instrument_kind, sub_kind) {
301        (BinanceSpot, Spot, PublicTrades | OrderBooksL1 | OrderBooksL2) => true,
302        (BinanceSpot, Spot, Candles { interval }) => {
303            crate::exchange::binance::supports_candle_interval(interval)
304        }
305        (
306            BinanceFuturesUsd,
307            Perpetual,
308            PublicTrades | OrderBooksL1 | OrderBooksL2 | Liquidations,
309        ) => true,
310        (BinanceFuturesUsd, Perpetual, Candles { interval }) => {
311            crate::exchange::binance::supports_candle_interval(interval)
312        }
313        (Bitfinex, Spot, PublicTrades) => true,
314        (Bitmex, Perpetual, PublicTrades) => true,
315        (BybitSpot, Spot, PublicTrades | OrderBooksL1 | OrderBooksL2) => true,
316        (BybitPerpetualsUsd, Perpetual, PublicTrades | OrderBooksL1 | OrderBooksL2) => true,
317        (Coinbase, Spot, PublicTrades) => true,
318        (GateioSpot, Spot, PublicTrades) => true,
319        (GateioFuturesUsd, Future { .. }, PublicTrades) => true,
320        (GateioFuturesBtc, Future { .. }, PublicTrades) => true,
321        (GateioPerpetualsUsd, Perpetual, PublicTrades) => true,
322        (GateioPerpetualsBtc, Perpetual, PublicTrades) => true,
323        (GateioOptions, Option { .. }, PublicTrades) => true,
324        (Kraken, Spot, PublicTrades | OrderBooksL1) => true,
325        (Okx, Spot | Future { .. } | Perpetual | Option { .. }, PublicTrades) => true,
326        (HyperliquidPerp, Perpetual, PublicTrades | OrderBooksL2) => true,
327
328        // London Strategic Edge serves both kinds from ONE frame: its WebSocket publishes a single
329        // tick carrying `price`, `bid`, `ask` and `volume` together, so a dataset that serves one
330        // of these kinds necessarily serves the other. The two arms differ only in instrument kind,
331        // which is fixed per dataset.
332        //
333        // ⚠️ A `PublicTrade` from this feed MAY NOT BE A PRINT. The tick's `price` equals its `bid`
334        // on every sample taken -- 3,966 of 3,966 ticks spanning every dataset family -- so a trade
335        // decoded from it is a bid-side quote wearing a trade's shape, and its arrival is not
336        // evidence that a transaction occurred, at that price or at all.
337        //
338        // ⚠️ And its `amount` is genuine on two of these venues and FABRICATED on the other two,
339        // with no in-band signal separating them. `LseCrypto` and `LseEquities` carry a real
340        // per-tick size, which reconciles against the provider's own one-minute candles to the last
341        // decimal. `LseFx` and `LseCfd` carry a hard-coded `1.0` on every tick of every symbol
342        // sampled -- a placeholder that, being a plausible positive number, aggregates into a
343        // legitimate-looking total at any resolution, leaving volume-weighted prices and size
344        // filters on those two meaningless rather than merely imprecise.
345        //
346        // Support is declared regardless, and uniformly: the frame is identical across datasets, so
347        // withholding the kind would hide the feed rather than the hazard. Saying so is the useful
348        // answer. The full reasoning lives on the trade decoder in the `lse` module.
349        (LseFx | LseCrypto | LseEquities, Spot, PublicTrades | OrderBooksL1) => true,
350        (LseCfd | LseFutures, Cfd, PublicTrades | OrderBooksL1) => true,
351        // No `Candles` arm exists for any London Strategic Edge venue, deliberately: the provider's
352        // WebSocket carries no candle channel at all -- the tick above is its only data frame, so
353        // there is nothing to subscribe to. Its candles are served exclusively over the REST vault,
354        // by a path that never reaches this matrix. The absence is a decision, not an omission.
355        (_, _, _) => false,
356    }
357}
358
359/// Metadata generated from a collection of Barter [`Subscription`]s, including the exchange
360/// specific subscription payloads that are sent to the exchange.
361#[derive(Clone, Eq, PartialEq, Debug)]
362pub struct SubscriptionMeta<InstrumentKey> {
363    /// `HashMap` containing the mapping between a [`SubscriptionId`] and
364    /// it's associated Barter [`MarketDataInstrument`].
365    pub instrument_map: Map<InstrumentKey>,
366    /// Collection of [`WsMessage`]s containing exchange specific subscription payloads to be sent.
367    pub ws_subscriptions: Vec<WsMessage>,
368}
369
370/// New type`HashMap` that maps a [`SubscriptionId`] to some associated type `T`.
371///
372/// Used by [`ExchangeTransformer`](crate::transformer::ExchangeTransformer)s to identify the
373/// Barter [`MarketDataInstrument`] associated with incoming exchange messages.
374#[derive(Clone, Eq, PartialEq, Debug, Deserialize, Serialize)]
375pub struct Map<T>(pub FnvHashMap<SubscriptionId, T>);
376
377impl<T> FromIterator<(SubscriptionId, T)> for Map<T> {
378    fn from_iter<Iter>(iter: Iter) -> Self
379    where
380        Iter: IntoIterator<Item = (SubscriptionId, T)>,
381    {
382        Self(iter.into_iter().collect::<FnvHashMap<SubscriptionId, T>>())
383    }
384}
385
386impl<T> Map<T> {
387    /// Find the `InstrumentKey` associated with the provided [`SubscriptionId`].
388    pub fn find<SubId>(&self, id: &SubId) -> Result<&T, SocketError>
389    where
390        SubscriptionId: Borrow<SubId>,
391        SubId: AsRef<str> + Hash + Eq + ?Sized,
392    {
393        self.0
394            .get(id)
395            .ok_or_else(|| SocketError::Unidentifiable(SubscriptionId(id.as_ref().to_smolstr())))
396    }
397
398    /// Find the mutable reference to `T` associated with the provided [`SubscriptionId`].
399    pub fn find_mut<SubId>(&mut self, id: &SubId) -> Result<&mut T, SocketError>
400    where
401        SubscriptionId: Borrow<SubId>,
402        SubId: AsRef<str> + Hash + Eq + ?Sized,
403    {
404        self.0
405            .get_mut(id)
406            .ok_or_else(|| SocketError::Unidentifiable(SubscriptionId(id.as_ref().to_smolstr())))
407    }
408}
409
410#[cfg(test)]
411#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
412mod tests {
413    use super::*;
414
415    mod supports {
416        use super::*;
417        use rustrade_instrument::exchange::ExchangeId;
418
419        /// The London Strategic Edge datasets, paired with the single
420        /// [`MarketDataInstrumentKind`] each one serves.
421        const LSE: [(ExchangeId, MarketDataInstrumentKind); 5] = [
422            (ExchangeId::LseFx, MarketDataInstrumentKind::Spot),
423            (ExchangeId::LseCrypto, MarketDataInstrumentKind::Spot),
424            (ExchangeId::LseEquities, MarketDataInstrumentKind::Spot),
425            (ExchangeId::LseFutures, MarketDataInstrumentKind::Cfd),
426            (ExchangeId::LseCfd, MarketDataInstrumentKind::Cfd),
427        ];
428
429        #[test]
430        fn test_lse_supports_exactly_its_own_kind() {
431            // Each dataset serves one kind and must deny the others. The denials matter as much as
432            // the grants: `exchange_supports_instrument_kind`'s `(_, Spot) => true` default means
433            // a CFD-only dataset would otherwise claim spot support with no edit and no error.
434            for (exchange, supported) in LSE {
435                assert!(
436                    exchange_supports_instrument_kind(exchange, &supported),
437                    "{exchange:?} should support {supported:?}"
438                );
439
440                for denied in [
441                    MarketDataInstrumentKind::Spot,
442                    MarketDataInstrumentKind::Cfd,
443                    MarketDataInstrumentKind::Perpetual,
444                ] {
445                    if denied == supported {
446                        continue;
447                    }
448                    assert!(
449                        !exchange_supports_instrument_kind(exchange, &denied),
450                        "{exchange:?} should not support {denied:?}"
451                    );
452                }
453            }
454        }
455
456        #[test]
457        fn test_cfd_denied_for_non_cfd_exchanges() {
458            for exchange in [
459                ExchangeId::BinanceSpot,
460                ExchangeId::Coinbase,
461                ExchangeId::Okx,
462                ExchangeId::GateioOptions,
463                ExchangeId::Mock,
464            ] {
465                assert!(
466                    !exchange_supports_instrument_kind(exchange, &MarketDataInstrumentKind::Cfd),
467                    "{exchange:?} should not support Cfd"
468                );
469            }
470        }
471
472        #[test]
473        fn test_lse_supports_both_tick_derived_sub_kinds_for_its_own_kind_only() {
474            // One tick frame carries price, bid, ask and size together, so every dataset serves
475            // both kinds decoded from it -- and serves neither under an instrument kind it does
476            // not model.
477            for (exchange, supported) in LSE {
478                for sub_kind in [SubKind::PublicTrades, SubKind::OrderBooksL1] {
479                    assert!(
480                        exchange_supports_instrument_kind_sub_kind(&exchange, &supported, sub_kind),
481                        "{exchange:?} should support ({supported:?}, {sub_kind})"
482                    );
483
484                    for denied in [
485                        MarketDataInstrumentKind::Spot,
486                        MarketDataInstrumentKind::Cfd,
487                        MarketDataInstrumentKind::Perpetual,
488                    ] {
489                        if denied == supported {
490                            continue;
491                        }
492                        assert!(
493                            !exchange_supports_instrument_kind_sub_kind(
494                                &exchange, &denied, sub_kind
495                            ),
496                            "{exchange:?} should not support ({denied:?}, {sub_kind})"
497                        );
498                    }
499                }
500            }
501        }
502
503        #[test]
504        fn test_lse_serves_no_candle_stream_at_any_interval() {
505            // Pins the absent `Candles` arm as a decision: the provider's WebSocket has no candle
506            // channel, and its candles reach users over the REST vault, which never consults this
507            // matrix. A future edit that "completes" the LSE rows fails here.
508            for (exchange, kind) in LSE {
509                for interval in CandleInterval::ALL {
510                    assert!(
511                        !exchange_supports_instrument_kind_sub_kind(
512                            &exchange,
513                            &kind,
514                            SubKind::Candles { interval },
515                        ),
516                        "{exchange:?} serves no candle stream, including {interval:?}"
517                    );
518                }
519            }
520        }
521
522        #[test]
523        fn test_lse_denies_sub_kinds_its_tick_cannot_serve() {
524            // The tick is top-of-book, so there is no depth to serve; the feed publishes no
525            // liquidations at all. `Quotes` is denied here as it is for every other venue -- no
526            // exchange declares it in this matrix.
527            for (exchange, kind) in LSE {
528                for sub_kind in [
529                    SubKind::OrderBooksL2,
530                    SubKind::OrderBooksL3,
531                    SubKind::Liquidations,
532                    SubKind::Quotes,
533                ] {
534                    assert!(
535                        !exchange_supports_instrument_kind_sub_kind(&exchange, &kind, sub_kind),
536                        "{exchange:?} should not support ({kind:?}, {sub_kind})"
537                    );
538                }
539            }
540        }
541    }
542
543    mod subscription {
544        use super::*;
545        use crate::{
546            exchange::{coinbase::Coinbase, okx::Okx},
547            subscription::trade::PublicTrades,
548        };
549        use rustrade_instrument::instrument::market_data::MarketDataInstrument;
550
551        mod de {
552            use super::*;
553            use crate::{
554                exchange::{
555                    binance::{futures::BinanceFuturesUsd, spot::BinanceSpot},
556                    gateio::perpetual::GateioPerpetualsUsd,
557                    okx::Okx,
558                },
559                subscription::{book::OrderBooksL2, trade::PublicTrades},
560            };
561            use rustrade_instrument::instrument::market_data::MarketDataInstrument;
562
563            #[test]
564            fn test_subscription_okx_spot_public_trades() {
565                let input = r#"
566                {
567                    "exchange": "okx",
568                    "base": "btc",
569                    "quote": "usdt",
570                    "instrument_kind": "spot",
571                    "kind": "public_trades"
572                }
573                "#;
574
575                serde_json::from_str::<Subscription<Okx, MarketDataInstrument, PublicTrades>>(
576                    input,
577                )
578                .unwrap();
579            }
580
581            #[test]
582            fn test_subscription_binance_spot_public_trades() {
583                let input = r#"
584                {
585                    "exchange": "binance_spot",
586                    "base": "btc",
587                    "quote": "usdt",
588                    "instrument_kind": "spot",
589                    "kind": "public_trades"
590                }
591                "#;
592
593                serde_json::from_str::<Subscription<BinanceSpot, MarketDataInstrument, PublicTrades>>(input)
594                    .unwrap();
595            }
596
597            #[test]
598            fn test_subscription_binance_futures_usd_order_books_l2() {
599                let input = r#"
600                {
601                    "exchange": "binance_futures_usd",
602                    "base": "btc",
603                    "quote": "usdt",
604                    "instrument_kind": "perpetual",
605                    "kind": "order_books_l2"
606                }
607                "#;
608
609                serde_json::from_str::<
610                    Subscription<BinanceFuturesUsd, MarketDataInstrument, OrderBooksL2>,
611                >(input)
612                .unwrap();
613            }
614
615            #[test]
616            fn subscription_gateio_futures_usd_public_trades() {
617                let input = r#"
618                {
619                    "exchange": "gateio_perpetuals_usd",
620                    "base": "btc",
621                    "quote": "usdt",
622                    "instrument_kind": "perpetual",
623                    "kind": "public_trades"
624                }
625                "#;
626
627                serde_json::from_str::<
628                    Subscription<GateioPerpetualsUsd, MarketDataInstrument, PublicTrades>,
629                >(input)
630                .unwrap();
631            }
632        }
633
634        #[test]
635        fn test_validate_bitfinex_public_trades() {
636            struct TestCase {
637                input: Subscription<Coinbase, MarketDataInstrument, PublicTrades>,
638                expected:
639                    Result<Subscription<Coinbase, MarketDataInstrument, PublicTrades>, SocketError>,
640            }
641
642            let tests = vec![
643                TestCase {
644                    // TC0: Valid Coinbase Spot PublicTrades subscription
645                    input: Subscription::from((
646                        Coinbase,
647                        "base",
648                        "quote",
649                        MarketDataInstrumentKind::Spot,
650                        PublicTrades,
651                    )),
652                    expected: Ok(Subscription::from((
653                        Coinbase,
654                        "base",
655                        "quote",
656                        MarketDataInstrumentKind::Spot,
657                        PublicTrades,
658                    ))),
659                },
660                TestCase {
661                    // TC1: Invalid Coinbase FuturePerpetual PublicTrades subscription
662                    input: Subscription::from((
663                        Coinbase,
664                        "base",
665                        "quote",
666                        MarketDataInstrumentKind::Perpetual,
667                        PublicTrades,
668                    )),
669                    expected: Err(SocketError::Unsupported {
670                        entity: "".to_string(),
671                        item: "".to_string(),
672                    }),
673                },
674            ];
675
676            for (index, test) in tests.into_iter().enumerate() {
677                let actual = test.input.validate();
678                match (actual, test.expected) {
679                    (Ok(actual), Ok(expected)) => {
680                        assert_eq!(actual, expected, "TC{} failed", index)
681                    }
682                    (Err(_), Err(_)) => {
683                        // Test passed
684                    }
685                    (actual, expected) => {
686                        // Test failed
687                        panic!(
688                            "TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"
689                        );
690                    }
691                }
692            }
693        }
694
695        #[test]
696        fn test_validate_okx_public_trades() {
697            struct TestCase {
698                input: Subscription<Okx, MarketDataInstrument, PublicTrades>,
699                expected:
700                    Result<Subscription<Okx, MarketDataInstrument, PublicTrades>, SocketError>,
701            }
702
703            let tests = vec![
704                TestCase {
705                    // TC0: Valid Okx Spot PublicTrades subscription
706                    input: Subscription::from((
707                        Okx,
708                        "base",
709                        "quote",
710                        MarketDataInstrumentKind::Spot,
711                        PublicTrades,
712                    )),
713                    expected: Ok(Subscription::from((
714                        Okx,
715                        "base",
716                        "quote",
717                        MarketDataInstrumentKind::Spot,
718                        PublicTrades,
719                    ))),
720                },
721                TestCase {
722                    // TC1: Valid Okx FuturePerpetual PublicTrades subscription
723                    input: Subscription::from((
724                        Okx,
725                        "base",
726                        "quote",
727                        MarketDataInstrumentKind::Perpetual,
728                        PublicTrades,
729                    )),
730                    expected: Ok(Subscription::from((
731                        Okx,
732                        "base",
733                        "quote",
734                        MarketDataInstrumentKind::Perpetual,
735                        PublicTrades,
736                    ))),
737                },
738            ];
739
740            for (index, test) in tests.into_iter().enumerate() {
741                let actual = test.input.validate();
742                match (actual, test.expected) {
743                    (Ok(actual), Ok(expected)) => {
744                        assert_eq!(actual, expected, "TC{} failed", index)
745                    }
746                    (Err(_), Err(_)) => {
747                        // Test passed
748                    }
749                    (actual, expected) => {
750                        // Test failed
751                        panic!(
752                            "TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"
753                        );
754                    }
755                }
756            }
757        }
758    }
759
760    mod sub_kind {
761        use super::*;
762
763        #[test]
764        fn candles_variant_serde_round_trips() {
765            let kind = SubKind::Candles {
766                interval: CandleInterval::Min1,
767            };
768
769            let json = serde_json::to_string(&kind).unwrap();
770            let back = serde_json::from_str::<SubKind>(&json).unwrap();
771
772            assert_eq!(kind, back, "SubKind::Candles must serde round-trip");
773        }
774
775        #[test]
776        fn candles_variant_serde_shape_is_externally_tagged() {
777            // `SubKind` is an externally-tagged enum (no `#[serde(tag = ...)]`), so the struct
778            // variant serialises as `{"Candles":{"interval":"1m"}}` — NOT an internally-tagged
779            // `{"type":"candles",...}` form. Pin the wire shape so a future tag/rename change is
780            // caught here rather than silently shipped to config-driven consumers.
781            let json = serde_json::to_string(&SubKind::Candles {
782                interval: CandleInterval::Min1,
783            })
784            .unwrap();
785            assert_eq!(json, r#"{"Candles":{"interval":"1m"}}"#);
786        }
787
788        #[test]
789        fn candles_display_tag_is_interval_independent() {
790            // The `derive_more::Display` tag is the fixed kind name, never the interval.
791            assert_eq!(
792                SubKind::Candles {
793                    interval: CandleInterval::Sec1
794                }
795                .to_string(),
796                "candles"
797            );
798            assert_eq!(
799                SubKind::Candles {
800                    interval: CandleInterval::Month1
801                }
802                .to_string(),
803                "candles"
804            );
805        }
806
807        #[test]
808        fn candles_supported_per_interval_on_both_binance_venues() {
809            // `CandleInterval` is a venue-agnostic union, so candle support is decided
810            // per interval, not per `SubKind`: both Binance venues serve every interval
811            // Binance publishes a kline for, and neither serves `5s`/`15s`/`30s`.
812            for interval in CandleInterval::ALL {
813                let expected = crate::exchange::binance::supports_candle_interval(interval);
814                assert_eq!(
815                    exchange_supports_instrument_kind_sub_kind(
816                        &ExchangeId::BinanceSpot,
817                        &MarketDataInstrumentKind::Spot,
818                        SubKind::Candles { interval },
819                    ),
820                    expected,
821                    "BinanceSpot/Spot Candles {interval:?}"
822                );
823                assert_eq!(
824                    exchange_supports_instrument_kind_sub_kind(
825                        &ExchangeId::BinanceFuturesUsd,
826                        &MarketDataInstrumentKind::Perpetual,
827                        SubKind::Candles { interval },
828                    ),
829                    expected,
830                    "BinanceFuturesUsd/Perpetual Candles {interval:?}"
831                );
832            }
833        }
834
835        #[test]
836        fn candles_rejected_for_unsupported_venue_kind_pairing() {
837            // A supported venue with the wrong instrument kind is still rejected.
838            assert!(
839                !exchange_supports_instrument_kind_sub_kind(
840                    &ExchangeId::BinanceSpot,
841                    &MarketDataInstrumentKind::Perpetual,
842                    SubKind::Candles {
843                        interval: CandleInterval::Min1,
844                    },
845                ),
846                "BinanceSpot does not serve Perpetual candles"
847            );
848        }
849    }
850
851    mod instrument_map {
852        use super::*;
853        use rustrade_instrument::instrument::market_data::MarketDataInstrument;
854
855        #[test]
856        fn test_find_instrument() {
857            // Initialise SubscriptionId-InstrumentKey HashMap
858            let ids = Map(FnvHashMap::from_iter([(
859                SubscriptionId::from("present"),
860                MarketDataInstrument::from(("base", "quote", MarketDataInstrumentKind::Spot)),
861            )]));
862
863            struct TestCase {
864                input: SubscriptionId,
865                expected: Result<MarketDataInstrument, SocketError>,
866            }
867
868            let cases = vec![
869                TestCase {
870                    // TC0: SubscriptionId (channel) is present in the HashMap
871                    input: SubscriptionId::from("present"),
872                    expected: Ok(MarketDataInstrument::from((
873                        "base",
874                        "quote",
875                        MarketDataInstrumentKind::Spot,
876                    ))),
877                },
878                TestCase {
879                    // TC1: SubscriptionId (channel) is not present in the HashMap
880                    input: SubscriptionId::from("not present"),
881                    expected: Err(SocketError::Unidentifiable(SubscriptionId::from(
882                        "not present",
883                    ))),
884                },
885            ];
886
887            for (index, test) in cases.into_iter().enumerate() {
888                let actual = ids.find(&test.input);
889                match (actual, test.expected) {
890                    (Ok(actual), Ok(expected)) => {
891                        assert_eq!(*actual, expected, "TC{} failed", index)
892                    }
893                    (Err(_), Err(_)) => {
894                        // Test passed
895                    }
896                    (actual, expected) => {
897                        // Test failed
898                        panic!(
899                            "TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"
900                        );
901                    }
902                }
903            }
904        }
905    }
906}