Skip to main content

rustrade_data/streams/builder/dynamic/
indexed.rs

1use crate::{
2    error::DataError,
3    event::DataKind,
4    instrument::MarketInstrumentData,
5    streams::{
6        builder::dynamic::{DynamicStreams, DynamicSubscribers},
7        consumer::{MarketStreamEvent, MarketStreamResult},
8        reconnect::stream::ReconnectingStream,
9    },
10    subscription::{SubKind, Subscription},
11};
12use futures::Stream;
13use itertools::Itertools;
14use rustrade_instrument::{
15    Keyed,
16    exchange::ExchangeId,
17    index::{IndexedInstruments, error::IndexError},
18    instrument::{InstrumentIndex, market_data::MarketDataInstrument},
19};
20use tracing::warn;
21
22/// Initialise an indexed [`DynamicStreams`] using batches of indexed [`Subscription`] batches.
23///
24/// Equivalent to [`init_indexed_multi_exchange_market_stream_with`] with no
25/// [`DynamicSubscribers`]: an instrument on a venue that needs a subscriber from the caller fails
26/// with [`DataError::SubscriberRequired`].
27pub async fn init_indexed_multi_exchange_market_stream(
28    instruments: &IndexedInstruments,
29    sub_kinds: &[SubKind],
30) -> Result<impl Stream<Item = MarketStreamEvent<InstrumentIndex, DataKind>> + use<>, DataError> {
31    init_indexed_multi_exchange_market_stream_with(
32        &DynamicSubscribers::default(),
33        instruments,
34        sub_kinds,
35    )
36    .await
37}
38
39/// Initialise an indexed [`DynamicStreams`] using batches of indexed [`Subscription`] batches, and
40/// the subscribers for venues that cannot be served without one.
41///
42/// This function:
43/// 1. Generates indexed market data Subscriptions from all Instrument-SubKind combinations found
44///    in the provided `IndexedInstruments` and `SubKind` slice.
45/// 2. Initialise an indexed [`DynamicStreams`] via [`DynamicStreams::init_with`], which states how
46///    `subscribers` are shared and what fails the call.
47/// 3. Combines all market streams into a single `Stream` via
48///    [`select_all`](futures_util::stream::select_all::select_all)
49/// 4. Handles recoverable errors by logging them at `warn` level.
50///
51/// See [`generate_indexed_market_data_subscription_batches`] for how indexed `Subscriptions` can
52/// be conveniently generated from an [`IndexedInstruments`] collection.
53///
54/// See [`index_market_data_subscription_batches`] for how unindexed `Subscriptions` can be
55/// indexed using an [`IndexedInstruments`] collection.
56pub async fn init_indexed_multi_exchange_market_stream_with(
57    subscribers: &DynamicSubscribers,
58    instruments: &IndexedInstruments,
59    sub_kinds: &[SubKind],
60) -> Result<impl Stream<Item = MarketStreamEvent<InstrumentIndex, DataKind>> + use<>, DataError> {
61    // Generate indexed market data Subscriptions
62    let subscriptions = generate_indexed_market_data_subscription_batches(instruments, sub_kinds);
63
64    // Initialise an indexed MarketStream via DynamicStreams
65    let stream = DynamicStreams::init_with(subscribers, subscriptions)
66        .await?
67        .select_all::<MarketStreamResult<InstrumentIndex, DataKind>>()
68        .with_error_handler(|error| warn!(?error, "MarketStream generated error"));
69
70    Ok(stream)
71}
72
73/// Generates batches of indexed market data `Subscriptions` from a collection of
74/// `IndexedInstruments`.
75///
76/// This function:
77/// 1. Groups instruments by [`ExchangeId`].
78/// 2. Generates indexed `Subscriptions` for each Instrument-SubKind combination.
79/// 4. Returns the `Subscriptions` grouped by [`ExchangeId`].
80///
81/// # Arguments
82/// * `instruments` - Collection of `IndexedInstruments` to generate `Subscriptions` for
83/// * `sub_kinds` - Slice of `SubKinds` to generate for each instrument
84pub fn generate_indexed_market_data_subscription_batches(
85    instruments: &IndexedInstruments,
86    sub_kinds: &[SubKind],
87) -> Vec<Vec<Subscription<ExchangeId, MarketInstrumentData<InstrumentIndex>, SubKind>>> {
88    // Generate Iterator<Item = Keyed<ExchangeId, MarketInstrumentData<InstrumentIndex>>>
89    let instruments = instruments.instruments().iter().map(|keyed| {
90        // The DATA venue, not the execution venue: a subscription asks whoever publishes the
91        // prices, which for a dual-venue instrument is not who fills its orders. Falls back to
92        // `exchange` for every single-venue instrument, so this is the correct read for both.
93        let exchange = keyed.value.data_exchange().value;
94        let instrument = MarketInstrumentData::from(keyed);
95        Keyed::new(exchange, instrument)
96    });
97
98    // Chunk instruments by ExchangeId
99    let instruments = instruments.sorted_unstable_by_key(|exchange| exchange.key);
100
101    // Generate Subscriptions
102    instruments
103        .chunk_by(|exchange| exchange.key)
104        .into_iter()
105        .map(|(_exchange, instruments)| {
106            instruments
107                .into_iter()
108                .flat_map(
109                    |Keyed {
110                         key: exchange,
111                         value: instrument,
112                     }| {
113                        sub_kinds
114                            .iter()
115                            .map(move |kind| Subscription::new(exchange, instrument.clone(), *kind))
116                    },
117                )
118                .collect::<Vec<_>>()
119        })
120        .collect()
121}
122
123/// Indexes batches of market data `Subscriptions` using a collection of `IndexedInstruments`.
124///
125/// This function maps unindexed market data `Subscriptions` to indexed ones by:
126/// 1. Finding the `AssetIndex` for the base and quote assets.
127/// 2. Finding the `InstrumentIndex` associated with the `Subscription` `ExchangeId`, `SubKind` and
128///    assets.
129/// 3. Creating new `Subscriptions` with indexed instruments.
130///
131///
132/// # Arguments
133/// * `instruments` - Collection of `IndexedInstruments` used for indexing
134/// * `batches` - Iterator of `Subscription` batches to be indexed
135///
136/// # Limitation: this function cannot honour a [`DataVenue`]
137/// Both lookups below resolve against `Subscription::exchange` as the instrument's **execution**
138/// venue — its assets via `find_asset_index`, and the instrument itself via `exchange.value ==
139/// exchange`. An instrument carrying a [`DataVenue`] is priced by one venue and executed on another,
140/// and its assets are registered only under the latter, so **hand-written subscriptions do not
141/// support dual-venue instruments**. The two spellings fail differently, and only one of them fails
142/// loudly:
143///
144/// - Naming the **data venue** returns [`DataError`]: that venue has no registered assets, so
145///   `find_asset_index` cannot resolve the base or quote.
146/// - Naming the **execution venue** returns `Ok`. The `InstrumentIndex` is the right one for that
147///   instrument — a caller cannot get the *wrong* instrument out of this function — but the
148///   subscription it is attached to names the execution venue's feed and symbol, which is the venue
149///   the `DataVenue` exists to say the instrument is *not* priced on. Nothing here can detect that:
150///   the lookup is by `(exchange, kind, base, quote)` and every one of those matches.
151///
152/// Use [`generate_indexed_market_data_subscription_batches`] instead; it reads the data venue
153/// directly off each instrument and needs no name-based reverse lookup.
154///
155/// [`DataVenue`]: rustrade_instrument::instrument::data_venue::DataVenue
156pub fn index_market_data_subscription_batches<SubBatchIter, SubIter, Sub>(
157    instruments: &IndexedInstruments,
158    batches: SubBatchIter,
159) -> Result<
160    Vec<Vec<Subscription<ExchangeId, Keyed<InstrumentIndex, MarketDataInstrument>>>>,
161    DataError,
162>
163where
164    SubBatchIter: IntoIterator<Item = SubIter>,
165    SubIter: IntoIterator<Item = Sub>,
166    Sub: Into<Subscription<ExchangeId, MarketDataInstrument, SubKind>>,
167{
168    batches
169        .into_iter()
170        .map(|batch| batch
171            .into_iter()
172            .map(|sub| {
173                let sub = sub.into();
174
175                let base_index = instruments.find_asset_index(sub.exchange, &sub.instrument.base)?;
176                let quote_index = instruments.find_asset_index(sub.exchange, &sub.instrument.quote)?;
177
178                let find_instrument = |exchange, kind, base, quote| {
179                    instruments
180                        .instruments()
181                        .iter()
182                        .find_map(|indexed| {
183                            (
184                                indexed.value.exchange.value == exchange
185                                    && indexed.value.kind.eq_market_data_instrument_kind(kind)
186                                    && indexed.value.underlying.base == base
187                                    && indexed.value.underlying.quote == quote
188                            ).then_some(indexed.key)
189                        })
190                        .ok_or(IndexError::InstrumentIndex(format!(
191                            "Instrument: ({}, {}, {}, {}) must be present in indexed instruments: {:?}",
192                            exchange, kind, base, quote, instruments.instruments()
193                        )))
194                };
195
196                let instrument_index = find_instrument(sub.exchange, &sub.instrument.kind, base_index, quote_index)?;
197
198                Ok(Subscription {
199                    exchange: sub.exchange,
200                    instrument: Keyed::new(instrument_index, sub.instrument),
201                    kind: sub.kind,
202                })
203            })
204            .collect()
205        )
206        .collect()
207}
208
209#[cfg(test)]
210#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
211mod tests {
212    use super::*;
213    use rustrade_instrument::{
214        Underlying,
215        asset::Asset,
216        index::error::IndexError,
217        instrument::{
218            Instrument, data_venue::DataVenue, market_data::kind::MarketDataInstrumentKind,
219            name::InstrumentNameExchange,
220        },
221    };
222
223    /// `BP` executed on IBKR, priced by LSE — where the two venues disagree on the symbol.
224    fn bp_priced_by_lse() -> Instrument<ExchangeId, Asset> {
225        Instrument::spot(
226            ExchangeId::Ibkr,
227            "ibkr-bp",
228            "BP",
229            Underlying::new(
230                Asset::new_from_exchange("bp"),
231                Asset::new_from_exchange("usd"),
232            ),
233            None,
234        )
235        .with_data_venue(DataVenue::new(
236            ExchangeId::LseEquities,
237            Some(InstrumentNameExchange::new("BP.L")),
238        ))
239    }
240
241    #[test]
242    fn a_subscription_is_built_against_the_data_venue_and_its_symbol() {
243        // Both halves matter, and both fail silently if wrong: subscribing on IBKR asks a venue
244        // that was never meant to supply prices here, and subscribing as `BP` asks LSE for a
245        // symbol it does not publish. Neither errors -- they just never tick.
246        let instruments = IndexedInstruments::new([bp_priced_by_lse()]);
247
248        let batches = generate_indexed_market_data_subscription_batches(
249            &instruments,
250            &[SubKind::PublicTrades],
251        );
252
253        let subscriptions = batches.into_iter().flatten().collect::<Vec<_>>();
254        assert_eq!(subscriptions.len(), 1);
255
256        assert_eq!(subscriptions[0].exchange, ExchangeId::LseEquities);
257        assert_eq!(
258            subscriptions[0].instrument.name_exchange,
259            InstrumentNameExchange::new("BP.L")
260        );
261    }
262
263    #[test]
264    fn a_single_venue_subscription_is_unchanged_by_the_data_venue_fallback() {
265        // The control: every instrument in the tree today has no data venue, so the fallback must
266        // reproduce exactly the previous behaviour.
267        let instrument = Instrument::spot(
268            ExchangeId::BinanceSpot,
269            "binance_spot-btc_usdt",
270            "BTCUSDT",
271            Underlying::new(
272                Asset::new_from_exchange("btc"),
273                Asset::new_from_exchange("usdt"),
274            ),
275            None,
276        );
277        let instruments = IndexedInstruments::new([instrument]);
278
279        let batches = generate_indexed_market_data_subscription_batches(
280            &instruments,
281            &[SubKind::PublicTrades],
282        );
283
284        let subscriptions = batches.into_iter().flatten().collect::<Vec<_>>();
285        assert_eq!(subscriptions.len(), 1);
286
287        assert_eq!(subscriptions[0].exchange, ExchangeId::BinanceSpot);
288        assert_eq!(
289            subscriptions[0].instrument.name_exchange,
290            InstrumentNameExchange::new("BTCUSDT")
291        );
292    }
293
294    #[test]
295    fn instruments_are_batched_by_data_venue_not_execution_venue() {
296        // Batches are per-venue connections. Two instruments executed on different venues but
297        // priced by the same one belong on ONE connection -- batching by execution venue would
298        // open two connections to LSE and, on a capped feed, burn two subscription slots.
299        let aapl = Instrument::spot(
300            ExchangeId::AlpacaBroker,
301            "alpaca_broker-aapl",
302            "AAPL",
303            Underlying::new(
304                Asset::new_from_exchange("aapl"),
305                Asset::new_from_exchange("usd"),
306            ),
307            None,
308        )
309        .with_data_venue(DataVenue::new_same_name(ExchangeId::LseEquities));
310
311        let instruments = IndexedInstruments::new([aapl, bp_priced_by_lse()]);
312
313        let batches = generate_indexed_market_data_subscription_batches(
314            &instruments,
315            &[SubKind::PublicTrades],
316        );
317
318        assert_eq!(batches.len(), 1, "{batches:?}");
319        assert_eq!(batches[0].len(), 2);
320        assert!(
321            batches[0]
322                .iter()
323                .all(|sub| sub.exchange == ExchangeId::LseEquities),
324            "{:?}",
325            batches[0]
326        );
327    }
328
329    #[test]
330    fn hand_written_subscription_naming_the_data_venue_is_rejected() {
331        // Half the documented limitation -- the half that fails loudly. Assets are registered under
332        // the execution venue only, so LSE resolves neither leg. The value here is the *shape* of
333        // the failure: a caller must not be able to receive an `InstrumentIndex` that resolves to an
334        // instrument priced by a venue other than the one they subscribed to, because every
335        // downstream event would then be attributed to the wrong instrument's state.
336        let instruments = IndexedInstruments::new([bp_priced_by_lse()]);
337
338        let subscription = Subscription::new(
339            ExchangeId::LseEquities,
340            MarketDataInstrument::new("bp", "usd", MarketDataInstrumentKind::Spot),
341            SubKind::PublicTrades,
342        );
343
344        let result = index_market_data_subscription_batches(&instruments, [[subscription]]);
345
346        // The *variant* is the claim, not merely that it failed. Were assets later registered under
347        // the data venue, the instrument lookup would fail instead and a bare `is_err` would keep
348        // passing with the rationale above silently false.
349        assert!(
350            matches!(result, Err(DataError::Index(IndexError::AssetIndex(_)))),
351            "the data venue registers no assets, so neither leg can resolve: {result:?}"
352        );
353    }
354
355    /// KNOWN GAP, pinned so that fixing it is a visible test change rather than a silent one.
356    ///
357    /// The other half of the limitation, and the half the rustdoc has to spell out because it does
358    /// *not* fail loudly: naming the execution venue matches on every component of the lookup key,
359    /// so the caller gets `Ok` and a subscription pointed at the venue the `DataVenue` exists to say
360    /// this instrument is not priced on. Rework this function to understand data venues and this
361    /// assertion should be inverted -- the caller cannot express "IBKR's feed for an LSE-priced
362    /// instrument" meaningfully, so `Err` is the defensible answer once the lookup can tell.
363    #[test]
364    fn hand_written_subscription_naming_the_execution_venue_succeeds_on_the_wrong_feed() {
365        let instruments = IndexedInstruments::new([bp_priced_by_lse()]);
366
367        let subscription = Subscription::new(
368            ExchangeId::Ibkr,
369            MarketDataInstrument::new("bp", "usd", MarketDataInstrumentKind::Spot),
370            SubKind::PublicTrades,
371        );
372
373        let batches = index_market_data_subscription_batches(&instruments, [[subscription]])
374            .expect(
375                "naming the execution venue resolves: its assets and instrument are registered",
376            );
377
378        let indexed = &batches[0][0];
379
380        // The instrument is the right one -- the documented guarantee that survives.
381        assert_eq!(indexed.instrument.key, InstrumentIndex(0));
382
383        // The feed is not. This is the wrong-feed subscription the rustdoc warns about.
384        assert_eq!(indexed.exchange, ExchangeId::Ibkr);
385        assert_ne!(
386            indexed.exchange,
387            instruments.instruments()[0].value.data_exchange().value,
388            "the subscription names a venue the instrument is explicitly not priced on"
389        );
390    }
391}