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#[allow(clippy::match_like_matches_macro)]
209pub fn exchange_supports_instrument_kind(
210    exchange: ExchangeId,
211    instrument_kind: &MarketDataInstrumentKind,
212) -> bool {
213    use rustrade_instrument::{
214        exchange::ExchangeId::*, instrument::market_data::kind::MarketDataInstrumentKind::*,
215    };
216
217    match (exchange, instrument_kind) {
218        // Spot
219        (
220            BinanceFuturesUsd | Bitmex | BybitPerpetualsUsd | GateioPerpetualsUsd
221            | GateioPerpetualsBtc,
222            Spot,
223        ) => false,
224        (_, Spot) => true,
225
226        // Future
227        (GateioFuturesUsd | GateioFuturesBtc | Okx, Future { .. }) => true,
228        (_, Future { .. }) => false,
229
230        // Perpetual
231        (
232            BinanceFuturesUsd | Bitmex | Okx | BybitPerpetualsUsd | GateioPerpetualsUsd
233            | GateioPerpetualsBtc | HyperliquidPerp,
234            Perpetual,
235        ) => true,
236        (_, Perpetual) => false,
237
238        // Option
239        (GateioOptions | Okx, Option { .. }) => true,
240        (_, Option { .. }) => false,
241    }
242}
243
244impl<Instrument> Validator for Subscription<ExchangeId, Instrument, SubKind>
245where
246    Instrument: InstrumentData,
247{
248    type Error = SocketError;
249
250    fn validate(self) -> Result<Self, Self::Error>
251    where
252        Self: Sized,
253    {
254        // Validate the Exchange supports the Subscription InstrumentKind
255        if exchange_supports_instrument_kind_sub_kind(
256            &self.exchange,
257            self.instrument.kind(),
258            self.kind,
259        ) {
260            Ok(self)
261        } else {
262            Err(SocketError::Unsupported {
263                entity: self.exchange.to_string(),
264                item: format!("({}, {})", self.instrument.kind(), self.kind),
265            })
266        }
267    }
268}
269
270/// Determines whether the [`Connector`] associated with this [`ExchangeId`] supports the
271/// ingestion of market data for the provided [`MarketDataInstrumentKind`] and [`SubKind`] combination.
272pub fn exchange_supports_instrument_kind_sub_kind(
273    exchange_id: &ExchangeId,
274    instrument_kind: &MarketDataInstrumentKind,
275    sub_kind: SubKind,
276) -> bool {
277    use ExchangeId::*;
278    use MarketDataInstrumentKind::*;
279    use SubKind::*;
280
281    match (exchange_id, instrument_kind, sub_kind) {
282        (BinanceSpot, Spot, PublicTrades | OrderBooksL1 | OrderBooksL2 | Candles { .. }) => true,
283        (
284            BinanceFuturesUsd,
285            Perpetual,
286            PublicTrades | OrderBooksL1 | OrderBooksL2 | Liquidations | Candles { .. },
287        ) => true,
288        (Bitfinex, Spot, PublicTrades) => true,
289        (Bitmex, Perpetual, PublicTrades) => true,
290        (BybitSpot, Spot, PublicTrades | OrderBooksL1 | OrderBooksL2) => true,
291        (BybitPerpetualsUsd, Perpetual, PublicTrades | OrderBooksL1 | OrderBooksL2) => true,
292        (Coinbase, Spot, PublicTrades) => true,
293        (GateioSpot, Spot, PublicTrades) => true,
294        (GateioFuturesUsd, Future { .. }, PublicTrades) => true,
295        (GateioFuturesBtc, Future { .. }, PublicTrades) => true,
296        (GateioPerpetualsUsd, Perpetual, PublicTrades) => true,
297        (GateioPerpetualsBtc, Perpetual, PublicTrades) => true,
298        (GateioOptions, Option { .. }, PublicTrades) => true,
299        (Kraken, Spot, PublicTrades | OrderBooksL1) => true,
300        (Okx, Spot | Future { .. } | Perpetual | Option { .. }, PublicTrades) => true,
301        (HyperliquidPerp, Perpetual, PublicTrades | OrderBooksL2) => true,
302
303        (_, _, _) => false,
304    }
305}
306
307/// Metadata generated from a collection of Barter [`Subscription`]s, including the exchange
308/// specific subscription payloads that are sent to the exchange.
309#[derive(Clone, Eq, PartialEq, Debug)]
310pub struct SubscriptionMeta<InstrumentKey> {
311    /// `HashMap` containing the mapping between a [`SubscriptionId`] and
312    /// it's associated Barter [`MarketDataInstrument`].
313    pub instrument_map: Map<InstrumentKey>,
314    /// Collection of [`WsMessage`]s containing exchange specific subscription payloads to be sent.
315    pub ws_subscriptions: Vec<WsMessage>,
316}
317
318/// New type`HashMap` that maps a [`SubscriptionId`] to some associated type `T`.
319///
320/// Used by [`ExchangeTransformer`](crate::transformer::ExchangeTransformer)s to identify the
321/// Barter [`MarketDataInstrument`] associated with incoming exchange messages.
322#[derive(Clone, Eq, PartialEq, Debug, Deserialize, Serialize)]
323pub struct Map<T>(pub FnvHashMap<SubscriptionId, T>);
324
325impl<T> FromIterator<(SubscriptionId, T)> for Map<T> {
326    fn from_iter<Iter>(iter: Iter) -> Self
327    where
328        Iter: IntoIterator<Item = (SubscriptionId, T)>,
329    {
330        Self(iter.into_iter().collect::<FnvHashMap<SubscriptionId, T>>())
331    }
332}
333
334impl<T> Map<T> {
335    /// Find the `InstrumentKey` associated with the provided [`SubscriptionId`].
336    pub fn find<SubId>(&self, id: &SubId) -> Result<&T, SocketError>
337    where
338        SubscriptionId: Borrow<SubId>,
339        SubId: AsRef<str> + Hash + Eq + ?Sized,
340    {
341        self.0
342            .get(id)
343            .ok_or_else(|| SocketError::Unidentifiable(SubscriptionId(id.as_ref().to_smolstr())))
344    }
345
346    /// Find the mutable reference to `T` associated with the provided [`SubscriptionId`].
347    pub fn find_mut<SubId>(&mut self, id: &SubId) -> Result<&mut T, SocketError>
348    where
349        SubscriptionId: Borrow<SubId>,
350        SubId: AsRef<str> + Hash + Eq + ?Sized,
351    {
352        self.0
353            .get_mut(id)
354            .ok_or_else(|| SocketError::Unidentifiable(SubscriptionId(id.as_ref().to_smolstr())))
355    }
356}
357
358#[cfg(test)]
359#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
360mod tests {
361    use super::*;
362
363    mod subscription {
364        use super::*;
365        use crate::{
366            exchange::{coinbase::Coinbase, okx::Okx},
367            subscription::trade::PublicTrades,
368        };
369        use rustrade_instrument::instrument::market_data::MarketDataInstrument;
370
371        mod de {
372            use super::*;
373            use crate::{
374                exchange::{
375                    binance::{futures::BinanceFuturesUsd, spot::BinanceSpot},
376                    gateio::perpetual::GateioPerpetualsUsd,
377                    okx::Okx,
378                },
379                subscription::{book::OrderBooksL2, trade::PublicTrades},
380            };
381            use rustrade_instrument::instrument::market_data::MarketDataInstrument;
382
383            #[test]
384            fn test_subscription_okx_spot_public_trades() {
385                let input = r#"
386                {
387                    "exchange": "okx",
388                    "base": "btc",
389                    "quote": "usdt",
390                    "instrument_kind": "spot",
391                    "kind": "public_trades"
392                }
393                "#;
394
395                serde_json::from_str::<Subscription<Okx, MarketDataInstrument, PublicTrades>>(
396                    input,
397                )
398                .unwrap();
399            }
400
401            #[test]
402            fn test_subscription_binance_spot_public_trades() {
403                let input = r#"
404                {
405                    "exchange": "binance_spot",
406                    "base": "btc",
407                    "quote": "usdt",
408                    "instrument_kind": "spot",
409                    "kind": "public_trades"
410                }
411                "#;
412
413                serde_json::from_str::<Subscription<BinanceSpot, MarketDataInstrument, PublicTrades>>(input)
414                    .unwrap();
415            }
416
417            #[test]
418            fn test_subscription_binance_futures_usd_order_books_l2() {
419                let input = r#"
420                {
421                    "exchange": "binance_futures_usd",
422                    "base": "btc",
423                    "quote": "usdt",
424                    "instrument_kind": "perpetual",
425                    "kind": "order_books_l2"
426                }
427                "#;
428
429                serde_json::from_str::<
430                    Subscription<BinanceFuturesUsd, MarketDataInstrument, OrderBooksL2>,
431                >(input)
432                .unwrap();
433            }
434
435            #[test]
436            fn subscription_gateio_futures_usd_public_trades() {
437                let input = r#"
438                {
439                    "exchange": "gateio_perpetuals_usd",
440                    "base": "btc",
441                    "quote": "usdt",
442                    "instrument_kind": "perpetual",
443                    "kind": "public_trades"
444                }
445                "#;
446
447                serde_json::from_str::<
448                    Subscription<GateioPerpetualsUsd, MarketDataInstrument, PublicTrades>,
449                >(input)
450                .unwrap();
451            }
452        }
453
454        #[test]
455        fn test_validate_bitfinex_public_trades() {
456            struct TestCase {
457                input: Subscription<Coinbase, MarketDataInstrument, PublicTrades>,
458                expected:
459                    Result<Subscription<Coinbase, MarketDataInstrument, PublicTrades>, SocketError>,
460            }
461
462            let tests = vec![
463                TestCase {
464                    // TC0: Valid Coinbase Spot PublicTrades subscription
465                    input: Subscription::from((
466                        Coinbase,
467                        "base",
468                        "quote",
469                        MarketDataInstrumentKind::Spot,
470                        PublicTrades,
471                    )),
472                    expected: Ok(Subscription::from((
473                        Coinbase,
474                        "base",
475                        "quote",
476                        MarketDataInstrumentKind::Spot,
477                        PublicTrades,
478                    ))),
479                },
480                TestCase {
481                    // TC1: Invalid Coinbase FuturePerpetual PublicTrades subscription
482                    input: Subscription::from((
483                        Coinbase,
484                        "base",
485                        "quote",
486                        MarketDataInstrumentKind::Perpetual,
487                        PublicTrades,
488                    )),
489                    expected: Err(SocketError::Unsupported {
490                        entity: "".to_string(),
491                        item: "".to_string(),
492                    }),
493                },
494            ];
495
496            for (index, test) in tests.into_iter().enumerate() {
497                let actual = test.input.validate();
498                match (actual, test.expected) {
499                    (Ok(actual), Ok(expected)) => {
500                        assert_eq!(actual, expected, "TC{} failed", index)
501                    }
502                    (Err(_), Err(_)) => {
503                        // Test passed
504                    }
505                    (actual, expected) => {
506                        // Test failed
507                        panic!(
508                            "TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"
509                        );
510                    }
511                }
512            }
513        }
514
515        #[test]
516        fn test_validate_okx_public_trades() {
517            struct TestCase {
518                input: Subscription<Okx, MarketDataInstrument, PublicTrades>,
519                expected:
520                    Result<Subscription<Okx, MarketDataInstrument, PublicTrades>, SocketError>,
521            }
522
523            let tests = vec![
524                TestCase {
525                    // TC0: Valid Okx Spot PublicTrades subscription
526                    input: Subscription::from((
527                        Okx,
528                        "base",
529                        "quote",
530                        MarketDataInstrumentKind::Spot,
531                        PublicTrades,
532                    )),
533                    expected: Ok(Subscription::from((
534                        Okx,
535                        "base",
536                        "quote",
537                        MarketDataInstrumentKind::Spot,
538                        PublicTrades,
539                    ))),
540                },
541                TestCase {
542                    // TC1: Valid Okx FuturePerpetual PublicTrades subscription
543                    input: Subscription::from((
544                        Okx,
545                        "base",
546                        "quote",
547                        MarketDataInstrumentKind::Perpetual,
548                        PublicTrades,
549                    )),
550                    expected: Ok(Subscription::from((
551                        Okx,
552                        "base",
553                        "quote",
554                        MarketDataInstrumentKind::Perpetual,
555                        PublicTrades,
556                    ))),
557                },
558            ];
559
560            for (index, test) in tests.into_iter().enumerate() {
561                let actual = test.input.validate();
562                match (actual, test.expected) {
563                    (Ok(actual), Ok(expected)) => {
564                        assert_eq!(actual, expected, "TC{} failed", index)
565                    }
566                    (Err(_), Err(_)) => {
567                        // Test passed
568                    }
569                    (actual, expected) => {
570                        // Test failed
571                        panic!(
572                            "TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"
573                        );
574                    }
575                }
576            }
577        }
578    }
579
580    mod sub_kind {
581        use super::*;
582
583        #[test]
584        fn candles_variant_serde_round_trips() {
585            let kind = SubKind::Candles {
586                interval: CandleInterval::Min1,
587            };
588
589            let json = serde_json::to_string(&kind).unwrap();
590            let back = serde_json::from_str::<SubKind>(&json).unwrap();
591
592            assert_eq!(kind, back, "SubKind::Candles must serde round-trip");
593        }
594
595        #[test]
596        fn candles_variant_serde_shape_is_externally_tagged() {
597            // `SubKind` is an externally-tagged enum (no `#[serde(tag = ...)]`), so the struct
598            // variant serialises as `{"Candles":{"interval":"1m"}}` — NOT an internally-tagged
599            // `{"type":"candles",...}` form. Pin the wire shape so a future tag/rename change is
600            // caught here rather than silently shipped to config-driven consumers.
601            let json = serde_json::to_string(&SubKind::Candles {
602                interval: CandleInterval::Min1,
603            })
604            .unwrap();
605            assert_eq!(json, r#"{"Candles":{"interval":"1m"}}"#);
606        }
607
608        #[test]
609        fn candles_display_tag_is_interval_independent() {
610            // The `derive_more::Display` tag is the fixed kind name, never the interval.
611            assert_eq!(
612                SubKind::Candles {
613                    interval: CandleInterval::Sec1
614                }
615                .to_string(),
616                "candles"
617            );
618            assert_eq!(
619                SubKind::Candles {
620                    interval: CandleInterval::Month1
621                }
622                .to_string(),
623                "candles"
624            );
625        }
626
627        #[test]
628        fn candles_supported_on_both_binance_venues_for_every_interval() {
629            // Both Binance venues serve the full interval set on the dynamic path; no interval
630            // is rejected (the Hyperliquid interval guard lives only in its typed historical path).
631            for interval in CandleInterval::ALL {
632                assert!(
633                    exchange_supports_instrument_kind_sub_kind(
634                        &ExchangeId::BinanceSpot,
635                        &MarketDataInstrumentKind::Spot,
636                        SubKind::Candles { interval },
637                    ),
638                    "BinanceSpot/Spot must support Candles {interval:?}"
639                );
640                assert!(
641                    exchange_supports_instrument_kind_sub_kind(
642                        &ExchangeId::BinanceFuturesUsd,
643                        &MarketDataInstrumentKind::Perpetual,
644                        SubKind::Candles { interval },
645                    ),
646                    "BinanceFuturesUsd/Perpetual must support Candles {interval:?}"
647                );
648            }
649        }
650
651        #[test]
652        fn candles_rejected_for_unsupported_venue_kind_pairing() {
653            // A supported venue with the wrong instrument kind is still rejected.
654            assert!(
655                !exchange_supports_instrument_kind_sub_kind(
656                    &ExchangeId::BinanceSpot,
657                    &MarketDataInstrumentKind::Perpetual,
658                    SubKind::Candles {
659                        interval: CandleInterval::Min1,
660                    },
661                ),
662                "BinanceSpot does not serve Perpetual candles"
663            );
664        }
665    }
666
667    mod instrument_map {
668        use super::*;
669        use rustrade_instrument::instrument::market_data::MarketDataInstrument;
670
671        #[test]
672        fn test_find_instrument() {
673            // Initialise SubscriptionId-InstrumentKey HashMap
674            let ids = Map(FnvHashMap::from_iter([(
675                SubscriptionId::from("present"),
676                MarketDataInstrument::from(("base", "quote", MarketDataInstrumentKind::Spot)),
677            )]));
678
679            struct TestCase {
680                input: SubscriptionId,
681                expected: Result<MarketDataInstrument, SocketError>,
682            }
683
684            let cases = vec![
685                TestCase {
686                    // TC0: SubscriptionId (channel) is present in the HashMap
687                    input: SubscriptionId::from("present"),
688                    expected: Ok(MarketDataInstrument::from((
689                        "base",
690                        "quote",
691                        MarketDataInstrumentKind::Spot,
692                    ))),
693                },
694                TestCase {
695                    // TC1: SubscriptionId (channel) is not present in the HashMap
696                    input: SubscriptionId::from("not present"),
697                    expected: Err(SocketError::Unidentifiable(SubscriptionId::from(
698                        "not present",
699                    ))),
700                },
701            ];
702
703            for (index, test) in cases.into_iter().enumerate() {
704                let actual = ids.find(&test.input);
705                match (actual, test.expected) {
706                    (Ok(actual), Ok(expected)) => {
707                        assert_eq!(*actual, expected, "TC{} failed", index)
708                    }
709                    (Err(_), Err(_)) => {
710                        // Test passed
711                    }
712                    (actual, expected) => {
713                        // Test failed
714                        panic!(
715                            "TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"
716                        );
717                    }
718                }
719            }
720        }
721    }
722}