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}