Skip to main content

rustrade_data/streams/builder/dynamic/
mod.rs

1#[cfg(feature = "alpaca")]
2use crate::exchange::alpaca::{AlpacaSubscriber, market::AlpacaInstrument};
3#[cfg(feature = "hyperliquid")]
4use crate::exchange::hyperliquid::market::HyperliquidInstrument;
5#[cfg(feature = "lse")]
6use crate::exchange::lse::{live::LseSubscriber, market::LseInstrument};
7#[cfg(feature = "massive")]
8use crate::exchange::massive::{MassiveSubscriber, market::MassiveInstrument};
9use crate::{
10    error::DataError,
11    instrument::InstrumentData,
12    streams::consumer::MarketStreamResult,
13    subscription::{
14        SubKind, Subscription,
15        book::{OrderBookEvent, OrderBookL1},
16        candle::Candle,
17        liquidation::Liquidation,
18        trade::PublicTrade,
19    },
20};
21use fnv::FnvHashMap;
22use futures::{Stream, stream::SelectAll};
23use futures_util::{StreamExt, future::join_all};
24use itertools::Itertools;
25use plan::{Route, Txs};
26use rustrade_instrument::exchange::ExchangeId;
27use rustrade_integration::{
28    Validator,
29    channel::{UnboundedRx, mpsc_unbounded},
30    error::SocketError,
31};
32use std::fmt::Debug;
33use tokio::task::JoinHandle;
34use tokio_stream::wrappers::UnboundedReceiverStream;
35use vecmap::VecMap;
36
37pub mod indexed;
38
39mod plan;
40
41#[derive(Debug)]
42pub struct DynamicStreams<InstrumentKey> {
43    pub trades:
44        VecMap<ExchangeId, UnboundedReceiverStream<MarketStreamResult<InstrumentKey, PublicTrade>>>,
45    pub l1s:
46        VecMap<ExchangeId, UnboundedReceiverStream<MarketStreamResult<InstrumentKey, OrderBookL1>>>,
47    pub l2s: VecMap<
48        ExchangeId,
49        UnboundedReceiverStream<MarketStreamResult<InstrumentKey, OrderBookEvent>>,
50    >,
51    pub liquidations:
52        VecMap<ExchangeId, UnboundedReceiverStream<MarketStreamResult<InstrumentKey, Liquidation>>>,
53    pub candles:
54        VecMap<ExchangeId, UnboundedReceiverStream<MarketStreamResult<InstrumentKey, Candle>>>,
55}
56
57/// The subscribers [`DynamicStreams`] cannot build for itself.
58///
59/// Most venues are served by a stateless [`WebSocketSubscriber`](crate::subscriber::WebSocketSubscriber),
60/// which [`DynamicStreams`] constructs on its own. A venue whose subscriber carries credentials —
61/// or shares one connection among its streams — needs one built by the caller and supplied here,
62/// through [`DynamicStreams::init_with`]. A subscription to such a venue with no subscriber
63/// supplied fails with [`DataError::SubscriberRequired`].
64///
65/// Venues that need one:
66/// - **Alpaca** (`alpaca` feature): `AlpacaCrypto`, `AlpacaIex` and `AlpacaSip`.
67/// - **London Strategic Edge** (`lse` feature): every `Lse*` dataset.
68/// - **Massive** (`massive` feature): `MassiveStocks`, `MassiveCrypto`, `MassiveForex` and
69///   `MassiveOptions`.
70///
71/// # Example
72/// ```ignore
73/// use rustrade_data::exchange::lse::live::LseSubscriber;
74/// use rustrade_data::streams::builder::dynamic::{DynamicStreams, DynamicSubscribers};
75/// use rustrade_data::subscription::SubKind;
76/// use rustrade_instrument::{
77///     exchange::ExchangeId, instrument::market_data::kind::MarketDataInstrumentKind,
78/// };
79///
80/// let subscribers = DynamicSubscribers::default().with_lse(LseSubscriber::from_env()?);
81///
82/// // Two datasets and both kinds: four streams, one connection.
83/// let streams = DynamicStreams::init_with(&subscribers, [[
84///     (ExchangeId::LseCrypto, "btc", "usd", MarketDataInstrumentKind::Spot, SubKind::PublicTrades),
85///     (ExchangeId::LseCrypto, "btc", "usd", MarketDataInstrumentKind::Spot, SubKind::OrderBooksL1),
86///     (ExchangeId::LseEquities, "aapl", "usd", MarketDataInstrumentKind::Spot, SubKind::PublicTrades),
87///     (ExchangeId::LseEquities, "aapl", "usd", MarketDataInstrumentKind::Spot, SubKind::OrderBooksL1),
88/// ]])
89/// .await?;
90/// ```
91#[derive(Clone, Debug, Default)]
92pub struct DynamicSubscribers {
93    #[cfg(feature = "alpaca")]
94    alpaca: Option<AlpacaSubscriber>,
95    #[cfg(feature = "lse")]
96    lse: Option<LseSubscriber>,
97    #[cfg(feature = "massive")]
98    massive: Option<MassiveSubscriber>,
99}
100
101impl DynamicSubscribers {
102    /// Serve every Alpaca subscription — any feed, either kind — from clones of `subscriber`.
103    ///
104    /// Alpaca allows an account one market data connection per feed and refuses a second on the
105    /// same feed with `connection limit exceeded`. Clones of one subscriber share one connection per
106    /// feed, so every `Alpaca*` group of every batch on a feed attaches to the same socket, and the
107    /// crypto, IEX and SIP feeds each get one of their own. Any other stream the caller opens on the
108    /// same account should take a clone of this subscriber too, rather than one built separately.
109    ///
110    /// Quotes arrive as [`OrderBookL1`]: `SubKind::OrderBooksL1` reads Alpaca's `quotes` channel,
111    /// and a side quoted at a zero price is `None`. `SubKind::Quotes` is not served here; the
112    /// typed [`Streams`](crate::streams::Streams) builder serves it. A confirmed crypto
113    /// subscription may stay silent for minutes: Alpaca publishes a crypto quote only when the top
114    /// of book changes. See [`exchange::alpaca`](crate::exchange::alpaca).
115    ///
116    /// The `(channel, symbol)` pairs every stream on a feed holds count together against Alpaca's
117    /// per-connection cap — 30 on the free IEX plan, as last measured. Trades and L1 on one symbol
118    /// are two pairs.
119    #[cfg(feature = "alpaca")]
120    #[must_use]
121    pub fn with_alpaca(mut self, subscriber: AlpacaSubscriber) -> Self {
122        self.alpaca = Some(subscriber);
123        self
124    }
125
126    /// Serve every London Strategic Edge subscription — any dataset, either kind — from clones of
127    /// `subscriber`.
128    ///
129    /// Clones share the subscriber's one connection, which is the only way several streams can
130    /// coexist on this provider: it allows a key a single WebSocket and refuses a second with
131    /// `TOO_MANY_CONNECTIONS`. So every `Lse*` group of every batch attaches to the same socket,
132    /// and so should any other stream the caller opens for the same key — pass it a clone of this
133    /// subscriber rather than building another.
134    ///
135    /// # Resumption
136    /// Configure it with [`LseSubscriber::with_resume`] *before* passing it here to resume every
137    /// London Strategic Edge stream this call opens; the one state then serves them all. Streams
138    /// are keyed by dataset, symbol and kind, and [`DynamicStreams::init_with`] removes duplicates
139    /// only within a batch — so repeating one subscription across two batches opens two streams
140    /// sharing one watermark, which [`LseSubscriber::with_resume`] warns against.
141    ///
142    /// # ⚠️ The data is not redistributable
143    /// London Strategic Edge permits use for your own research, trading and model training, but
144    /// prohibits redistributing the data it serves. See
145    /// [`exchange::lse`](crate::exchange::lse) and <https://londonstrategicedge.com/terms>.
146    #[cfg(feature = "lse")]
147    #[must_use]
148    pub fn with_lse(mut self, subscriber: LseSubscriber) -> Self {
149        self.lse = Some(subscriber);
150        self
151    }
152
153    /// Serve every Massive subscription — any cluster, any kind — from clones of `subscriber`.
154    ///
155    /// Massive allows a key a fixed number of WebSocket connections per cluster, one on an
156    /// individual plan, and past it does not refuse the new connection: it **closes the older
157    /// one**. Clones of one subscriber share one connection per cluster, so every `Massive*` group
158    /// of every batch on a cluster attaches to the same socket, and the stocks, crypto, forex and
159    /// options clusters each get one of their own. Any other stream the caller opens on the same
160    /// key should take a clone of this subscriber too: one built separately evicts this one's
161    /// connection, and the warning logged for the lost connection quotes Massive's
162    /// `max_connections` status.
163    ///
164    /// What each cluster serves:
165    /// - `SubKind::PublicTrades` on every cluster but forex, which publishes no trades.
166    /// - `SubKind::OrderBooksL1`, read from the cluster's quote channel. A side quoted at a zero
167    ///   price is `None`, and a forex quote carries no sizes, so its amounts are zero.
168    ///   `SubKind::Quotes` is not served here; the typed [`Streams`](crate::streams::Streams)
169    ///   builder serves it.
170    /// - `SubKind::Candles` at `CandleInterval::Sec1` and `Min1`, the only intervals Massive
171    ///   aggregates at. Forex candles report no volume. Both intervals on one cluster arrive on its
172    ///   one candle stream; see [`DynamicStreams::select_candles`].
173    ///
174    /// `MassiveOptions` takes option contracts only and has never been run against the live feed,
175    /// which needs an options subscription. An instrument given as
176    /// [`MarketInstrumentData`](crate::instrument::MarketInstrumentData) is subscribed by its
177    /// `name_exchange` as given, so that must be the cluster's own spelling: `AAPL`, `BTC-USD`,
178    /// `EUR/USD` or `O:AAPL251219C00150000`. See [`exchange::massive`](crate::exchange::massive).
179    ///
180    /// # ⚠️ The data is not redistributable
181    /// Massive's terms prohibit redistributing the data it serves. See
182    /// <https://massive.com/legal/market-data-terms-of-service> (§5(c)).
183    #[cfg(feature = "massive")]
184    #[must_use]
185    pub fn with_massive(mut self, subscriber: MassiveSubscriber) -> Self {
186        self.massive = Some(subscriber);
187        self
188    }
189}
190
191/// An instrument type every venue [`DynamicStreams`] routes to can subscribe with.
192///
193/// Implemented for [`MarketDataInstrument`](rustrade_instrument::instrument::market_data::MarketDataInstrument),
194/// [`Keyed`](rustrade_instrument::Keyed) over it, and
195/// [`MarketInstrumentData`](crate::instrument::MarketInstrumentData): the representations every
196/// connector can name a market for. It stands for the whole set of per-connector identifier
197/// bounds, so a caller generic over the instrument states this one bound rather than one per
198/// connector and kind.
199///
200/// Which connectors it covers depends on the cargo features enabled: with `alpaca` it also
201/// requires [`AlpacaInstrument`], with `lse` [`LseInstrument`], with `massive`
202/// [`MassiveInstrument`], with `hyperliquid` [`HyperliquidInstrument`]. The three types above
203/// satisfy every combination.
204///
205/// # Only this crate's instrument types implement it
206/// It is sealed: its supertrait lives in a private module, so no crate but this one can name it
207/// or implement it. This crate implements that supertrait once, as a blanket impl requiring an
208/// `Identifier<ExchangeMarket>` impl for a `Subscription` over the type, per connector and kind.
209/// A downstream crate cannot write those impls for a type of its own either: the coherence rules
210/// leave them to the crate that owns `Identifier`, `Subscription` and the market types, which is
211/// this one. So the set of qualifying types is exactly the three above, in every build, whichever
212/// features any crate in the build enables. A custom instrument type streams through the typed
213/// [`Streams`](crate::streams::Streams) builder instead, for a connector whose identifier it can
214/// provide: [`LseInstrument`] is implementable for a downstream type, for example.
215pub trait DynamicInstrument: Route {}
216
217impl<Instrument> DynamicInstrument for Instrument where Instrument: Route {}
218
219/// Requires [`AlpacaInstrument`] when the `alpaca` feature is enabled, and nothing otherwise. See
220/// [`DynamicLseInstrument`] for why this is a trait.
221#[cfg(feature = "alpaca")]
222pub trait DynamicAlpacaInstrument: AlpacaInstrument {}
223
224#[cfg(feature = "alpaca")]
225impl<Instrument> DynamicAlpacaInstrument for Instrument where Instrument: AlpacaInstrument {}
226
227/// Requires `AlpacaInstrument` when the `alpaca` feature is enabled, and nothing otherwise.
228#[cfg(not(feature = "alpaca"))]
229pub trait DynamicAlpacaInstrument {}
230
231#[cfg(not(feature = "alpaca"))]
232impl<Instrument> DynamicAlpacaInstrument for Instrument {}
233
234/// Requires [`LseInstrument`] when the `lse` feature is enabled, and nothing otherwise.
235///
236/// A where-clause bound cannot be `cfg`-gated on stable Rust, but a supertrait of a trait declared
237/// twice can: this is how [`DynamicInstrument`] requires London Strategic Edge support only in
238/// builds that have it.
239#[cfg(feature = "lse")]
240pub trait DynamicLseInstrument: LseInstrument {}
241
242#[cfg(feature = "lse")]
243impl<Instrument> DynamicLseInstrument for Instrument where Instrument: LseInstrument {}
244
245/// Requires `LseInstrument` when the `lse` feature is enabled, and nothing otherwise.
246#[cfg(not(feature = "lse"))]
247pub trait DynamicLseInstrument {}
248
249#[cfg(not(feature = "lse"))]
250impl<Instrument> DynamicLseInstrument for Instrument {}
251
252/// Requires [`MassiveInstrument`] when the `massive` feature is enabled, and nothing otherwise. See
253/// [`DynamicLseInstrument`] for why this is a trait.
254#[cfg(feature = "massive")]
255pub trait DynamicMassiveInstrument: MassiveInstrument {}
256
257#[cfg(feature = "massive")]
258impl<Instrument> DynamicMassiveInstrument for Instrument where Instrument: MassiveInstrument {}
259
260/// Requires `MassiveInstrument` when the `massive` feature is enabled, and nothing otherwise.
261#[cfg(not(feature = "massive"))]
262pub trait DynamicMassiveInstrument {}
263
264#[cfg(not(feature = "massive"))]
265impl<Instrument> DynamicMassiveInstrument for Instrument {}
266
267/// Requires [`HyperliquidInstrument`] when the `hyperliquid` feature is enabled, and nothing
268/// otherwise. See [`DynamicLseInstrument`] for why this is a trait.
269#[cfg(feature = "hyperliquid")]
270pub trait DynamicHyperliquidInstrument: HyperliquidInstrument {}
271
272#[cfg(feature = "hyperliquid")]
273impl<Instrument> DynamicHyperliquidInstrument for Instrument where Instrument: HyperliquidInstrument {}
274
275/// Requires `HyperliquidInstrument` when the `hyperliquid` feature is enabled, and nothing
276/// otherwise.
277#[cfg(not(feature = "hyperliquid"))]
278pub trait DynamicHyperliquidInstrument {}
279
280#[cfg(not(feature = "hyperliquid"))]
281impl<Instrument> DynamicHyperliquidInstrument for Instrument {}
282
283impl<InstrumentKey> DynamicStreams<InstrumentKey> {
284    /// Initialise a set of `Streams` by providing one or more [`Subscription`] batches, for venues
285    /// that need no subscriber from the caller.
286    ///
287    /// Equivalent to [`init_with`](Self::init_with) with no [`DynamicSubscribers`]: a subscription
288    /// to a venue that needs one fails with [`DataError::SubscriberRequired`].
289    ///
290    /// ## Examples
291    /// Please see rustrade-data-rs/examples/dynamic_multi_stream_multi_exchange.rs for a
292    /// comprehensive example of how to use this market data stream initialiser.
293    pub async fn init<SubBatchIter, SubIter, Sub, Instrument>(
294        subscription_batches: SubBatchIter,
295    ) -> Result<Self, DataError>
296    where
297        SubBatchIter: IntoIterator<Item = SubIter>,
298        SubIter: IntoIterator<Item = Sub>,
299        Sub: Into<Subscription<ExchangeId, Instrument, SubKind>>,
300        Instrument: DynamicInstrument + InstrumentData<Key = InstrumentKey>,
301        InstrumentKey: Debug + Clone + PartialEq + Send + Sync + 'static,
302    {
303        Self::init_with(&DynamicSubscribers::default(), subscription_batches).await
304    }
305
306    /// Initialise a set of `Streams` by providing one or more [`Subscription`] batches, and the
307    /// subscribers for venues that cannot be served without one.
308    ///
309    /// Each batch (ie/ `impl Iterator<Item = Subscription>`) will initialise at-least-one
310    /// `Stream` under the hood. If the batch contains more-than-one [`ExchangeId`] and/or
311    /// [`SubKind`], it will be further split under the hood for compile-time reasons.
312    ///
313    /// # Streams sharing a connection
314    /// A venue supplied through `subscribers` receives a clone of that subscriber for every group,
315    /// whichever batch it came from. Where clones share a connection, every group on that venue
316    /// shares it: across datasets and kinds on London Strategic Edge, across kinds on each Alpaca
317    /// feed, and across kinds on each Massive cluster.
318    ///
319    /// # Errors
320    /// Nothing is connected until every group has been routed, so these fail the call with no
321    /// connection opened:
322    /// - a subscription the support matrix refuses (see
323    ///   [`exchange_supports_instrument_kind_sub_kind`](crate::subscription::exchange_supports_instrument_kind_sub_kind));
324    /// - [`DataError::Unsupported`] for a pair the matrix accepts but no connector here serves;
325    /// - [`DataError::SubscriberRequired`] for a venue `subscribers` has no subscriber for;
326    /// - [`DataError::FeatureDisabled`] for a venue whose cargo feature this build lacks.
327    ///
328    /// Every group is then initialised concurrently. If any fails, the call waits for the rest to
329    /// finish initialising, stops every stream that succeeded, and returns the first error — so a
330    /// failed call leaves no stream running: each is dropped before the call returns.
331    ///
332    /// A dropped London Strategic Edge, Alpaca or Massive stream asks its connection to release
333    /// its share, and the connection does so asynchronously, closing the socket once no stream
334    /// remains. A retry through the same subscriber, or a clone of it, reuses that connection
335    /// whatever state it is in. A retry through a subscriber built separately for the same account
336    /// may still be refused — `TOO_MANY_CONNECTIONS` on London Strategic Edge, `connection limit
337    /// exceeded` on Alpaca — until the socket has closed. Massive refuses nothing: it closes the
338    /// older socket instead, ending every stream still on it.
339    ///
340    /// ## Examples
341    /// Please see rustrade-data-rs/examples/dynamic_multi_stream_multi_exchange.rs for a
342    /// comprehensive example of how to use this market data stream initialiser.
343    pub async fn init_with<SubBatchIter, SubIter, Sub, Instrument>(
344        subscribers: &DynamicSubscribers,
345        subscription_batches: SubBatchIter,
346    ) -> Result<Self, DataError>
347    where
348        SubBatchIter: IntoIterator<Item = SubIter>,
349        SubIter: IntoIterator<Item = Sub>,
350        Sub: Into<Subscription<ExchangeId, Instrument, SubKind>>,
351        Instrument: DynamicInstrument + InstrumentData<Key = InstrumentKey>,
352        InstrumentKey: Debug + Clone + PartialEq + Send + Sync + 'static,
353    {
354        // Validate & dedup Subscription batches
355        let batches = validate_batches(subscription_batches)?;
356
357        // Generate required Channels from Subscription batches
358        let Channels { txs, rxs } = Channels::try_from(&batches)?;
359
360        let groups = route_batches(batches, &txs, subscribers)?;
361
362        settle(join_all(groups).await).await?;
363
364        Ok(Self {
365            trades: rxs
366                .trades
367                .into_iter()
368                .map(|(exchange, rx)| (exchange, rx.into_stream()))
369                .collect(),
370            l1s: rxs
371                .l1s
372                .into_iter()
373                .map(|(exchange, rx)| (exchange, rx.into_stream()))
374                .collect(),
375            l2s: rxs
376                .l2s
377                .into_iter()
378                .map(|(exchange, rx)| (exchange, rx.into_stream()))
379                .collect(),
380            liquidations: rxs
381                .liquidations
382                .into_iter()
383                .map(|(exchange, rx)| (exchange, rx.into_stream()))
384                .collect(),
385            candles: rxs
386                .candles
387                .into_iter()
388                .map(|(exchange, rx)| (exchange, rx.into_stream()))
389                .collect(),
390        })
391    }
392
393    /// Remove an exchange [`PublicTrade`] `Stream` from the [`DynamicStreams`] collection.
394    ///
395    /// Note that calling this method will permanently remove this `Stream` from [`Self`].
396    pub fn select_trades(
397        &mut self,
398        exchange: ExchangeId,
399    ) -> Option<UnboundedReceiverStream<MarketStreamResult<InstrumentKey, PublicTrade>>> {
400        self.trades.remove(&exchange)
401    }
402
403    /// Select and merge every exchange [`PublicTrade`] `Stream` using
404    /// [`SelectAll`](futures_util::stream::select_all::select_all).
405    pub fn select_all_trades(
406        &mut self,
407    ) -> SelectAll<UnboundedReceiverStream<MarketStreamResult<InstrumentKey, PublicTrade>>> {
408        futures_util::stream::select_all::select_all(std::mem::take(&mut self.trades).into_values())
409    }
410
411    /// Remove an exchange [`OrderBookL1`] `Stream` from the [`DynamicStreams`] collection.
412    ///
413    /// Note that calling this method will permanently remove this `Stream` from [`Self`].
414    pub fn select_l1s(
415        &mut self,
416        exchange: ExchangeId,
417    ) -> Option<UnboundedReceiverStream<MarketStreamResult<InstrumentKey, OrderBookL1>>> {
418        self.l1s.remove(&exchange)
419    }
420
421    /// Select and merge every exchange [`OrderBookL1`] `Stream` using
422    /// [`SelectAll`](futures_util::stream::select_all::select_all).
423    pub fn select_all_l1s(
424        &mut self,
425    ) -> SelectAll<UnboundedReceiverStream<MarketStreamResult<InstrumentKey, OrderBookL1>>> {
426        futures_util::stream::select_all::select_all(std::mem::take(&mut self.l1s).into_values())
427    }
428
429    /// Remove an exchange [`OrderBookEvent`] `Stream` from the [`DynamicStreams`] collection.
430    ///
431    /// Note that calling this method will permanently remove this `Stream` from [`Self`].
432    pub fn select_l2s(
433        &mut self,
434        exchange: ExchangeId,
435    ) -> Option<UnboundedReceiverStream<MarketStreamResult<InstrumentKey, OrderBookEvent>>> {
436        self.l2s.remove(&exchange)
437    }
438
439    /// Select and merge every exchange [`OrderBookEvent`] `Stream` using
440    /// [`SelectAll`](futures_util::stream::select_all::select_all).
441    pub fn select_all_l2s(
442        &mut self,
443    ) -> SelectAll<UnboundedReceiverStream<MarketStreamResult<InstrumentKey, OrderBookEvent>>> {
444        futures_util::stream::select_all::select_all(std::mem::take(&mut self.l2s).into_values())
445    }
446
447    /// Remove an exchange [`Liquidation`] `Stream` from the [`DynamicStreams`] collection.
448    ///
449    /// Note that calling this method will permanently remove this `Stream` from [`Self`].
450    pub fn select_liquidations(
451        &mut self,
452        exchange: ExchangeId,
453    ) -> Option<UnboundedReceiverStream<MarketStreamResult<InstrumentKey, Liquidation>>> {
454        self.liquidations.remove(&exchange)
455    }
456
457    /// Select and merge every exchange [`Liquidation`] `Stream` using
458    /// [`SelectAll`](futures_util::stream::select_all::select_all).
459    pub fn select_all_liquidations(
460        &mut self,
461    ) -> SelectAll<UnboundedReceiverStream<MarketStreamResult<InstrumentKey, Liquidation>>> {
462        futures_util::stream::select_all::select_all(
463            std::mem::take(&mut self.liquidations).into_values(),
464        )
465    }
466
467    /// Remove an exchange [`Candle`] `Stream` from the [`DynamicStreams`] collection.
468    ///
469    /// Note that calling this method will permanently remove this `Stream` from [`Self`].
470    ///
471    /// # Mixed intervals
472    ///
473    /// `DynamicStreams` keys candle streams by [`ExchangeId`] only, so subscribing to
474    /// multiple intervals on one exchange (e.g. `Candles { Min1 }` + `Candles { Hour1 }`
475    /// on `BinanceSpot`) merges them into this single per-exchange stream. Each distinct
476    /// interval is a separate upstream WebSocket subscription (mind venue per-connection
477    /// stream limits at high symbol × interval fan-out). Because [`Candle`] carries no
478    /// `interval` field, a consumer mixing intervals must recover it from `close_time`
479    /// spacing or the originating subscription; if per-interval routing matters, use the
480    /// typed [`Streams`](crate::streams::Streams) builder with one
481    /// [`StreamSelector`](crate::exchange::StreamSelector) per interval.
482    pub fn select_candles(
483        &mut self,
484        exchange: ExchangeId,
485    ) -> Option<UnboundedReceiverStream<MarketStreamResult<InstrumentKey, Candle>>> {
486        self.candles.remove(&exchange)
487    }
488
489    /// Select and merge every exchange [`Candle`] `Stream` using
490    /// [`SelectAll`](futures_util::stream::select_all::select_all).
491    ///
492    /// See [`select_candles`](Self::select_candles) for how multiple intervals on one
493    /// exchange are merged — the merged `Candle`s carry no `interval` to disambiguate them.
494    pub fn select_all_candles(
495        &mut self,
496    ) -> SelectAll<UnboundedReceiverStream<MarketStreamResult<InstrumentKey, Candle>>> {
497        futures_util::stream::select_all::select_all(
498            std::mem::take(&mut self.candles).into_values(),
499        )
500    }
501
502    /// Select and merge every exchange `Stream` for every data type using [`select_all`](futures_util::stream::select_all::select_all)
503    ///
504    /// Note that using [`MarketStreamResult<Instrument, DataKind>`] as the `Output` is suitable for most
505    /// use cases.
506    pub fn select_all<Output>(self) -> impl Stream<Item = Output>
507    where
508        InstrumentKey: Send + 'static,
509        Output: 'static,
510        MarketStreamResult<InstrumentKey, PublicTrade>: Into<Output>,
511        MarketStreamResult<InstrumentKey, OrderBookL1>: Into<Output>,
512        MarketStreamResult<InstrumentKey, OrderBookEvent>: Into<Output>,
513        MarketStreamResult<InstrumentKey, Liquidation>: Into<Output>,
514        MarketStreamResult<InstrumentKey, Candle>: Into<Output>,
515    {
516        let Self {
517            trades,
518            l1s,
519            l2s,
520            liquidations,
521            candles,
522        } = self;
523
524        let trades = trades
525            .into_values()
526            .map(|stream| stream.map(MarketStreamResult::into).boxed());
527
528        let l1s = l1s
529            .into_values()
530            .map(|stream| stream.map(MarketStreamResult::into).boxed());
531
532        let l2s = l2s
533            .into_values()
534            .map(|stream| stream.map(MarketStreamResult::into).boxed());
535
536        let liquidations = liquidations
537            .into_values()
538            .map(|stream| stream.map(MarketStreamResult::into).boxed());
539
540        let candles = candles
541            .into_values()
542            .map(|stream| stream.map(MarketStreamResult::into).boxed());
543
544        let all = trades
545            .chain(l1s)
546            .chain(l2s)
547            .chain(liquidations)
548            .chain(candles);
549
550        futures_util::stream::select_all::select_all(all)
551    }
552}
553
554pub fn validate_batches<SubBatchIter, SubIter, Sub, Instrument>(
555    batches: SubBatchIter,
556) -> Result<Vec<Vec<Subscription<ExchangeId, Instrument, SubKind>>>, DataError>
557where
558    SubBatchIter: IntoIterator<Item = SubIter>,
559    SubIter: IntoIterator<Item = Sub>,
560    Sub: Into<Subscription<ExchangeId, Instrument, SubKind>>,
561    Instrument: InstrumentData + Ord,
562{
563    batches
564        .into_iter()
565        .map(validate_subscriptions::<SubIter, Sub, Instrument>)
566        .collect()
567}
568
569pub fn validate_subscriptions<SubIter, Sub, Instrument>(
570    batch: SubIter,
571) -> Result<Vec<Subscription<ExchangeId, Instrument, SubKind>>, DataError>
572where
573    SubIter: IntoIterator<Item = Sub>,
574    Sub: Into<Subscription<ExchangeId, Instrument, SubKind>>,
575    Instrument: InstrumentData + Ord,
576{
577    // Validate Subscriptions
578    let mut batch = batch
579        .into_iter()
580        .map(Sub::into)
581        .map(Validator::validate)
582        .collect::<Result<Vec<_>, SocketError>>()?;
583
584    // Remove duplicate Subscriptions
585    batch.sort();
586    batch.dedup();
587
588    Ok(batch)
589}
590
591/// Split each batch into its `(ExchangeId, SubKind)` groups and route every one, connecting none.
592///
593/// Routing all of them first is what lets a group nothing can serve fail the call before any
594/// connection is opened.
595fn route_batches<Instrument>(
596    batches: Vec<Vec<Subscription<ExchangeId, Instrument, SubKind>>>,
597    txs: &Txs<Instrument::Key>,
598    subscribers: &DynamicSubscribers,
599) -> Result<Vec<plan::GroupFuture>, DataError>
600where
601    Instrument: DynamicInstrument,
602{
603    batches
604        .into_iter()
605        .flat_map(|mut batch| {
606            batch.sort_unstable_by_key(|sub| (sub.exchange, sub.kind));
607            batch
608                .into_iter()
609                .chunk_by(|sub| (sub.exchange, sub.kind))
610                .into_iter()
611                .map(|(key, subs)| (key, subs.collect::<Vec<_>>()))
612                .collect::<Vec<_>>()
613        })
614        .map(|((exchange, sub_kind), subs)| {
615            Instrument::route(exchange, sub_kind, subs, txs, subscribers)
616        })
617        .collect()
618}
619
620/// Keep every group's forwarder if all of them initialised, or stop them all and return the first
621/// error.
622///
623/// Stopping them is what makes a failed initialisation leave nothing running: a forwarder owns its
624/// stream, and a stream on a shared connection holds its share of it for as long as it lives. Each
625/// aborted forwarder is awaited, because an abort only schedules the cancellation: awaiting it is
626/// what guarantees the stream has been dropped by the time this returns.
627async fn settle(results: Vec<Result<JoinHandle<()>, DataError>>) -> Result<(), DataError> {
628    let (forwarders, errors): (Vec<_>, Vec<_>) = results.into_iter().partition_result();
629
630    match errors.into_iter().next() {
631        None => Ok(()),
632        Some(error) => {
633            forwarders.iter().for_each(JoinHandle::abort);
634            join_all(forwarders).await;
635            Err(error)
636        }
637    }
638}
639
640struct Channels<InstrumentKey> {
641    txs: Txs<InstrumentKey>,
642    rxs: Rxs<InstrumentKey>,
643}
644
645impl<'a, Instrument> TryFrom<&'a Vec<Vec<Subscription<ExchangeId, Instrument, SubKind>>>>
646    for Channels<Instrument::Key>
647where
648    Instrument: InstrumentData,
649{
650    type Error = DataError;
651
652    fn try_from(
653        value: &'a Vec<Vec<Subscription<ExchangeId, Instrument, SubKind>>>,
654    ) -> Result<Self, Self::Error> {
655        let mut txs = Txs::default();
656        let mut rxs = Rxs::default();
657
658        for sub in value.iter().flatten() {
659            match sub.kind {
660                SubKind::PublicTrades => {
661                    if let (None, None) =
662                        (txs.trades.get(&sub.exchange), rxs.trades.get(&sub.exchange))
663                    {
664                        let (tx, rx) = mpsc_unbounded();
665                        txs.trades.insert(sub.exchange, tx);
666                        rxs.trades.insert(sub.exchange, rx);
667                    }
668                }
669                SubKind::OrderBooksL1 => {
670                    if let (None, None) = (txs.l1s.get(&sub.exchange), rxs.l1s.get(&sub.exchange)) {
671                        let (tx, rx) = mpsc_unbounded();
672                        txs.l1s.insert(sub.exchange, tx);
673                        rxs.l1s.insert(sub.exchange, rx);
674                    }
675                }
676                SubKind::OrderBooksL2 => {
677                    if let (None, None) = (txs.l2s.get(&sub.exchange), rxs.l2s.get(&sub.exchange)) {
678                        let (tx, rx) = mpsc_unbounded();
679                        txs.l2s.insert(sub.exchange, tx);
680                        rxs.l2s.insert(sub.exchange, rx);
681                    }
682                }
683                SubKind::Liquidations => {
684                    if let (None, None) = (
685                        txs.liquidations.get(&sub.exchange),
686                        rxs.liquidations.get(&sub.exchange),
687                    ) {
688                        let (tx, rx) = mpsc_unbounded();
689                        txs.liquidations.insert(sub.exchange, tx);
690                        rxs.liquidations.insert(sub.exchange, rx);
691                    }
692                }
693                SubKind::Candles { .. } => {
694                    if let (None, None) = (
695                        txs.candles.get(&sub.exchange),
696                        rxs.candles.get(&sub.exchange),
697                    ) {
698                        let (tx, rx) = mpsc_unbounded();
699                        txs.candles.insert(sub.exchange, tx);
700                        rxs.candles.insert(sub.exchange, rx);
701                    }
702                }
703                sub_kind @ (SubKind::OrderBooksL3 | SubKind::Quotes) => {
704                    return Err(DataError::Unsupported {
705                        exchange: sub.exchange,
706                        sub_kind,
707                    });
708                }
709            }
710        }
711
712        Ok(Channels { txs, rxs })
713    }
714}
715
716struct Rxs<InstrumentKey> {
717    trades: FnvHashMap<ExchangeId, UnboundedRx<MarketStreamResult<InstrumentKey, PublicTrade>>>,
718    l1s: FnvHashMap<ExchangeId, UnboundedRx<MarketStreamResult<InstrumentKey, OrderBookL1>>>,
719    l2s: FnvHashMap<ExchangeId, UnboundedRx<MarketStreamResult<InstrumentKey, OrderBookEvent>>>,
720    liquidations:
721        FnvHashMap<ExchangeId, UnboundedRx<MarketStreamResult<InstrumentKey, Liquidation>>>,
722    candles: FnvHashMap<ExchangeId, UnboundedRx<MarketStreamResult<InstrumentKey, Candle>>>,
723}
724
725impl<InstrumentKey> Default for Rxs<InstrumentKey> {
726    fn default() -> Self {
727        Self {
728            trades: Default::default(),
729            l1s: Default::default(),
730            l2s: Default::default(),
731            liquidations: Default::default(),
732            candles: Default::default(),
733        }
734    }
735}
736
737#[cfg(test)]
738#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
739mod tests {
740    use super::*;
741    use crate::subscription::{candle::CandleInterval, exchange_supports_instrument_kind_sub_kind};
742    use chrono::{TimeZone, Utc};
743    use rust_decimal::Decimal;
744    use rustrade_instrument::instrument::{
745        kind::option::{OptionExercise, OptionKind},
746        market_data::{
747            MarketDataInstrument,
748            kind::{MarketDataFutureContract, MarketDataInstrumentKind, MarketDataOptionContract},
749        },
750    };
751
752    /// Every [`ExchangeId`] variant.
753    fn every_exchange() -> Vec<ExchangeId> {
754        use ExchangeId::*;
755
756        let every = vec![
757            Other,
758            Simulated,
759            Mock,
760            BinanceFuturesCoin,
761            BinanceFuturesUsd,
762            BinanceMargin,
763            BinanceOptions,
764            BinancePortfolioMargin,
765            BinanceSpot,
766            BinanceUs,
767            Bitazza,
768            Bitfinex,
769            Bitflyer,
770            Bitget,
771            Bitmart,
772            BitmartFuturesUsd,
773            Bitmex,
774            Bitso,
775            Bitstamp,
776            Bitvavo,
777            Bithumb,
778            BybitPerpetualsUsd,
779            BybitSpot,
780            Cexio,
781            Coinbase,
782            CoinbaseInternational,
783            Cryptocom,
784            DatabentoDbeq,
785            DatabentoGlbx,
786            DatabentoOpra,
787            DatabentoXnas,
788            DatabentoXnys,
789            Deribit,
790            GateioFuturesBtc,
791            GateioFuturesUsd,
792            GateioOptions,
793            GateioPerpetualsBtc,
794            GateioPerpetualsUsd,
795            GateioSpot,
796            Gemini,
797            Hitbtc,
798            Htx,
799            HyperliquidPerp,
800            HyperliquidSpot,
801            AlpacaBroker,
802            AlpacaCrypto,
803            AlpacaIex,
804            AlpacaSip,
805            Ibkr,
806            Kraken,
807            Kucoin,
808            Liquid,
809            Massive,
810            Mexc,
811            Okx,
812            Poloniex,
813            LseFx,
814            LseCrypto,
815            LseEquities,
816            LseFutures,
817            LseCfd,
818            LseOptions,
819            MassiveStocks,
820            MassiveCrypto,
821            MassiveForex,
822            MassiveOptions,
823        ];
824
825        // Exhaustive, so a new variant fails to compile here until it is added to the list too.
826        for exchange in &every {
827            match exchange {
828                Other
829                | Simulated
830                | Mock
831                | BinanceFuturesCoin
832                | BinanceFuturesUsd
833                | BinanceMargin
834                | BinanceOptions
835                | BinancePortfolioMargin
836                | BinanceSpot
837                | BinanceUs
838                | Bitazza
839                | Bitfinex
840                | Bitflyer
841                | Bitget
842                | Bitmart
843                | BitmartFuturesUsd
844                | Bitmex
845                | Bitso
846                | Bitstamp
847                | Bitvavo
848                | Bithumb
849                | BybitPerpetualsUsd
850                | BybitSpot
851                | Cexio
852                | Coinbase
853                | CoinbaseInternational
854                | Cryptocom
855                | DatabentoDbeq
856                | DatabentoGlbx
857                | DatabentoOpra
858                | DatabentoXnas
859                | DatabentoXnys
860                | Deribit
861                | GateioFuturesBtc
862                | GateioFuturesUsd
863                | GateioOptions
864                | GateioPerpetualsBtc
865                | GateioPerpetualsUsd
866                | GateioSpot
867                | Gemini
868                | Hitbtc
869                | Htx
870                | HyperliquidPerp
871                | HyperliquidSpot
872                | AlpacaBroker
873                | AlpacaCrypto
874                | AlpacaIex
875                | AlpacaSip
876                | Ibkr
877                | Kraken
878                | Kucoin
879                | Liquid
880                | Massive
881                | Mexc
882                | Okx
883                | Poloniex
884                | LseFx
885                | LseCrypto
886                | LseEquities
887                | LseFutures
888                | LseCfd
889                | LseOptions
890                | MassiveStocks
891                | MassiveCrypto
892                | MassiveForex
893                | MassiveOptions => {}
894            }
895        }
896
897        every
898    }
899
900    fn instrument_kinds() -> [MarketDataInstrumentKind; 5] {
901        let expiry = Utc.with_ymd_and_hms(2030, 1, 1, 0, 0, 0).unwrap();
902        [
903            MarketDataInstrumentKind::Spot,
904            MarketDataInstrumentKind::Perpetual,
905            MarketDataInstrumentKind::Cfd,
906            MarketDataInstrumentKind::Future(MarketDataFutureContract { expiry }),
907            option(),
908        ]
909    }
910
911    fn sub_kinds() -> Vec<SubKind> {
912        [
913            SubKind::PublicTrades,
914            SubKind::OrderBooksL1,
915            SubKind::OrderBooksL2,
916            SubKind::OrderBooksL3,
917            SubKind::Liquidations,
918            SubKind::Quotes,
919        ]
920        .into_iter()
921        .chain(
922            CandleInterval::ALL
923                .into_iter()
924                .map(|interval| SubKind::Candles { interval }),
925        )
926        .collect()
927    }
928
929    fn subscription(
930        exchange: ExchangeId,
931        kind: MarketDataInstrumentKind,
932        sub_kind: SubKind,
933    ) -> Subscription<ExchangeId, MarketDataInstrument, SubKind> {
934        Subscription::new(
935            exchange,
936            MarketDataInstrument::new("btc", "usd", kind),
937            sub_kind,
938        )
939    }
940
941    /// Route `batches` as `init_with` does, without connecting anything.
942    fn route(
943        batches: Vec<Vec<Subscription<ExchangeId, MarketDataInstrument, SubKind>>>,
944        subscribers: &DynamicSubscribers,
945    ) -> Result<Vec<plan::GroupFuture>, DataError> {
946        let Channels { txs, rxs: _ } = Channels::try_from(&batches)?;
947        route_batches(batches, &txs, subscribers)
948    }
949
950    /// Subscribers for every venue that needs one in this build.
951    fn every_subscriber() -> DynamicSubscribers {
952        let subscribers = DynamicSubscribers::default();
953
954        #[cfg(feature = "alpaca")]
955        let subscribers = subscribers.with_alpaca(alpaca_subscriber());
956
957        #[cfg(feature = "lse")]
958        let subscribers = subscribers.with_lse(LseSubscriber::new(
959            crate::exchange::lse::live::LseCredentials::new("test-key"),
960        ));
961
962        #[cfg(feature = "massive")]
963        let subscribers = subscribers.with_massive(massive_subscriber());
964
965        subscribers
966    }
967
968    #[cfg(feature = "massive")]
969    fn massive_subscriber() -> MassiveSubscriber {
970        MassiveSubscriber::new(crate::exchange::massive::MassiveCredentials::new(
971            "test-key",
972        ))
973    }
974
975    fn option() -> MarketDataInstrumentKind {
976        MarketDataInstrumentKind::Option(MarketDataOptionContract {
977            kind: OptionKind::Call,
978            exercise: OptionExercise::American,
979            expiry: Utc.with_ymd_and_hms(2030, 1, 1, 0, 0, 0).unwrap(),
980            strike: Decimal::ONE_HUNDRED,
981        })
982    }
983
984    #[cfg(feature = "alpaca")]
985    fn alpaca_subscriber() -> AlpacaSubscriber {
986        AlpacaSubscriber::new(crate::exchange::alpaca::AlpacaCredentials::new(
987            "test-key",
988            "test-secret",
989        ))
990    }
991
992    fn feature_enabled(feature: &str) -> bool {
993        match feature {
994            "alpaca" => cfg!(feature = "alpaca"),
995            "lse" => cfg!(feature = "lse"),
996            "massive" => cfg!(feature = "massive"),
997            "hyperliquid" => cfg!(feature = "hyperliquid"),
998            other => panic!("no dynamic route is gated on the `{other}` feature"),
999        }
1000    }
1001
1002    #[test]
1003    fn every_pair_the_support_matrix_accepts_is_routed() {
1004        let subscribers = every_subscriber();
1005        let mut routed = 0;
1006
1007        for exchange in every_exchange() {
1008            for kind in instrument_kinds() {
1009                for sub_kind in sub_kinds() {
1010                    if !exchange_supports_instrument_kind_sub_kind(&exchange, &kind, sub_kind) {
1011                        continue;
1012                    }
1013
1014                    // The futures are dropped unpolled, so nothing connects.
1015                    match route(
1016                        vec![vec![subscription(exchange, kind.clone(), sub_kind)]],
1017                        &subscribers,
1018                    ) {
1019                        Ok(groups) => {
1020                            assert_eq!(groups.len(), 1);
1021                            routed += 1;
1022                        }
1023                        Err(DataError::FeatureDisabled { feature, .. }) => assert!(
1024                            !feature_enabled(&feature),
1025                            "{exchange} ({kind}, {sub_kind}) reported `{feature}` disabled in a \
1026                             build that enables it"
1027                        ),
1028                        Err(error) => panic!(
1029                            "the matrix accepts {exchange} ({kind}, {sub_kind}), but routing it \
1030                             failed: {error}"
1031                        ),
1032                    }
1033                }
1034            }
1035        }
1036
1037        // Guards against the loop passing vacuously.
1038        assert!(routed > 30, "only {routed} pairs were routed");
1039    }
1040
1041    #[test]
1042    fn a_pair_the_support_matrix_refuses_is_refused_before_routing() {
1043        // Options publish no quote, so an options L1 stream is refused rather than left silent.
1044        let error = validate_batches([[subscription(
1045            ExchangeId::LseOptions,
1046            option(),
1047            SubKind::OrderBooksL1,
1048        )]])
1049        .unwrap_err();
1050
1051        assert!(
1052            matches!(&error, DataError::Socket(message) if message.contains("lse_options")),
1053            "{error:?}"
1054        );
1055    }
1056
1057    #[cfg(feature = "alpaca")]
1058    #[test]
1059    fn one_alpaca_batch_across_feeds_and_kinds_shares_one_subscriber() {
1060        let subscriber = alpaca_subscriber();
1061        let subscribers = DynamicSubscribers::default().with_alpaca(subscriber.clone());
1062        let before = subscriber.connection_handles();
1063
1064        let groups = route(
1065            vec![vec![
1066                subscription(
1067                    ExchangeId::AlpacaCrypto,
1068                    MarketDataInstrumentKind::Spot,
1069                    SubKind::PublicTrades,
1070                ),
1071                subscription(
1072                    ExchangeId::AlpacaCrypto,
1073                    MarketDataInstrumentKind::Spot,
1074                    SubKind::OrderBooksL1,
1075                ),
1076                subscription(
1077                    ExchangeId::AlpacaIex,
1078                    MarketDataInstrumentKind::Spot,
1079                    SubKind::PublicTrades,
1080                ),
1081                subscription(
1082                    ExchangeId::AlpacaIex,
1083                    MarketDataInstrumentKind::Spot,
1084                    SubKind::OrderBooksL1,
1085                ),
1086            ]],
1087            &subscribers,
1088        )
1089        .unwrap();
1090
1091        // One group per (feed, kind), each holding a clone of the one subscriber: a group given a
1092        // subscriber of its own would open a second socket to its feed, which Alpaca refuses.
1093        assert_eq!(groups.len(), 4);
1094        assert_eq!(subscriber.connection_handles(), before + groups.len());
1095
1096        drop(groups);
1097        assert_eq!(subscriber.connection_handles(), before);
1098    }
1099
1100    #[cfg(feature = "alpaca")]
1101    #[test]
1102    fn an_alpaca_subscription_without_a_subscriber_fails_the_call_before_any_group_connects() {
1103        let error = route(
1104            vec![
1105                vec![subscription(
1106                    ExchangeId::BinanceSpot,
1107                    MarketDataInstrumentKind::Spot,
1108                    SubKind::PublicTrades,
1109                )],
1110                vec![subscription(
1111                    ExchangeId::AlpacaIex,
1112                    MarketDataInstrumentKind::Spot,
1113                    SubKind::OrderBooksL1,
1114                )],
1115            ],
1116            &DynamicSubscribers::default(),
1117        )
1118        .err()
1119        .unwrap();
1120
1121        assert_eq!(
1122            error,
1123            DataError::SubscriberRequired {
1124                exchange: ExchangeId::AlpacaIex
1125            }
1126        );
1127    }
1128
1129    #[cfg(not(feature = "alpaca"))]
1130    #[test]
1131    fn an_alpaca_subscription_without_the_feature_names_the_feature() {
1132        let error = route(
1133            vec![vec![subscription(
1134                ExchangeId::AlpacaCrypto,
1135                MarketDataInstrumentKind::Spot,
1136                SubKind::PublicTrades,
1137            )]],
1138            &DynamicSubscribers::default(),
1139        )
1140        .err()
1141        .unwrap();
1142
1143        assert_eq!(
1144            error,
1145            DataError::FeatureDisabled {
1146                exchange: ExchangeId::AlpacaCrypto,
1147                feature: "alpaca".to_owned(),
1148            }
1149        );
1150    }
1151
1152    #[test]
1153    fn alpaca_quotes_are_refused_before_routing_because_dynamic_streams_carry_no_quote() {
1154        let error = validate_batches([[subscription(
1155            ExchangeId::AlpacaCrypto,
1156            MarketDataInstrumentKind::Spot,
1157            SubKind::Quotes,
1158        )]])
1159        .unwrap_err();
1160
1161        assert!(
1162            matches!(&error, DataError::Socket(message) if message.contains("alpaca_crypto")),
1163            "{error:?}"
1164        );
1165    }
1166
1167    #[cfg(feature = "massive")]
1168    #[test]
1169    fn one_massive_batch_across_clusters_and_kinds_shares_one_subscriber() {
1170        let subscriber = massive_subscriber();
1171        let subscribers = DynamicSubscribers::default().with_massive(subscriber.clone());
1172        let before = subscriber.connection_handles();
1173
1174        let groups = route(
1175            vec![vec![
1176                subscription(
1177                    ExchangeId::MassiveStocks,
1178                    MarketDataInstrumentKind::Spot,
1179                    SubKind::PublicTrades,
1180                ),
1181                subscription(
1182                    ExchangeId::MassiveCrypto,
1183                    MarketDataInstrumentKind::Spot,
1184                    SubKind::PublicTrades,
1185                ),
1186                subscription(
1187                    ExchangeId::MassiveCrypto,
1188                    MarketDataInstrumentKind::Spot,
1189                    SubKind::OrderBooksL1,
1190                ),
1191                subscription(
1192                    ExchangeId::MassiveCrypto,
1193                    MarketDataInstrumentKind::Spot,
1194                    SubKind::Candles {
1195                        interval: CandleInterval::Min1,
1196                    },
1197                ),
1198                subscription(
1199                    ExchangeId::MassiveForex,
1200                    MarketDataInstrumentKind::Spot,
1201                    SubKind::OrderBooksL1,
1202                ),
1203                subscription(ExchangeId::MassiveOptions, option(), SubKind::PublicTrades),
1204            ]],
1205            &subscribers,
1206        )
1207        .unwrap();
1208
1209        // One group per (cluster, kind), each holding a clone of the one subscriber: a group given
1210        // a subscriber of its own would open a second socket to its cluster, and Massive would close
1211        // the older one.
1212        assert_eq!(groups.len(), 6);
1213        assert_eq!(subscriber.connection_handles(), before + groups.len());
1214
1215        drop(groups);
1216        assert_eq!(subscriber.connection_handles(), before);
1217    }
1218
1219    #[cfg(feature = "massive")]
1220    #[test]
1221    fn a_massive_subscription_without_a_subscriber_fails_the_call_before_any_group_connects() {
1222        let error = route(
1223            vec![
1224                vec![subscription(
1225                    ExchangeId::BinanceSpot,
1226                    MarketDataInstrumentKind::Spot,
1227                    SubKind::PublicTrades,
1228                )],
1229                vec![subscription(
1230                    ExchangeId::MassiveForex,
1231                    MarketDataInstrumentKind::Spot,
1232                    SubKind::OrderBooksL1,
1233                )],
1234            ],
1235            &DynamicSubscribers::default(),
1236        )
1237        .err()
1238        .unwrap();
1239
1240        assert_eq!(
1241            error,
1242            DataError::SubscriberRequired {
1243                exchange: ExchangeId::MassiveForex
1244            }
1245        );
1246    }
1247
1248    #[cfg(not(feature = "massive"))]
1249    #[test]
1250    fn a_massive_subscription_without_the_feature_names_the_feature() {
1251        let error = route(
1252            vec![vec![subscription(
1253                ExchangeId::MassiveCrypto,
1254                MarketDataInstrumentKind::Spot,
1255                SubKind::PublicTrades,
1256            )]],
1257            &DynamicSubscribers::default(),
1258        )
1259        .err()
1260        .unwrap();
1261
1262        assert_eq!(
1263            error,
1264            DataError::FeatureDisabled {
1265                exchange: ExchangeId::MassiveCrypto,
1266                feature: "massive".to_owned(),
1267            }
1268        );
1269    }
1270
1271    #[test]
1272    fn massive_pairs_outside_the_support_matrix_are_refused_before_routing() {
1273        let spot = MarketDataInstrumentKind::Spot;
1274        for (exchange, kind, sub_kind) in [
1275            (
1276                ExchangeId::MassiveForex,
1277                spot.clone(),
1278                SubKind::Candles {
1279                    interval: CandleInterval::Min5,
1280                },
1281            ),
1282            // Forex publishes no trades, and the engine's market data carries no quote.
1283            (
1284                ExchangeId::MassiveForex,
1285                spot.clone(),
1286                SubKind::PublicTrades,
1287            ),
1288            (ExchangeId::MassiveForex, spot.clone(), SubKind::Quotes),
1289            // Each cluster streams its own instrument kind only.
1290            (ExchangeId::MassiveOptions, spot, SubKind::PublicTrades),
1291            (ExchangeId::MassiveStocks, option(), SubKind::PublicTrades),
1292        ] {
1293            let error =
1294                validate_batches([[subscription(exchange, kind.clone(), sub_kind)]]).unwrap_err();
1295
1296            assert!(
1297                matches!(&error, DataError::Socket(message) if message.contains(exchange.as_str())),
1298                "{exchange} ({kind}, {sub_kind}): {error:?}"
1299            );
1300        }
1301    }
1302
1303    #[cfg(feature = "lse")]
1304    #[test]
1305    fn one_lse_batch_across_datasets_and_kinds_shares_one_subscriber() {
1306        let subscriber =
1307            LseSubscriber::new(crate::exchange::lse::live::LseCredentials::new("test-key"));
1308        let subscribers = DynamicSubscribers::default().with_lse(subscriber.clone());
1309        let before = subscriber.connection_handles();
1310
1311        let groups = route(
1312            vec![vec![
1313                subscription(
1314                    ExchangeId::LseCrypto,
1315                    MarketDataInstrumentKind::Spot,
1316                    SubKind::PublicTrades,
1317                ),
1318                subscription(
1319                    ExchangeId::LseCrypto,
1320                    MarketDataInstrumentKind::Spot,
1321                    SubKind::OrderBooksL1,
1322                ),
1323                subscription(
1324                    ExchangeId::LseEquities,
1325                    MarketDataInstrumentKind::Spot,
1326                    SubKind::PublicTrades,
1327                ),
1328                subscription(
1329                    ExchangeId::LseEquities,
1330                    MarketDataInstrumentKind::Spot,
1331                    SubKind::OrderBooksL1,
1332                ),
1333            ]],
1334            &subscribers,
1335        )
1336        .unwrap();
1337
1338        // One group per (dataset, kind), each holding a clone of the one subscriber: a group given
1339        // a subscriber of its own would open a second socket, which the provider refuses.
1340        assert_eq!(groups.len(), 4);
1341        assert_eq!(subscriber.connection_handles(), before + groups.len());
1342
1343        drop(groups);
1344        assert_eq!(subscriber.connection_handles(), before);
1345    }
1346
1347    #[cfg(feature = "lse")]
1348    #[test]
1349    fn an_lse_subscription_without_a_subscriber_fails_the_call_before_any_group_connects() {
1350        let error = route(
1351            vec![
1352                vec![subscription(
1353                    ExchangeId::BinanceSpot,
1354                    MarketDataInstrumentKind::Spot,
1355                    SubKind::PublicTrades,
1356                )],
1357                vec![subscription(
1358                    ExchangeId::LseFx,
1359                    MarketDataInstrumentKind::Spot,
1360                    SubKind::PublicTrades,
1361                )],
1362            ],
1363            &DynamicSubscribers::default(),
1364        )
1365        .err()
1366        .unwrap();
1367
1368        assert_eq!(
1369            error,
1370            DataError::SubscriberRequired {
1371                exchange: ExchangeId::LseFx
1372            }
1373        );
1374    }
1375
1376    #[cfg(not(feature = "lse"))]
1377    #[test]
1378    fn an_lse_subscription_without_the_feature_names_the_feature() {
1379        let error = route(
1380            vec![vec![subscription(
1381                ExchangeId::LseFx,
1382                MarketDataInstrumentKind::Spot,
1383                SubKind::PublicTrades,
1384            )]],
1385            &DynamicSubscribers::default(),
1386        )
1387        .err()
1388        .unwrap();
1389
1390        assert_eq!(
1391            error,
1392            DataError::FeatureDisabled {
1393                exchange: ExchangeId::LseFx,
1394                feature: "lse".to_owned(),
1395            }
1396        );
1397    }
1398
1399    #[tokio::test]
1400    async fn a_failed_group_stops_every_forwarder_that_started() {
1401        struct OnDrop(Option<tokio::sync::oneshot::Sender<()>>);
1402        impl Drop for OnDrop {
1403            fn drop(&mut self) {
1404                let _ = self.0.take().map(|tx| tx.send(()));
1405            }
1406        }
1407
1408        // Built outside the task, so it is dropped with it whether or not the task ever ran.
1409        let (dropped_tx, mut dropped) = tokio::sync::oneshot::channel();
1410        let guard = OnDrop(Some(dropped_tx));
1411        let forwarder = tokio::spawn(async move {
1412            let _guard = guard;
1413            std::future::pending::<()>().await;
1414        });
1415
1416        let error = settle(vec![
1417            Ok(forwarder),
1418            Err(DataError::SubscriberRequired {
1419                exchange: ExchangeId::LseFx,
1420            }),
1421        ])
1422        .await
1423        .unwrap_err();
1424
1425        assert_eq!(
1426            error,
1427            DataError::SubscriberRequired {
1428                exchange: ExchangeId::LseFx
1429            }
1430        );
1431        // The forwarder's task, and with it the stream it owned, was dropped before `settle`
1432        // returned: nothing further needs to run for the drop to have happened.
1433        assert_eq!(dropped.try_recv(), Ok(()));
1434    }
1435
1436    #[tokio::test]
1437    async fn forwarders_keep_running_when_every_group_initialised() {
1438        let forwarder = tokio::spawn(std::future::pending::<()>());
1439        let abort = forwarder.abort_handle();
1440
1441        settle(vec![Ok(forwarder)]).await.unwrap();
1442        tokio::task::yield_now().await;
1443
1444        assert!(!abort.is_finished());
1445        abort.abort();
1446    }
1447}