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}