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. The compiler cannot see the list
826        // above, so add the variant there too when adding it to this match.
827        for exchange in &every {
828            match exchange {
829                Other
830                | Simulated
831                | Mock
832                | BinanceFuturesCoin
833                | BinanceFuturesUsd
834                | BinanceMargin
835                | BinanceOptions
836                | BinancePortfolioMargin
837                | BinanceSpot
838                | BinanceUs
839                | Bitazza
840                | Bitfinex
841                | Bitflyer
842                | Bitget
843                | Bitmart
844                | BitmartFuturesUsd
845                | Bitmex
846                | Bitso
847                | Bitstamp
848                | Bitvavo
849                | Bithumb
850                | BybitPerpetualsUsd
851                | BybitSpot
852                | Cexio
853                | Coinbase
854                | CoinbaseInternational
855                | Cryptocom
856                | DatabentoDbeq
857                | DatabentoGlbx
858                | DatabentoOpra
859                | DatabentoXnas
860                | DatabentoXnys
861                | Deribit
862                | GateioFuturesBtc
863                | GateioFuturesUsd
864                | GateioOptions
865                | GateioPerpetualsBtc
866                | GateioPerpetualsUsd
867                | GateioSpot
868                | Gemini
869                | Hitbtc
870                | Htx
871                | HyperliquidPerp
872                | HyperliquidSpot
873                | AlpacaBroker
874                | AlpacaCrypto
875                | AlpacaIex
876                | AlpacaSip
877                | Ibkr
878                | Kraken
879                | Kucoin
880                | Liquid
881                | Massive
882                | Mexc
883                | Okx
884                | Poloniex
885                | LseFx
886                | LseCrypto
887                | LseEquities
888                | LseFutures
889                | LseCfd
890                | LseOptions
891                | MassiveStocks
892                | MassiveCrypto
893                | MassiveForex
894                | MassiveOptions => {}
895            }
896        }
897
898        every
899    }
900
901    /// One [`MarketDataInstrumentKind`] per variant.
902    fn instrument_kinds() -> [MarketDataInstrumentKind; 5] {
903        let expiry = Utc.with_ymd_and_hms(2030, 1, 1, 0, 0, 0).unwrap();
904        let every = [
905            MarketDataInstrumentKind::Spot,
906            MarketDataInstrumentKind::Perpetual,
907            MarketDataInstrumentKind::Cfd,
908            MarketDataInstrumentKind::Future(MarketDataFutureContract { expiry }),
909            option(),
910        ];
911
912        // Exhaustive, so a new variant fails to compile here. The compiler cannot see the list
913        // above, so add the variant there too when adding it to this match.
914        for kind in &every {
915            match kind {
916                MarketDataInstrumentKind::Spot
917                | MarketDataInstrumentKind::Perpetual
918                | MarketDataInstrumentKind::Cfd
919                | MarketDataInstrumentKind::Future(_)
920                | MarketDataInstrumentKind::Option(_) => {}
921            }
922        }
923
924        every
925    }
926
927    /// Every [`SubKind`] variant, with [`SubKind::Candles`] at every [`CandleInterval`].
928    fn sub_kinds() -> Vec<SubKind> {
929        let every: Vec<SubKind> = [
930            SubKind::PublicTrades,
931            SubKind::OrderBooksL1,
932            SubKind::OrderBooksL2,
933            SubKind::OrderBooksL3,
934            SubKind::Liquidations,
935            SubKind::Quotes,
936        ]
937        .into_iter()
938        .chain(
939            CandleInterval::ALL
940                .into_iter()
941                .map(|interval| SubKind::Candles { interval }),
942        )
943        .collect();
944
945        // Exhaustive, so a new variant fails to compile here. The compiler cannot see the list
946        // above, so add the variant there too when adding it to this match.
947        for sub_kind in &every {
948            match sub_kind {
949                SubKind::PublicTrades
950                | SubKind::OrderBooksL1
951                | SubKind::OrderBooksL2
952                | SubKind::OrderBooksL3
953                | SubKind::Liquidations
954                | SubKind::Candles { .. }
955                | SubKind::Quotes => {}
956            }
957        }
958
959        every
960    }
961
962    fn subscription(
963        exchange: ExchangeId,
964        kind: MarketDataInstrumentKind,
965        sub_kind: SubKind,
966    ) -> Subscription<ExchangeId, MarketDataInstrument, SubKind> {
967        Subscription::new(
968            exchange,
969            MarketDataInstrument::new("btc", "usd", kind),
970            sub_kind,
971        )
972    }
973
974    /// Route `batches` as `init_with` does, without connecting anything.
975    fn route(
976        batches: Vec<Vec<Subscription<ExchangeId, MarketDataInstrument, SubKind>>>,
977        subscribers: &DynamicSubscribers,
978    ) -> Result<Vec<plan::GroupFuture>, DataError> {
979        let Channels { txs, rxs: _ } = Channels::try_from(&batches)?;
980        route_batches(batches, &txs, subscribers)
981    }
982
983    /// Subscribers for every venue that needs one in this build.
984    fn every_subscriber() -> DynamicSubscribers {
985        let subscribers = DynamicSubscribers::default();
986
987        #[cfg(feature = "alpaca")]
988        let subscribers = subscribers.with_alpaca(alpaca_subscriber());
989
990        #[cfg(feature = "lse")]
991        let subscribers = subscribers.with_lse(LseSubscriber::new(
992            crate::exchange::lse::live::LseCredentials::new("test-key"),
993        ));
994
995        #[cfg(feature = "massive")]
996        let subscribers = subscribers.with_massive(massive_subscriber());
997
998        subscribers
999    }
1000
1001    #[cfg(feature = "massive")]
1002    fn massive_subscriber() -> MassiveSubscriber {
1003        MassiveSubscriber::new(crate::exchange::massive::MassiveCredentials::new(
1004            "test-key",
1005        ))
1006    }
1007
1008    fn option() -> MarketDataInstrumentKind {
1009        MarketDataInstrumentKind::Option(MarketDataOptionContract {
1010            kind: OptionKind::Call,
1011            exercise: OptionExercise::American,
1012            expiry: Utc.with_ymd_and_hms(2030, 1, 1, 0, 0, 0).unwrap(),
1013            strike: Decimal::ONE_HUNDRED,
1014        })
1015    }
1016
1017    #[cfg(feature = "alpaca")]
1018    fn alpaca_subscriber() -> AlpacaSubscriber {
1019        AlpacaSubscriber::new(crate::exchange::alpaca::AlpacaCredentials::new(
1020            "test-key",
1021            "test-secret",
1022        ))
1023    }
1024
1025    fn feature_enabled(feature: &str) -> bool {
1026        match feature {
1027            "alpaca" => cfg!(feature = "alpaca"),
1028            "lse" => cfg!(feature = "lse"),
1029            "massive" => cfg!(feature = "massive"),
1030            "hyperliquid" => cfg!(feature = "hyperliquid"),
1031            other => panic!("no dynamic route is gated on the `{other}` feature"),
1032        }
1033    }
1034
1035    #[test]
1036    fn every_pair_the_support_matrix_accepts_is_routed() {
1037        let subscribers = every_subscriber();
1038        let mut routed = 0;
1039
1040        for exchange in every_exchange() {
1041            for kind in instrument_kinds() {
1042                for sub_kind in sub_kinds() {
1043                    if !exchange_supports_instrument_kind_sub_kind(&exchange, &kind, sub_kind) {
1044                        continue;
1045                    }
1046
1047                    // The futures are dropped unpolled, so nothing connects.
1048                    match route(
1049                        vec![vec![subscription(exchange, kind.clone(), sub_kind)]],
1050                        &subscribers,
1051                    ) {
1052                        Ok(groups) => {
1053                            assert_eq!(groups.len(), 1);
1054                            routed += 1;
1055                        }
1056                        Err(DataError::FeatureDisabled { feature, .. }) => assert!(
1057                            !feature_enabled(&feature),
1058                            "{exchange} ({kind}, {sub_kind}) reported `{feature}` disabled in a \
1059                             build that enables it"
1060                        ),
1061                        Err(error) => panic!(
1062                            "the matrix accepts {exchange} ({kind}, {sub_kind}), but routing it \
1063                             failed: {error}"
1064                        ),
1065                    }
1066                }
1067            }
1068        }
1069
1070        // Guards against the loop passing vacuously.
1071        assert!(routed > 30, "only {routed} pairs were routed");
1072    }
1073
1074    #[test]
1075    fn every_pair_the_router_serves_is_accepted_by_the_support_matrix() {
1076        let subscribers = every_subscriber();
1077        let mut served = 0;
1078
1079        for exchange in every_exchange() {
1080            for sub_kind in sub_kinds() {
1081                // The router has no instrument-kind dimension, so any kind stands in for all.
1082                // The futures are dropped unpolled, so nothing connects.
1083                match route(
1084                    vec![vec![subscription(
1085                        exchange,
1086                        MarketDataInstrumentKind::Spot,
1087                        sub_kind,
1088                    )]],
1089                    &subscribers,
1090                ) {
1091                    Err(DataError::Unsupported { .. }) => continue,
1092                    Ok(_) | Err(DataError::FeatureDisabled { .. }) => {}
1093                    Err(error) => panic!(
1094                        "routing {exchange} ({sub_kind}) failed with neither a route nor \
1095                         Unsupported: {error}"
1096                    ),
1097                }
1098                served += 1;
1099
1100                // The router's candle arms take every interval and the matrix narrows them per
1101                // exchange, so a candle pair needs the matrix to accept only some interval.
1102                let candidates = match sub_kind {
1103                    SubKind::Candles { .. } => CandleInterval::ALL
1104                        .into_iter()
1105                        .map(|interval| SubKind::Candles { interval })
1106                        .collect(),
1107                    other => vec![other],
1108                };
1109                let accepted = instrument_kinds().iter().any(|kind| {
1110                    candidates.iter().any(|candidate| {
1111                        exchange_supports_instrument_kind_sub_kind(&exchange, kind, *candidate)
1112                    })
1113                });
1114                assert!(
1115                    accepted,
1116                    "the router serves {exchange} ({sub_kind}), but the support matrix accepts \
1117                     it for no instrument kind"
1118                );
1119            }
1120        }
1121
1122        // Guards against the loop passing vacuously.
1123        assert!(served > 30, "only {served} pairs were served");
1124    }
1125
1126    #[test]
1127    fn a_pair_the_support_matrix_refuses_is_refused_before_routing() {
1128        // Options publish no quote, so an options L1 stream is refused rather than left silent.
1129        let error = validate_batches([[subscription(
1130            ExchangeId::LseOptions,
1131            option(),
1132            SubKind::OrderBooksL1,
1133        )]])
1134        .unwrap_err();
1135
1136        assert!(
1137            matches!(&error, DataError::Socket(message) if message.contains("lse_options")),
1138            "{error:?}"
1139        );
1140    }
1141
1142    #[cfg(feature = "alpaca")]
1143    #[test]
1144    fn one_alpaca_batch_across_feeds_and_kinds_shares_one_subscriber() {
1145        let subscriber = alpaca_subscriber();
1146        let subscribers = DynamicSubscribers::default().with_alpaca(subscriber.clone());
1147        let before = subscriber.connection_handles();
1148
1149        let groups = route(
1150            vec![vec![
1151                subscription(
1152                    ExchangeId::AlpacaCrypto,
1153                    MarketDataInstrumentKind::Spot,
1154                    SubKind::PublicTrades,
1155                ),
1156                subscription(
1157                    ExchangeId::AlpacaCrypto,
1158                    MarketDataInstrumentKind::Spot,
1159                    SubKind::OrderBooksL1,
1160                ),
1161                subscription(
1162                    ExchangeId::AlpacaIex,
1163                    MarketDataInstrumentKind::Spot,
1164                    SubKind::PublicTrades,
1165                ),
1166                subscription(
1167                    ExchangeId::AlpacaIex,
1168                    MarketDataInstrumentKind::Spot,
1169                    SubKind::OrderBooksL1,
1170                ),
1171            ]],
1172            &subscribers,
1173        )
1174        .unwrap();
1175
1176        // One group per (feed, kind), each holding a clone of the one subscriber: a group given a
1177        // subscriber of its own would open a second socket to its feed, which Alpaca refuses.
1178        assert_eq!(groups.len(), 4);
1179        assert_eq!(subscriber.connection_handles(), before + groups.len());
1180
1181        drop(groups);
1182        assert_eq!(subscriber.connection_handles(), before);
1183    }
1184
1185    #[cfg(feature = "alpaca")]
1186    #[test]
1187    fn an_alpaca_subscription_without_a_subscriber_fails_the_call_before_any_group_connects() {
1188        let error = route(
1189            vec![
1190                vec![subscription(
1191                    ExchangeId::BinanceSpot,
1192                    MarketDataInstrumentKind::Spot,
1193                    SubKind::PublicTrades,
1194                )],
1195                vec![subscription(
1196                    ExchangeId::AlpacaIex,
1197                    MarketDataInstrumentKind::Spot,
1198                    SubKind::OrderBooksL1,
1199                )],
1200            ],
1201            &DynamicSubscribers::default(),
1202        )
1203        .err()
1204        .unwrap();
1205
1206        assert_eq!(
1207            error,
1208            DataError::SubscriberRequired {
1209                exchange: ExchangeId::AlpacaIex
1210            }
1211        );
1212    }
1213
1214    #[cfg(not(feature = "alpaca"))]
1215    #[test]
1216    fn an_alpaca_subscription_without_the_feature_names_the_feature() {
1217        let error = route(
1218            vec![vec![subscription(
1219                ExchangeId::AlpacaCrypto,
1220                MarketDataInstrumentKind::Spot,
1221                SubKind::PublicTrades,
1222            )]],
1223            &DynamicSubscribers::default(),
1224        )
1225        .err()
1226        .unwrap();
1227
1228        assert_eq!(
1229            error,
1230            DataError::FeatureDisabled {
1231                exchange: ExchangeId::AlpacaCrypto,
1232                feature: "alpaca".to_owned(),
1233            }
1234        );
1235    }
1236
1237    #[test]
1238    fn alpaca_quotes_are_refused_before_routing_because_dynamic_streams_carry_no_quote() {
1239        let error = validate_batches([[subscription(
1240            ExchangeId::AlpacaCrypto,
1241            MarketDataInstrumentKind::Spot,
1242            SubKind::Quotes,
1243        )]])
1244        .unwrap_err();
1245
1246        assert!(
1247            matches!(&error, DataError::Socket(message) if message.contains("alpaca_crypto")),
1248            "{error:?}"
1249        );
1250    }
1251
1252    #[cfg(feature = "massive")]
1253    #[test]
1254    fn one_massive_batch_across_clusters_and_kinds_shares_one_subscriber() {
1255        let subscriber = massive_subscriber();
1256        let subscribers = DynamicSubscribers::default().with_massive(subscriber.clone());
1257        let before = subscriber.connection_handles();
1258
1259        let groups = route(
1260            vec![vec![
1261                subscription(
1262                    ExchangeId::MassiveStocks,
1263                    MarketDataInstrumentKind::Spot,
1264                    SubKind::PublicTrades,
1265                ),
1266                subscription(
1267                    ExchangeId::MassiveCrypto,
1268                    MarketDataInstrumentKind::Spot,
1269                    SubKind::PublicTrades,
1270                ),
1271                subscription(
1272                    ExchangeId::MassiveCrypto,
1273                    MarketDataInstrumentKind::Spot,
1274                    SubKind::OrderBooksL1,
1275                ),
1276                subscription(
1277                    ExchangeId::MassiveCrypto,
1278                    MarketDataInstrumentKind::Spot,
1279                    SubKind::Candles {
1280                        interval: CandleInterval::Min1,
1281                    },
1282                ),
1283                subscription(
1284                    ExchangeId::MassiveForex,
1285                    MarketDataInstrumentKind::Spot,
1286                    SubKind::OrderBooksL1,
1287                ),
1288                subscription(ExchangeId::MassiveOptions, option(), SubKind::PublicTrades),
1289            ]],
1290            &subscribers,
1291        )
1292        .unwrap();
1293
1294        // One group per (cluster, kind), each holding a clone of the one subscriber: a group given
1295        // a subscriber of its own would open a second socket to its cluster, and Massive would close
1296        // the older one.
1297        assert_eq!(groups.len(), 6);
1298        assert_eq!(subscriber.connection_handles(), before + groups.len());
1299
1300        drop(groups);
1301        assert_eq!(subscriber.connection_handles(), before);
1302    }
1303
1304    #[cfg(feature = "massive")]
1305    #[test]
1306    fn a_massive_subscription_without_a_subscriber_fails_the_call_before_any_group_connects() {
1307        let error = route(
1308            vec![
1309                vec![subscription(
1310                    ExchangeId::BinanceSpot,
1311                    MarketDataInstrumentKind::Spot,
1312                    SubKind::PublicTrades,
1313                )],
1314                vec![subscription(
1315                    ExchangeId::MassiveForex,
1316                    MarketDataInstrumentKind::Spot,
1317                    SubKind::OrderBooksL1,
1318                )],
1319            ],
1320            &DynamicSubscribers::default(),
1321        )
1322        .err()
1323        .unwrap();
1324
1325        assert_eq!(
1326            error,
1327            DataError::SubscriberRequired {
1328                exchange: ExchangeId::MassiveForex
1329            }
1330        );
1331    }
1332
1333    #[cfg(not(feature = "massive"))]
1334    #[test]
1335    fn a_massive_subscription_without_the_feature_names_the_feature() {
1336        let error = route(
1337            vec![vec![subscription(
1338                ExchangeId::MassiveCrypto,
1339                MarketDataInstrumentKind::Spot,
1340                SubKind::PublicTrades,
1341            )]],
1342            &DynamicSubscribers::default(),
1343        )
1344        .err()
1345        .unwrap();
1346
1347        assert_eq!(
1348            error,
1349            DataError::FeatureDisabled {
1350                exchange: ExchangeId::MassiveCrypto,
1351                feature: "massive".to_owned(),
1352            }
1353        );
1354    }
1355
1356    #[test]
1357    fn massive_pairs_outside_the_support_matrix_are_refused_before_routing() {
1358        let spot = MarketDataInstrumentKind::Spot;
1359        for (exchange, kind, sub_kind) in [
1360            (
1361                ExchangeId::MassiveForex,
1362                spot.clone(),
1363                SubKind::Candles {
1364                    interval: CandleInterval::Min5,
1365                },
1366            ),
1367            // Forex publishes no trades, and the engine's market data carries no quote.
1368            (
1369                ExchangeId::MassiveForex,
1370                spot.clone(),
1371                SubKind::PublicTrades,
1372            ),
1373            (ExchangeId::MassiveForex, spot.clone(), SubKind::Quotes),
1374            // Each cluster streams its own instrument kind only.
1375            (ExchangeId::MassiveOptions, spot, SubKind::PublicTrades),
1376            (ExchangeId::MassiveStocks, option(), SubKind::PublicTrades),
1377        ] {
1378            let error =
1379                validate_batches([[subscription(exchange, kind.clone(), sub_kind)]]).unwrap_err();
1380
1381            assert!(
1382                matches!(&error, DataError::Socket(message) if message.contains(exchange.as_str())),
1383                "{exchange} ({kind}, {sub_kind}): {error:?}"
1384            );
1385        }
1386    }
1387
1388    #[cfg(feature = "lse")]
1389    #[test]
1390    fn one_lse_batch_across_datasets_and_kinds_shares_one_subscriber() {
1391        let subscriber =
1392            LseSubscriber::new(crate::exchange::lse::live::LseCredentials::new("test-key"));
1393        let subscribers = DynamicSubscribers::default().with_lse(subscriber.clone());
1394        let before = subscriber.connection_handles();
1395
1396        let groups = route(
1397            vec![vec![
1398                subscription(
1399                    ExchangeId::LseCrypto,
1400                    MarketDataInstrumentKind::Spot,
1401                    SubKind::PublicTrades,
1402                ),
1403                subscription(
1404                    ExchangeId::LseCrypto,
1405                    MarketDataInstrumentKind::Spot,
1406                    SubKind::OrderBooksL1,
1407                ),
1408                subscription(
1409                    ExchangeId::LseEquities,
1410                    MarketDataInstrumentKind::Spot,
1411                    SubKind::PublicTrades,
1412                ),
1413                subscription(
1414                    ExchangeId::LseEquities,
1415                    MarketDataInstrumentKind::Spot,
1416                    SubKind::OrderBooksL1,
1417                ),
1418            ]],
1419            &subscribers,
1420        )
1421        .unwrap();
1422
1423        // One group per (dataset, kind), each holding a clone of the one subscriber: a group given
1424        // a subscriber of its own would open a second socket, which the provider refuses.
1425        assert_eq!(groups.len(), 4);
1426        assert_eq!(subscriber.connection_handles(), before + groups.len());
1427
1428        drop(groups);
1429        assert_eq!(subscriber.connection_handles(), before);
1430    }
1431
1432    #[cfg(feature = "lse")]
1433    #[test]
1434    fn an_lse_subscription_without_a_subscriber_fails_the_call_before_any_group_connects() {
1435        let error = route(
1436            vec![
1437                vec![subscription(
1438                    ExchangeId::BinanceSpot,
1439                    MarketDataInstrumentKind::Spot,
1440                    SubKind::PublicTrades,
1441                )],
1442                vec![subscription(
1443                    ExchangeId::LseFx,
1444                    MarketDataInstrumentKind::Spot,
1445                    SubKind::PublicTrades,
1446                )],
1447            ],
1448            &DynamicSubscribers::default(),
1449        )
1450        .err()
1451        .unwrap();
1452
1453        assert_eq!(
1454            error,
1455            DataError::SubscriberRequired {
1456                exchange: ExchangeId::LseFx
1457            }
1458        );
1459    }
1460
1461    #[cfg(not(feature = "lse"))]
1462    #[test]
1463    fn an_lse_subscription_without_the_feature_names_the_feature() {
1464        let error = route(
1465            vec![vec![subscription(
1466                ExchangeId::LseFx,
1467                MarketDataInstrumentKind::Spot,
1468                SubKind::PublicTrades,
1469            )]],
1470            &DynamicSubscribers::default(),
1471        )
1472        .err()
1473        .unwrap();
1474
1475        assert_eq!(
1476            error,
1477            DataError::FeatureDisabled {
1478                exchange: ExchangeId::LseFx,
1479                feature: "lse".to_owned(),
1480            }
1481        );
1482    }
1483
1484    #[tokio::test]
1485    async fn a_failed_group_stops_every_forwarder_that_started() {
1486        struct OnDrop(Option<tokio::sync::oneshot::Sender<()>>);
1487        impl Drop for OnDrop {
1488            fn drop(&mut self) {
1489                let _ = self.0.take().map(|tx| tx.send(()));
1490            }
1491        }
1492
1493        // Built outside the task, so it is dropped with it whether or not the task ever ran.
1494        let (dropped_tx, mut dropped) = tokio::sync::oneshot::channel();
1495        let guard = OnDrop(Some(dropped_tx));
1496        let forwarder = tokio::spawn(async move {
1497            let _guard = guard;
1498            std::future::pending::<()>().await;
1499        });
1500
1501        let error = settle(vec![
1502            Ok(forwarder),
1503            Err(DataError::SubscriberRequired {
1504                exchange: ExchangeId::LseFx,
1505            }),
1506        ])
1507        .await
1508        .unwrap_err();
1509
1510        assert_eq!(
1511            error,
1512            DataError::SubscriberRequired {
1513                exchange: ExchangeId::LseFx
1514            }
1515        );
1516        // The forwarder's task, and with it the stream it owned, was dropped before `settle`
1517        // returned: nothing further needs to run for the drop to have happened.
1518        assert_eq!(dropped.try_recv(), Ok(()));
1519    }
1520
1521    #[tokio::test]
1522    async fn forwarders_keep_running_when_every_group_initialised() {
1523        let forwarder = tokio::spawn(std::future::pending::<()>());
1524        let abort = forwarder.abort_handle();
1525
1526        settle(vec![Ok(forwarder)]).await.unwrap();
1527        tokio::task::yield_now().await;
1528
1529        assert!(!abort.is_finished());
1530        abort.abort();
1531    }
1532}