Skip to main content

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}