rustrade_data/exchange/lse/backtest.rs
1use crate::{
2 error::DataError,
3 event::DataKind,
4 exchange::lse::{error::LseError, market, vault::LseVaultClient},
5 streams::{
6 consumer::MarketStreamEvent,
7 merge::{merge_time_sorted, tag_events},
8 },
9 subscription::candle::{Candle, CandleInterval},
10};
11use async_stream::try_stream;
12use chrono::{DateTime, Utc};
13use futures::{Stream, StreamExt};
14use rustrade_instrument::{
15 exchange::ExchangeId, index::IndexedInstruments, instrument::InstrumentIndex,
16};
17use smol_str::SmolStr;
18use std::sync::Arc;
19
20/// One instrument's candle source: which vault symbol to fetch, and what to tag it as.
21///
22/// # Prefer [`resolve`](Self::resolve)
23/// It derives both keys from the registry the engine was built with and checks the quote asset,
24/// which is the only place a `.L` listing registered in `gbp` rather than `gbx` — pence booked as
25/// pounds, every notional 100× wrong — is visible. [`new`](Self::new) takes the caller's word for
26/// both and carries no protection; it exists for callers who have no registry to check against.
27///
28/// # The keys must match the instruments the engine was built with
29/// With [`new`](Self::new), `instrument` and `exchange` are **not** derived from the symbol,
30/// because only the caller knows how they registered the instrument. Both must match that
31/// registration:
32///
33/// - `instrument` is the [`InstrumentIndex`] the instrument received in `IndexedInstruments`.
34/// Positions and unrealised PnL are strictly index-scoped, so a wrong index attributes this
35/// symbol's prices to a different instrument — silently.
36/// - `exchange` must be the [`ExchangeId`] the instrument is priced on: its `DataVenue`'s exchange
37/// if it declares one, otherwise the exchange it was registered under. Either is one the engine
38/// knows, and engine state panics on a market event from an exchange it does not.
39/// [`LseDataset::exchange_id`] gives the variant a given dataset belongs to.
40///
41/// [`LseDataset::exchange_id`]: super::market::LseDataset::exchange_id
42#[derive(Debug, Clone, Eq, PartialEq)]
43pub struct LseCandleSource {
44 /// Vault **display symbol** — `EUR/USD`, `AAPL`, `BP.L`, `ES.F`. Not a dataset slug.
45 pub symbol: SmolStr,
46 /// The engine-side key for this instrument.
47 pub instrument: InstrumentIndex,
48 /// The exchange this instrument is priced on: its `DataVenue`'s exchange if it declares one,
49 /// otherwise the exchange it is registered under.
50 pub exchange: ExchangeId,
51}
52
53impl LseCandleSource {
54 /// Create a candle source for one instrument, taking both engine-side keys on trust.
55 ///
56 /// Prefer [`resolve`](Self::resolve) whenever the registry is in hand: it derives `instrument`
57 /// from that registry and rejects a quote-asset disagreement, neither of which this can do.
58 /// This exists for callers assembling sources without an `IndexedInstruments` — and it means a
59 /// fabricated index, or a `.L` symbol registered in pounds, reaches the engine unremarked.
60 pub fn new(
61 symbol: impl Into<SmolStr>,
62 instrument: InstrumentIndex,
63 exchange: ExchangeId,
64 ) -> Self {
65 Self {
66 symbol: symbol.into(),
67 instrument,
68 exchange,
69 }
70 }
71
72 /// Create a candle source by resolving the symbol against the engine's instrument registry.
73 ///
74 /// The checked constructor, and the one to reach for: `instrument` is derived from
75 /// `instruments` rather than supplied, so a fabricated or typo'd index is unrepresentable, and
76 /// the symbol's quote asset is verified against the registration. That verification is the
77 /// candle path's only defence against a London listing registered in `gbp` rather than `gbx`,
78 /// which books pence as pounds and inflates every notional, fee, PnL and balance by 100× with
79 /// nothing downstream able to notice. See
80 /// [`market::instrument_index_for`].
81 ///
82 /// The symbol is matched on the data side, so an instrument executed on one venue and priced
83 /// here through a `DataVenue` resolves as well as one registered on `exchange` directly.
84 ///
85 /// # Errors
86 /// - [`LseError::UnknownInstrument`] if no instrument is priced on `exchange` under `symbol`.
87 /// - [`LseError::AmbiguousInstrument`] if more than one is.
88 /// - [`LseError::QuoteAssetMismatch`] if one is, but prices it in a different asset than the
89 /// provider quotes that symbol in.
90 pub fn resolve(
91 instruments: &IndexedInstruments,
92 exchange: ExchangeId,
93 symbol: impl Into<SmolStr>,
94 ) -> Result<Self, LseError> {
95 let symbol = symbol.into();
96 let instrument = market::instrument_index_for(instruments, exchange, &symbol)?;
97
98 Ok(Self {
99 symbol,
100 instrument,
101 exchange,
102 })
103 }
104}
105
106/// Replay historical candles for N instruments as ONE time-ordered market stream.
107///
108/// This is the bridge between the per-symbol vault fetch and a consumer that wants a single feed —
109/// a backtest harness above all, which exposes exactly one stream. Each source is fetched
110/// independently, tagged with its own [`InstrumentIndex`], and k-way merged on `time_exchange`.
111///
112/// Nothing is buffered beyond one event per source, so memory is O(N) in the number of instruments
113/// and O(1) in the length of the range. The whole thing is lazy: no request is issued until the
114/// stream is polled.
115///
116/// # `time_exchange` is the candle's `close_time`
117/// A bar enters the timeline when its period **ends**. Stamping the open would let a strategy act
118/// on a completed bar at the instant its period began — lookahead, silently. The vault reports only
119/// the open instant; `close_time` is derived library-side by
120/// [`fetch_candles`](LseVaultClient::fetch_candles).
121///
122/// # Ordering
123/// Each source is ascending by `close_time`, which is what the merge requires. Equal timestamps
124/// across instruments resolve to the earlier entry in `sources`, so a given `sources` ordering
125/// replays identically every time.
126///
127/// That per-source ordering is the vault's observed behaviour rather than something
128/// [`fetch_candles`](LseVaultClient::fetch_candles) enforces: it filters each page to the requested
129/// range but never reorders one. A provider that answered a page out of order would therefore
130/// breach [`merge_time_sorted`]'s caller obligation and put a non-monotonic clock in front of a
131/// backtest — silently, because a merge cannot recover ordering its inputs do not have.
132///
133/// # Cost, and how the provider's allowance is respected
134/// One paged fetch per source. The merge polls every source on every `poll_next` and cannot emit
135/// until all have buffered, so N fetches making progress at once is guaranteed rather than
136/// incidental — and re-running the replay re-fetches everything. For repeated runs over the same
137/// range, fetch once to local storage and replay from that.
138///
139/// The provider's allowance (`calls_per_minute`, `vault_concurrency`, both reported by
140/// [`usage`](LseVaultClient::usage)) is **not** multiplied by N here: every fetch shares `client`,
141/// and therefore its
142/// [request gate](LseVaultClient#request-rationing-is-per-client-not-per-call) — requests in flight
143/// stay under [`with_concurrency`](LseVaultClient::with_concurrency) and their starts stay
144/// [`with_pace`](LseVaultClient::with_pace) apart no matter how many sources are passed. A large N
145/// therefore makes the replay *slower*, not louder. Pass a client built by `new`, not one per
146/// source: two independently-built clients ration independently.
147///
148/// # Errors
149/// A failed fetch on any source is forwarded immediately, ahead of buffered events from the others:
150/// once one instrument's series is incomplete, the merged replay is no longer the dataset that was
151/// asked for. [`LseError`] is flattened into
152/// [`DataError::Lse`] — handle `LseError` at the [`fetch_candles`](LseVaultClient::fetch_candles)
153/// level if you need to match on the cause.
154///
155/// # ⚠️ Licensing
156/// The data this replays is **not redistributable**. See the [module docs](super).
157pub fn replay_candles(
158 client: Arc<LseVaultClient>,
159 sources: Vec<LseCandleSource>,
160 interval: CandleInterval,
161 start: DateTime<Utc>,
162 end: DateTime<Utc>,
163) -> impl Stream<Item = Result<MarketStreamEvent<InstrumentIndex, DataKind>, DataError>> + Send + 'static
164{
165 merge_time_sorted(sources.into_iter().map(move |source| {
166 tag_events(
167 owned_candles(
168 Arc::clone(&client),
169 source.symbol.clone(),
170 interval,
171 start,
172 end,
173 ),
174 source.exchange,
175 source.instrument,
176 |candle: &Candle| candle.close_time,
177 DataKind::Candle,
178 )
179 }))
180}
181
182/// Drive [`fetch_candles`](LseVaultClient::fetch_candles) from an owned client and symbol.
183///
184/// `fetch_candles` borrows both, producing a stream tied to their lifetimes. Consumers such as
185/// `BacktestMarketData` require a `'static` stream, so the borrow is moved inside the generator
186/// here rather than pushed onto the caller.
187fn owned_candles(
188 client: Arc<LseVaultClient>,
189 symbol: SmolStr,
190 interval: CandleInterval,
191 start: DateTime<Utc>,
192 end: DateTime<Utc>,
193) -> impl Stream<Item = Result<Candle, DataError>> + Send + 'static {
194 try_stream! {
195 let candles = client.fetch_candles(&symbol, interval, start, end);
196 futures::pin_mut!(candles);
197
198 while let Some(candle) = candles.next().await {
199 yield candle.map_err(DataError::from)?;
200 }
201 }
202}
203
204#[cfg(test)]
205#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panicking on a bad fixture is acceptable
206mod tests {
207 use super::*;
208 use crate::event::MarketEvent;
209 use std::time::Duration;
210
211 /// `(instrument index, time_exchange)` of each replayed candle.
212 fn observed(
213 events: &[Result<MarketStreamEvent<InstrumentIndex, DataKind>, DataError>],
214 ) -> Vec<(usize, DateTime<Utc>)> {
215 events
216 .iter()
217 .map(|event| match event {
218 Ok(MarketStreamEvent::Item(MarketEvent {
219 time_exchange,
220 instrument,
221 kind: DataKind::Candle(_),
222 ..
223 })) => (instrument.index(), *time_exchange),
224 other => panic!("expected a candle Item, got {other:?}"),
225 })
226 .collect()
227 }
228
229 /// `count` one-minute candle rows on 2024-01-02, opening at `first_minute` and stepping by
230 /// `step` minutes.
231 fn rows(count: i64, first_minute: i64, step: i64) -> String {
232 let rows: Vec<String> = (0..count)
233 .map(|index| {
234 let minute = first_minute + index * step;
235 format!(
236 r#"{{"ts":"2024-01-02 {:02}:{:02}:00","open":1.0,"high":1.0,"low":1.0,"close":1.0,"volume":1}}"#,
237 minute / 60,
238 minute % 60
239 )
240 })
241 .collect();
242
243 format!("[{}]", rows.join(","))
244 }
245
246 /// Two symbols on interleaved minutes must come back strictly time-ordered and correctly
247 /// attributed — the property a multi-instrument backtest depends on.
248 #[tokio::test]
249 async fn replay_merges_two_symbols_into_one_time_ordered_stream() {
250 let server = wiremock::MockServer::start().await;
251
252 // AAPL opens on even minutes, MSFT on odd, so a correct merge must alternate. Each symbol
253 // serves one page; the fallback below then ends its pagination.
254 for (symbol, first_minute) in [("AAPL", 0), ("MSFT", 1)] {
255 wiremock::Mock::given(wiremock::matchers::method("GET"))
256 .and(wiremock::matchers::path("/vault/candles"))
257 .and(wiremock::matchers::query_param("symbol", symbol))
258 .respond_with(
259 wiremock::ResponseTemplate::new(200)
260 .set_body_raw(rows(2, first_minute, 2), "application/json"),
261 )
262 .up_to_n_times(1)
263 .mount(&server)
264 .await;
265 }
266
267 // A short page is not a terminal signal (the row cap is applied silently), so pagination
268 // continues until an empty page arrives.
269 wiremock::Mock::given(wiremock::matchers::method("GET"))
270 .respond_with(
271 wiremock::ResponseTemplate::new(200).set_body_raw("[]", "application/json"),
272 )
273 .mount(&server)
274 .await;
275
276 let client = Arc::new(
277 LseVaultClient::new("key")
278 .unwrap()
279 .with_base_url(format!("{}/vault", server.uri()))
280 // Pacing is exercised in `vault`'s own tests; disabling it here keeps this suite
281 // from sleeping the default interval between every page of every source.
282 .with_pace(Duration::ZERO),
283 );
284
285 let events = replay_candles(
286 client,
287 vec![
288 LseCandleSource::new("AAPL", InstrumentIndex::new(0), ExchangeId::LseEquities),
289 LseCandleSource::new("MSFT", InstrumentIndex::new(1), ExchangeId::LseEquities),
290 ],
291 CandleInterval::Min1,
292 "2024-01-01T00:00:00Z".parse().unwrap(),
293 "2024-01-03T00:00:00Z".parse().unwrap(),
294 )
295 .collect::<Vec<_>>()
296 .await;
297
298 let observed = observed(&events);
299 // Strictly ascending, and alternating between the two instruments.
300 assert!(
301 observed.windows(2).all(|pair| pair[0].1 <= pair[1].1),
302 "merged replay must be time-ordered: {observed:?}"
303 );
304 assert_eq!(
305 observed.iter().map(|(index, _)| *index).collect::<Vec<_>>(),
306 vec![0, 1, 0, 1]
307 );
308 }
309
310 #[tokio::test]
311 async fn replay_of_no_sources_is_empty() {
312 let client = Arc::new(LseVaultClient::new("key").unwrap());
313
314 let events = replay_candles(
315 client,
316 vec![],
317 CandleInterval::Min1,
318 "2024-01-01T00:00:00Z".parse().unwrap(),
319 "2024-01-03T00:00:00Z".parse().unwrap(),
320 )
321 .collect::<Vec<_>>()
322 .await;
323
324 assert!(events.is_empty());
325 }
326
327 /// A failed source must surface rather than silently shortening the replay.
328 #[tokio::test]
329 async fn replay_surfaces_a_source_failure() {
330 let server = wiremock::MockServer::start().await;
331 wiremock::Mock::given(wiremock::matchers::method("GET"))
332 .respond_with(
333 wiremock::ResponseTemplate::new(500)
334 .set_body_raw(r#"{"detail":"upstream unavailable"}"#, "application/json"),
335 )
336 .mount(&server)
337 .await;
338
339 let client = Arc::new(
340 LseVaultClient::new("key")
341 .unwrap()
342 .with_base_url(format!("{}/vault", server.uri()))
343 // Pacing is exercised in `vault`'s own tests; disabling it here keeps this suite
344 // from sleeping the default interval between every page of every source.
345 .with_pace(Duration::ZERO),
346 );
347
348 let events = replay_candles(
349 client,
350 vec![LseCandleSource::new(
351 "AAPL",
352 InstrumentIndex::new(0),
353 ExchangeId::LseEquities,
354 )],
355 CandleInterval::Min1,
356 "2024-01-01T00:00:00Z".parse().unwrap(),
357 "2024-01-03T00:00:00Z".parse().unwrap(),
358 )
359 .collect::<Vec<_>>()
360 .await;
361
362 assert!(matches!(events.first(), Some(Err(DataError::Lse { .. }))));
363 }
364
365 /// The candle path's half of the GBX protection.
366 ///
367 /// `new` takes both keys on trust, so before `resolve` existed nothing on this path could see a
368 /// London listing registered in pounds — the export path's check was unreachable from here, it
369 /// being behind `lse-parquet`. These assert the checked constructor closes that.
370 #[test]
371 fn resolve_derives_the_index_and_rejects_a_london_listing_registered_in_pounds() {
372 use rustrade_instrument::Underlying;
373 use rustrade_instrument::index::builder::IndexedInstrumentsBuilder;
374 use rustrade_instrument::instrument::name::{
375 InstrumentNameExchange, InstrumentNameInternal,
376 };
377 use rustrade_instrument::instrument::quote::InstrumentQuoteAsset;
378 use rustrade_instrument::instrument::{Instrument, kind::InstrumentKind};
379 use rustrade_instrument::test_utils::asset;
380
381 let register = |symbol: &str, quote: &str| {
382 let name = InstrumentNameExchange::from(symbol);
383 IndexedInstrumentsBuilder::default()
384 .add_instrument(Instrument::new(
385 ExchangeId::LseEquities,
386 InstrumentNameInternal::new_from_exchange(
387 ExchangeId::LseEquities,
388 name.clone(),
389 ),
390 name,
391 Underlying::new(asset(&symbol.to_lowercase()), asset(quote)),
392 InstrumentQuoteAsset::UnderlyingQuote,
393 InstrumentKind::Spot,
394 None,
395 ))
396 .build()
397 };
398
399 // `BP.L` prints ~548 where BP trades around £5.48, and the provider sends no unit -- so the
400 // registered asset IS the unit, and booking that as GBP inflates every notional, fee, PnL
401 // and balance by 100x with nothing downstream able to tell.
402 let instruments = register("BP.L", "gbp");
403 let error = LseCandleSource::resolve(&instruments, ExchangeId::LseEquities, "BP.L")
404 .expect_err("a pence-quoted listing registered in pounds must not build a source");
405 assert!(matches!(error, LseError::QuoteAssetMismatch { .. }));
406
407 // The correct registration builds, and carries the index the registry issued rather than
408 // one the caller supplied.
409 let instruments = register("BP.L", "gbx");
410 let source =
411 LseCandleSource::resolve(&instruments, ExchangeId::LseEquities, "BP.L").unwrap();
412 assert_eq!(source.instrument, InstrumentIndex::new(0));
413 assert_eq!(source.symbol, "BP.L");
414 assert_eq!(source.exchange, ExchangeId::LseEquities);
415
416 // The typo that would otherwise leave one instrument silently receiving nothing.
417 let error = LseCandleSource::resolve(&instruments, ExchangeId::LseEquities, "BP")
418 .expect_err("an unregistered symbol must not build a source");
419 assert!(matches!(error, LseError::UnknownInstrument { .. }));
420 assert!(error.to_string().contains("BP.L"));
421 }
422
423 /// An instrument executed on one venue and priced on LSE through a `DataVenue` resolves by its
424 /// data-side symbol, which is the configuration `DataVenue` exists for. Two instruments priced
425 /// under one LSE symbol are refused, since a source feeds one.
426 #[test]
427 fn resolve_matches_the_data_venue_and_rejects_a_symbol_pricing_two_instruments() {
428 use rustrade_instrument::Underlying;
429 use rustrade_instrument::index::builder::IndexedInstrumentsBuilder;
430 use rustrade_instrument::instrument::Instrument;
431 use rustrade_instrument::instrument::data_venue::DataVenue;
432 use rustrade_instrument::instrument::name::InstrumentNameExchange;
433
434 let priced_on_lse = |exchange, name_internal: &str| {
435 Instrument::spot(
436 exchange,
437 name_internal,
438 "BP",
439 Underlying::new("bp", "gbx"),
440 None,
441 )
442 .with_data_venue(DataVenue::new(
443 ExchangeId::LseEquities,
444 Some(InstrumentNameExchange::new("BP.L")),
445 ))
446 };
447
448 let instruments = IndexedInstrumentsBuilder::default()
449 .add_instrument(priced_on_lse(ExchangeId::Ibkr, "ibkr-bp"))
450 .build();
451 let source =
452 LseCandleSource::resolve(&instruments, ExchangeId::LseEquities, "BP.L").unwrap();
453 assert_eq!(
454 instruments.instruments()[source.instrument.index()]
455 .value
456 .name_internal
457 .as_ref(),
458 "ibkr-bp"
459 );
460 assert_eq!(source.exchange, ExchangeId::LseEquities);
461
462 // The execution-side symbol is not what LSE publishes, so it does not resolve.
463 let error = LseCandleSource::resolve(&instruments, ExchangeId::LseEquities, "BP")
464 .expect_err("the execution-side symbol is not priced on LSE");
465 assert!(matches!(error, LseError::UnknownInstrument { .. }));
466
467 // The ambiguity spans both ways of being priced on LSE: one registered there directly,
468 // with no `DataVenue`, and one priced there through its `DataVenue`.
469 let registered_on_lse = Instrument::spot(
470 ExchangeId::LseEquities,
471 "lse-bp",
472 "BP.L",
473 Underlying::new("bp", "gbx"),
474 None,
475 );
476 let instruments = IndexedInstrumentsBuilder::default()
477 .add_instrument(priced_on_lse(ExchangeId::Ibkr, "ibkr-bp"))
478 .add_instrument(registered_on_lse)
479 .build();
480 let error = LseCandleSource::resolve(&instruments, ExchangeId::LseEquities, "BP.L")
481 .expect_err("one symbol pricing two instruments must not build a source");
482 assert!(
483 matches!(error, LseError::AmbiguousInstrument { .. }),
484 "{error:?}"
485 );
486
487 let instruments = IndexedInstrumentsBuilder::default()
488 .add_instrument(priced_on_lse(ExchangeId::Ibkr, "ibkr-bp"))
489 .add_instrument(priced_on_lse(ExchangeId::AlpacaBroker, "alpaca-bp"))
490 .build();
491 let error = LseCandleSource::resolve(&instruments, ExchangeId::LseEquities, "BP.L")
492 .expect_err("one symbol pricing two instruments must not build a source");
493 let LseError::AmbiguousInstrument { instruments, .. } = &error else {
494 panic!("unexpected error: {error:?}");
495 };
496 assert!(instruments.contains("ibkr-bp"), "{instruments}");
497 assert!(instruments.contains("alpaca-bp"), "{instruments}");
498 }
499}