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