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 = "hyperliquid")]
1143    #[test]
1144    fn hyperliquid_spot_trades_and_depth_are_validated_and_routed() {
1145        let batch: Vec<_> = [SubKind::PublicTrades, SubKind::OrderBooksL2]
1146            .into_iter()
1147            .map(|sub_kind| {
1148                subscription(
1149                    ExchangeId::HyperliquidSpot,
1150                    MarketDataInstrumentKind::Spot,
1151                    sub_kind,
1152                )
1153            })
1154            .collect();
1155
1156        let batches = validate_batches([batch]).unwrap();
1157        let groups = route(batches, &DynamicSubscribers::default()).unwrap();
1158
1159        // One group per kind; the futures are dropped unpolled, so nothing connects.
1160        assert_eq!(groups.len(), 2);
1161    }
1162
1163    #[cfg(not(feature = "hyperliquid"))]
1164    #[test]
1165    fn a_hyperliquid_spot_subscription_without_the_feature_names_the_feature() {
1166        let error = route(
1167            vec![vec![subscription(
1168                ExchangeId::HyperliquidSpot,
1169                MarketDataInstrumentKind::Spot,
1170                SubKind::PublicTrades,
1171            )]],
1172            &DynamicSubscribers::default(),
1173        )
1174        .err()
1175        .unwrap();
1176
1177        assert_eq!(
1178            error,
1179            DataError::FeatureDisabled {
1180                exchange: ExchangeId::HyperliquidSpot,
1181                feature: "hyperliquid".to_owned(),
1182            }
1183        );
1184    }
1185
1186    #[cfg(feature = "alpaca")]
1187    #[test]
1188    fn one_alpaca_batch_across_feeds_and_kinds_shares_one_subscriber() {
1189        let subscriber = alpaca_subscriber();
1190        let subscribers = DynamicSubscribers::default().with_alpaca(subscriber.clone());
1191        let before = subscriber.connection_handles();
1192
1193        let groups = route(
1194            vec![vec![
1195                subscription(
1196                    ExchangeId::AlpacaCrypto,
1197                    MarketDataInstrumentKind::Spot,
1198                    SubKind::PublicTrades,
1199                ),
1200                subscription(
1201                    ExchangeId::AlpacaCrypto,
1202                    MarketDataInstrumentKind::Spot,
1203                    SubKind::OrderBooksL1,
1204                ),
1205                subscription(
1206                    ExchangeId::AlpacaIex,
1207                    MarketDataInstrumentKind::Spot,
1208                    SubKind::PublicTrades,
1209                ),
1210                subscription(
1211                    ExchangeId::AlpacaIex,
1212                    MarketDataInstrumentKind::Spot,
1213                    SubKind::OrderBooksL1,
1214                ),
1215            ]],
1216            &subscribers,
1217        )
1218        .unwrap();
1219
1220        // One group per (feed, kind), each holding a clone of the one subscriber: a group given a
1221        // subscriber of its own would open a second socket to its feed, which Alpaca refuses.
1222        assert_eq!(groups.len(), 4);
1223        assert_eq!(subscriber.connection_handles(), before + groups.len());
1224
1225        drop(groups);
1226        assert_eq!(subscriber.connection_handles(), before);
1227    }
1228
1229    #[cfg(feature = "alpaca")]
1230    #[test]
1231    fn an_alpaca_subscription_without_a_subscriber_fails_the_call_before_any_group_connects() {
1232        let error = route(
1233            vec![
1234                vec![subscription(
1235                    ExchangeId::BinanceSpot,
1236                    MarketDataInstrumentKind::Spot,
1237                    SubKind::PublicTrades,
1238                )],
1239                vec![subscription(
1240                    ExchangeId::AlpacaIex,
1241                    MarketDataInstrumentKind::Spot,
1242                    SubKind::OrderBooksL1,
1243                )],
1244            ],
1245            &DynamicSubscribers::default(),
1246        )
1247        .err()
1248        .unwrap();
1249
1250        assert_eq!(
1251            error,
1252            DataError::SubscriberRequired {
1253                exchange: ExchangeId::AlpacaIex
1254            }
1255        );
1256    }
1257
1258    #[cfg(not(feature = "alpaca"))]
1259    #[test]
1260    fn an_alpaca_subscription_without_the_feature_names_the_feature() {
1261        let error = route(
1262            vec![vec![subscription(
1263                ExchangeId::AlpacaCrypto,
1264                MarketDataInstrumentKind::Spot,
1265                SubKind::PublicTrades,
1266            )]],
1267            &DynamicSubscribers::default(),
1268        )
1269        .err()
1270        .unwrap();
1271
1272        assert_eq!(
1273            error,
1274            DataError::FeatureDisabled {
1275                exchange: ExchangeId::AlpacaCrypto,
1276                feature: "alpaca".to_owned(),
1277            }
1278        );
1279    }
1280
1281    #[test]
1282    fn alpaca_quotes_are_refused_before_routing_because_dynamic_streams_carry_no_quote() {
1283        let error = validate_batches([[subscription(
1284            ExchangeId::AlpacaCrypto,
1285            MarketDataInstrumentKind::Spot,
1286            SubKind::Quotes,
1287        )]])
1288        .unwrap_err();
1289
1290        assert!(
1291            matches!(&error, DataError::Socket(message) if message.contains("alpaca_crypto")),
1292            "{error:?}"
1293        );
1294    }
1295
1296    #[cfg(feature = "massive")]
1297    #[test]
1298    fn one_massive_batch_across_clusters_and_kinds_shares_one_subscriber() {
1299        let subscriber = massive_subscriber();
1300        let subscribers = DynamicSubscribers::default().with_massive(subscriber.clone());
1301        let before = subscriber.connection_handles();
1302
1303        let groups = route(
1304            vec![vec![
1305                subscription(
1306                    ExchangeId::MassiveStocks,
1307                    MarketDataInstrumentKind::Spot,
1308                    SubKind::PublicTrades,
1309                ),
1310                subscription(
1311                    ExchangeId::MassiveCrypto,
1312                    MarketDataInstrumentKind::Spot,
1313                    SubKind::PublicTrades,
1314                ),
1315                subscription(
1316                    ExchangeId::MassiveCrypto,
1317                    MarketDataInstrumentKind::Spot,
1318                    SubKind::OrderBooksL1,
1319                ),
1320                subscription(
1321                    ExchangeId::MassiveCrypto,
1322                    MarketDataInstrumentKind::Spot,
1323                    SubKind::Candles {
1324                        interval: CandleInterval::Min1,
1325                    },
1326                ),
1327                subscription(
1328                    ExchangeId::MassiveForex,
1329                    MarketDataInstrumentKind::Spot,
1330                    SubKind::OrderBooksL1,
1331                ),
1332                subscription(ExchangeId::MassiveOptions, option(), SubKind::PublicTrades),
1333            ]],
1334            &subscribers,
1335        )
1336        .unwrap();
1337
1338        // One group per (cluster, kind), each holding a clone of the one subscriber: a group given
1339        // a subscriber of its own would open a second socket to its cluster, and Massive would close
1340        // the older one.
1341        assert_eq!(groups.len(), 6);
1342        assert_eq!(subscriber.connection_handles(), before + groups.len());
1343
1344        drop(groups);
1345        assert_eq!(subscriber.connection_handles(), before);
1346    }
1347
1348    #[cfg(feature = "massive")]
1349    #[test]
1350    fn a_massive_subscription_without_a_subscriber_fails_the_call_before_any_group_connects() {
1351        let error = route(
1352            vec![
1353                vec![subscription(
1354                    ExchangeId::BinanceSpot,
1355                    MarketDataInstrumentKind::Spot,
1356                    SubKind::PublicTrades,
1357                )],
1358                vec![subscription(
1359                    ExchangeId::MassiveForex,
1360                    MarketDataInstrumentKind::Spot,
1361                    SubKind::OrderBooksL1,
1362                )],
1363            ],
1364            &DynamicSubscribers::default(),
1365        )
1366        .err()
1367        .unwrap();
1368
1369        assert_eq!(
1370            error,
1371            DataError::SubscriberRequired {
1372                exchange: ExchangeId::MassiveForex
1373            }
1374        );
1375    }
1376
1377    #[cfg(not(feature = "massive"))]
1378    #[test]
1379    fn a_massive_subscription_without_the_feature_names_the_feature() {
1380        let error = route(
1381            vec![vec![subscription(
1382                ExchangeId::MassiveCrypto,
1383                MarketDataInstrumentKind::Spot,
1384                SubKind::PublicTrades,
1385            )]],
1386            &DynamicSubscribers::default(),
1387        )
1388        .err()
1389        .unwrap();
1390
1391        assert_eq!(
1392            error,
1393            DataError::FeatureDisabled {
1394                exchange: ExchangeId::MassiveCrypto,
1395                feature: "massive".to_owned(),
1396            }
1397        );
1398    }
1399
1400    #[test]
1401    fn massive_pairs_outside_the_support_matrix_are_refused_before_routing() {
1402        let spot = MarketDataInstrumentKind::Spot;
1403        for (exchange, kind, sub_kind) in [
1404            (
1405                ExchangeId::MassiveForex,
1406                spot.clone(),
1407                SubKind::Candles {
1408                    interval: CandleInterval::Min5,
1409                },
1410            ),
1411            // Forex publishes no trades, and the engine's market data carries no quote.
1412            (
1413                ExchangeId::MassiveForex,
1414                spot.clone(),
1415                SubKind::PublicTrades,
1416            ),
1417            (ExchangeId::MassiveForex, spot.clone(), SubKind::Quotes),
1418            // Each cluster streams its own instrument kind only.
1419            (ExchangeId::MassiveOptions, spot, SubKind::PublicTrades),
1420            (ExchangeId::MassiveStocks, option(), SubKind::PublicTrades),
1421        ] {
1422            let error =
1423                validate_batches([[subscription(exchange, kind.clone(), sub_kind)]]).unwrap_err();
1424
1425            assert!(
1426                matches!(&error, DataError::Socket(message) if message.contains(exchange.as_str())),
1427                "{exchange} ({kind}, {sub_kind}): {error:?}"
1428            );
1429        }
1430    }
1431
1432    #[cfg(feature = "lse")]
1433    #[test]
1434    fn one_lse_batch_across_datasets_and_kinds_shares_one_subscriber() {
1435        let subscriber =
1436            LseSubscriber::new(crate::exchange::lse::live::LseCredentials::new("test-key"));
1437        let subscribers = DynamicSubscribers::default().with_lse(subscriber.clone());
1438        let before = subscriber.connection_handles();
1439
1440        let groups = route(
1441            vec![vec![
1442                subscription(
1443                    ExchangeId::LseCrypto,
1444                    MarketDataInstrumentKind::Spot,
1445                    SubKind::PublicTrades,
1446                ),
1447                subscription(
1448                    ExchangeId::LseCrypto,
1449                    MarketDataInstrumentKind::Spot,
1450                    SubKind::OrderBooksL1,
1451                ),
1452                subscription(
1453                    ExchangeId::LseEquities,
1454                    MarketDataInstrumentKind::Spot,
1455                    SubKind::PublicTrades,
1456                ),
1457                subscription(
1458                    ExchangeId::LseEquities,
1459                    MarketDataInstrumentKind::Spot,
1460                    SubKind::OrderBooksL1,
1461                ),
1462            ]],
1463            &subscribers,
1464        )
1465        .unwrap();
1466
1467        // One group per (dataset, kind), each holding a clone of the one subscriber: a group given
1468        // a subscriber of its own would open a second socket, which the provider refuses.
1469        assert_eq!(groups.len(), 4);
1470        assert_eq!(subscriber.connection_handles(), before + groups.len());
1471
1472        drop(groups);
1473        assert_eq!(subscriber.connection_handles(), before);
1474    }
1475
1476    #[cfg(feature = "lse")]
1477    #[test]
1478    fn an_lse_subscription_without_a_subscriber_fails_the_call_before_any_group_connects() {
1479        let error = route(
1480            vec![
1481                vec![subscription(
1482                    ExchangeId::BinanceSpot,
1483                    MarketDataInstrumentKind::Spot,
1484                    SubKind::PublicTrades,
1485                )],
1486                vec![subscription(
1487                    ExchangeId::LseFx,
1488                    MarketDataInstrumentKind::Spot,
1489                    SubKind::PublicTrades,
1490                )],
1491            ],
1492            &DynamicSubscribers::default(),
1493        )
1494        .err()
1495        .unwrap();
1496
1497        assert_eq!(
1498            error,
1499            DataError::SubscriberRequired {
1500                exchange: ExchangeId::LseFx
1501            }
1502        );
1503    }
1504
1505    #[cfg(not(feature = "lse"))]
1506    #[test]
1507    fn an_lse_subscription_without_the_feature_names_the_feature() {
1508        let error = route(
1509            vec![vec![subscription(
1510                ExchangeId::LseFx,
1511                MarketDataInstrumentKind::Spot,
1512                SubKind::PublicTrades,
1513            )]],
1514            &DynamicSubscribers::default(),
1515        )
1516        .err()
1517        .unwrap();
1518
1519        assert_eq!(
1520            error,
1521            DataError::FeatureDisabled {
1522                exchange: ExchangeId::LseFx,
1523                feature: "lse".to_owned(),
1524            }
1525        );
1526    }
1527
1528    #[tokio::test]
1529    async fn a_failed_group_stops_every_forwarder_that_started() {
1530        struct OnDrop(Option<tokio::sync::oneshot::Sender<()>>);
1531        impl Drop for OnDrop {
1532            fn drop(&mut self) {
1533                let _ = self.0.take().map(|tx| tx.send(()));
1534            }
1535        }
1536
1537        // Built outside the task, so it is dropped with it whether or not the task ever ran.
1538        let (dropped_tx, mut dropped) = tokio::sync::oneshot::channel();
1539        let guard = OnDrop(Some(dropped_tx));
1540        let forwarder = tokio::spawn(async move {
1541            let _guard = guard;
1542            std::future::pending::<()>().await;
1543        });
1544
1545        let error = settle(vec![
1546            Ok(forwarder),
1547            Err(DataError::SubscriberRequired {
1548                exchange: ExchangeId::LseFx,
1549            }),
1550        ])
1551        .await
1552        .unwrap_err();
1553
1554        assert_eq!(
1555            error,
1556            DataError::SubscriberRequired {
1557                exchange: ExchangeId::LseFx
1558            }
1559        );
1560        // The forwarder's task, and with it the stream it owned, was dropped before `settle`
1561        // returned: nothing further needs to run for the drop to have happened.
1562        assert_eq!(dropped.try_recv(), Ok(()));
1563    }
1564
1565    #[tokio::test]
1566    async fn forwarders_keep_running_when_every_group_initialised() {
1567        let forwarder = tokio::spawn(std::future::pending::<()>());
1568        let abort = forwarder.abort_handle();
1569
1570        settle(vec![Ok(forwarder)]).await.unwrap();
1571        tokio::task::yield_now().await;
1572
1573        assert!(!abort.is_finished());
1574        abort.abort();
1575    }
1576}