Skip to main content

rustrade_data/streams/
merge.rs

1use crate::{event::MarketEvent, streams::consumer::MarketStreamEvent};
2use chrono::{DateTime, Utc};
3use futures::{
4    Stream, StreamExt,
5    stream::{FusedStream, Peekable},
6};
7use rustrade_instrument::exchange::ExchangeId;
8use std::{
9    pin::Pin,
10    task::{Context, Poll},
11};
12
13/// Lazily merge N time-sorted market streams into one time-sorted stream.
14///
15/// Historical market data usually arrives one instrument (or one file) at a time, while consumers —
16/// a backtest harness above all — need a single feed in simulated-time order. This performs that
17/// k-way merge **lazily**: at most one buffered event per input is held, so memory is O(N) in the
18/// number of inputs and O(1) in the size of the dataset. It is what lets a multi-instrument
19/// backtest run over sources far larger than memory.
20///
21/// # Caller obligations
22/// - **Every input MUST be sorted ascending by `time_exchange`.** A merge cannot recover ordering
23///   its inputs do not have; an unsorted input yields an unsorted output, which in a backtest means
24///   a non-monotonic clock and wrong results with no failure point. Violations trip a
25///   `debug_assert!` naming the offending input — so tests fail loudly — but the check is compiled
26///   out of release builds, where this stays an obligation rather than a guarantee.
27/// - Inputs should already be tagged with the right instrument key. Resolving a source's key is the
28///   producer's job, not the merge's.
29///
30/// # Ordering
31/// The earliest `time_exchange` across all inputs wins. Ties are broken by **input order** — the
32/// event from the earliest-listed stream is emitted first — which makes the merge deterministic for
33/// a given input ordering, a property backtests depend on for reproducibility.
34///
35/// [`MarketStreamEvent::Reconnecting`] carries no timestamp. It is emitted as soon as it is seen,
36/// ahead of any buffered `Item`, since it reports a transport condition at the point it occurred
37/// rather than an event at a simulated instant.
38///
39/// # Pace
40/// Nothing can be emitted until **every** input has either buffered an event or ended — an input
41/// yet to produce might be about to yield something earlier, and an out-of-order output is
42/// undetectable downstream. So the merge advances at the pace of its slowest input. Inputs still
43/// make progress concurrently; it is only the emission that waits.
44///
45/// # ⚠️ Inputs must be able to progress independently
46/// The corollary of the pace rule is that **an input which cannot make progress stalls the whole
47/// merge, permanently and silently**. The inputs are also collected eagerly here, so every one of
48/// them starts as soon as this function is called rather than when it is first polled.
49///
50/// The reachable case is
51/// [`stream_blocking_iter`](super::blocking::stream_blocking_iter), which takes a blocking-pool
52/// thread per input at construction and holds it for the whole decode. Tokio's pool defaults to
53/// `max_blocking_threads: 512`, so beyond 512 inputs the surplus decoders never get a thread, stay
54/// `Pending`, and hold the merge `Pending` — which stops the running decodes being drained, so none
55/// of them releases a thread either. Nothing errors and nothing logs. See that function's
56/// `# ⚠️ One blocking thread per call` section for the ways out.
57///
58/// # Errors
59/// The item type is a `Result` so that fallible sources (file decoders, paginated fetches) compose
60/// without collecting. An `Err` on any input is forwarded immediately, ahead of buffered events:
61/// once a source has failed, the merged stream can no longer be complete, so the consumer should
62/// see the failure before acting on more data. The merge does not end itself — that policy belongs
63/// to the consumer.
64pub fn merge_time_sorted<St, InstrumentKey, Kind, Error>(
65    streams: impl IntoIterator<Item = St>,
66) -> TimeSortedMerge<St, InstrumentKey, Kind, Error>
67where
68    St: Stream<Item = Result<MarketStreamEvent<InstrumentKey, Kind>, Error>>,
69{
70    let inputs = streams
71        .into_iter()
72        .map(|stream| Box::pin(stream.peekable()))
73        .collect::<Vec<_>>();
74
75    TimeSortedMerge {
76        #[cfg(debug_assertions)]
77        last_peeked: vec![None; inputs.len()],
78        inputs,
79    }
80}
81
82/// A lazy, time-ordered k-way merge of market streams. See [`merge_time_sorted`].
83#[derive(Debug)]
84pub struct TimeSortedMerge<St, InstrumentKey, Kind, Error>
85where
86    St: Stream<Item = Result<MarketStreamEvent<InstrumentKey, Kind>, Error>>,
87{
88    inputs: Vec<Pin<Box<Peekable<St>>>>,
89    /// Last `time_exchange` peeked from each input, for the debug-only sortedness check.
90    ///
91    /// *Enforcing* the sort obligation would need buffering, which defeats the O(1) memory the
92    /// merge exists for. *Detecting* a violation needs only the previous timestamp per input, which
93    /// is O(N) in the input count and costs a release build nothing — the field does not exist
94    /// there. So the obligation stays a caller obligation in production and becomes a loud failure
95    /// in every test that runs this code.
96    #[cfg(debug_assertions)]
97    last_peeked: Vec<Option<DateTime<Utc>>>,
98}
99
100impl<St, InstrumentKey, Kind, Error> Stream for TimeSortedMerge<St, InstrumentKey, Kind, Error>
101where
102    St: Stream<Item = Result<MarketStreamEvent<InstrumentKey, Kind>, Error>>,
103{
104    type Item = Result<MarketStreamEvent<InstrumentKey, Kind>, Error>;
105
106    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
107        let this = self.get_mut();
108
109        // Index of the earliest buffered `Item`, and its time.
110        let mut earliest: Option<(usize, DateTime<Utc>)> = None;
111        // Whether any input has yet to produce or end. See the check below the loop.
112        let mut pending = false;
113
114        for (index, input) in this.inputs.iter_mut().enumerate() {
115            match input.as_mut().poll_peek(cx) {
116                Poll::Pending => pending = true,
117                // An ended input contributes nothing and stays ended.
118                Poll::Ready(None) => {}
119                // Forwarded ahead of buffered events: a failed source means the merged stream can
120                // no longer be complete, and the consumer should learn that before acting on more
121                // data.
122                Poll::Ready(Some(Err(_))) => {
123                    return input.as_mut().poll_next(cx);
124                }
125                // No timestamp to order on, so it is emitted where it occurred.
126                Poll::Ready(Some(Ok(MarketStreamEvent::Reconnecting(_)))) => {
127                    return input.as_mut().poll_next(cx);
128                }
129                Poll::Ready(Some(Ok(MarketStreamEvent::Item(event)))) => {
130                    let time = event.time_exchange;
131
132                    // `poll_peek` re-reports the same buffered event until it is consumed, so this
133                    // must accept equality -- and ties across a genuine advance are legal anyway.
134                    // Only a step backwards is the violation.
135                    #[cfg(debug_assertions)]
136                    {
137                        debug_assert!(
138                            this.last_peeked[index].is_none_or(|previous| previous <= time),
139                            "merge_time_sorted input {index} is not sorted ascending by \
140                             time_exchange: {:?} was followed by {time}. An unsorted input yields \
141                             an unsorted output, which a backtest cannot detect downstream",
142                            this.last_peeked[index]
143                        );
144                        this.last_peeked[index] = Some(time);
145                    }
146
147                    // Strictly `<`, so ties resolve to the earliest-listed input and the merge
148                    // stays deterministic for a given input ordering.
149                    if earliest.is_none_or(|(_, seen)| time < seen) {
150                        earliest = Some((index, time));
151                    }
152                }
153            }
154        }
155
156        // A single unresolved input holds the whole merge: it might be about to yield an event
157        // earlier than anything currently buffered, and emitting the buffered one first would put
158        // the output out of order — which no downstream consumer can detect or recover from.
159        // Emitting is therefore only safe once EVERY input has either buffered an event or ended.
160        // Each `poll_peek` above registered this task's waker, so the merge is re-polled when the
161        // outstanding input resolves.
162        if pending {
163            return Poll::Pending;
164        }
165
166        match earliest {
167            // The index came from the loop above, so this cannot be out of bounds.
168            Some((index, _)) => this.inputs[index].as_mut().poll_next(cx),
169            // Nothing pending and nothing buffered: every input has ended.
170            None => Poll::Ready(None),
171        }
172    }
173}
174
175impl<St, InstrumentKey, Kind, Error> FusedStream for TimeSortedMerge<St, InstrumentKey, Kind, Error>
176where
177    St: Stream<Item = Result<MarketStreamEvent<InstrumentKey, Kind>, Error>>,
178{
179    /// Terminated exactly when every input is.
180    ///
181    /// `Peekable` is itself a [`FusedStream`], and `poll_next` above returns `Poll::Ready(None)`
182    /// precisely when no input has either a buffered event or an outstanding poll — which is this
183    /// condition. Implementing it means the normal backtest shape, a `select!` over the merged
184    /// feed and a control channel, does not need a redundant `.fuse()` wrapper that would only
185    /// re-derive a property the merge already has.
186    fn is_terminated(&self) -> bool {
187        self.inputs.iter().all(|input| input.is_terminated())
188    }
189}
190
191/// Tag a stream of provider payloads as [`MarketStreamEvent::Item`]s for one instrument.
192///
193/// The N=1 building block for [`merge_time_sorted`]: historical fetches are typically per-symbol
194/// and yield bare payloads, while consumers need [`MarketEvent`]s carrying the exchange and the
195/// instrument key the caller resolved.
196///
197/// `time_exchange` is supplied by `time`, applied to each payload. For candles this MUST be the
198/// bar's `close_time` — the period **end** — since stamping the open would enter a completed bar
199/// into the timeline at the instant its period began, which is lookahead.
200pub fn tag_events<St, Payload, InstrumentKey, Kind, Error>(
201    stream: St,
202    exchange: ExchangeId,
203    instrument: InstrumentKey,
204    time: impl Fn(&Payload) -> DateTime<Utc>,
205    kind: impl Fn(Payload) -> Kind,
206) -> impl Stream<Item = Result<MarketStreamEvent<InstrumentKey, Kind>, Error>>
207where
208    St: Stream<Item = Result<Payload, Error>>,
209    InstrumentKey: Clone,
210{
211    stream.map(move |payload| {
212        payload.map(|payload| {
213            let time_exchange = time(&payload);
214            MarketStreamEvent::Item(MarketEvent {
215                time_exchange,
216                // A historical replay has no real receipt instant; only `time_exchange` orders the
217                // timeline, so mirroring it is the honest choice over a synthetic `Utc::now()`.
218                time_received: time_exchange,
219                exchange,
220                instrument: instrument.clone(),
221                kind: kind(payload),
222            })
223        })
224    })
225}
226
227#[cfg(test)]
228#[allow(clippy::unwrap_used)] // Test code: panicking on a bad fixture is acceptable
229mod tests {
230    use super::*;
231    use crate::{event::DataKind, subscription::trade::PublicTrade};
232    use rust_decimal_macros::dec;
233    use rustrade_instrument::{Side, instrument::InstrumentIndex};
234
235    #[derive(Debug, Clone, PartialEq)]
236    struct TestError(&'static str);
237
238    fn at(secs: i64) -> DateTime<Utc> {
239        DateTime::from_timestamp(secs, 0).unwrap()
240    }
241
242    fn item(
243        instrument: usize,
244        secs: i64,
245    ) -> Result<MarketStreamEvent<InstrumentIndex, DataKind>, TestError> {
246        Ok(MarketStreamEvent::Item(MarketEvent {
247            time_exchange: at(secs),
248            time_received: at(secs),
249            exchange: ExchangeId::BinanceSpot,
250            instrument: InstrumentIndex::new(instrument),
251            kind: DataKind::Trade(PublicTrade {
252                id: "1".into(),
253                price: dec!(100),
254                amount: dec!(1),
255                side: Some(Side::Buy),
256            }),
257        }))
258    }
259
260    /// `(instrument index, time_exchange seconds)` of each merged `Item`.
261    fn observed(
262        events: &[Result<MarketStreamEvent<InstrumentIndex, DataKind>, TestError>],
263    ) -> Vec<(usize, i64)> {
264        events
265            .iter()
266            .filter_map(|event| match event {
267                Ok(MarketStreamEvent::Item(event)) => {
268                    Some((event.instrument.index(), event.time_exchange.timestamp()))
269                }
270                _ => None,
271            })
272            .collect()
273    }
274
275    async fn merge(
276        inputs: Vec<Vec<Result<MarketStreamEvent<InstrumentIndex, DataKind>, TestError>>>,
277    ) -> Vec<Result<MarketStreamEvent<InstrumentIndex, DataKind>, TestError>> {
278        merge_time_sorted(inputs.into_iter().map(futures::stream::iter))
279            .collect::<Vec<_>>()
280            .await
281    }
282
283    #[tokio::test]
284    async fn the_merge_reports_itself_terminated_once_every_input_has_ended() {
285        // `FusedStream` is what lets a `select!` over the merged feed and a control channel -- the
286        // normal backtest shape -- skip a redundant `.fuse()`. It has to be true before exhaustion
287        // as well as after, or a `select!` would drop the merge on its first poll.
288        let mut merged = merge_time_sorted(
289            vec![vec![item(0, 10)], vec![item(1, 20)]]
290                .into_iter()
291                .map(futures::stream::iter),
292        );
293
294        assert!(!merged.is_terminated());
295
296        while merged.next().await.is_some() {}
297
298        assert!(merged.is_terminated());
299    }
300
301    #[tokio::test]
302    async fn interleaves_three_streams_by_time() {
303        let merged = merge(vec![
304            vec![item(0, 10), item(0, 40)],
305            vec![item(1, 20), item(1, 50)],
306            vec![item(2, 30)],
307        ])
308        .await;
309
310        assert_eq!(
311            observed(&merged),
312            vec![(0, 10), (1, 20), (2, 30), (0, 40), (1, 50)]
313        );
314    }
315
316    #[tokio::test]
317    async fn ties_resolve_to_the_earliest_listed_input() {
318        let merged = merge(vec![
319            vec![item(0, 10)],
320            vec![item(1, 10)],
321            vec![item(2, 10)],
322        ])
323        .await;
324
325        assert_eq!(observed(&merged), vec![(0, 10), (1, 10), (2, 10)]);
326    }
327
328    #[tokio::test]
329    async fn drains_remaining_inputs_after_others_end() {
330        let merged = merge(vec![
331            vec![item(0, 10)],
332            vec![item(1, 20), item(1, 30), item(1, 40)],
333        ])
334        .await;
335
336        assert_eq!(observed(&merged), vec![(0, 10), (1, 20), (1, 30), (1, 40)]);
337    }
338
339    /// The sort obligation cannot be *enforced* without buffering, so the debug-only check is the
340    /// only thing that makes a violation loud — and a check nothing exercises is one that can be
341    /// inverted or deleted without a single test noticing.
342    // Neither the assert nor the state it reads exists in a release build.
343    #[cfg(debug_assertions)]
344    #[tokio::test]
345    #[should_panic(expected = "is not sorted ascending")]
346    async fn an_input_that_steps_backwards_trips_the_debug_assert() {
347        let _merged = merge(vec![vec![item(0, 20), item(0, 10)]]).await;
348    }
349
350    /// An input that has not yet produced must not be overtaken by one that has.
351    ///
352    /// This is the merge's whole correctness condition, and the failure is silent: emitting a
353    /// buffered event while another input is still `Pending` yields an out-of-order stream that no
354    /// downstream consumer can detect — in a backtest, a non-monotonic clock and wrong results.
355    /// Real inputs resolve at different times (independent HTTP fetches, files on different
356    /// devices), so this is the normal case, not an edge one.
357    #[tokio::test]
358    async fn a_slow_input_is_not_overtaken_by_a_ready_one() {
359        // Input 0 is immediately ready but holds LATER events; input 1 must yield to the executor
360        // before producing its EARLIER ones.
361        let fast = futures::stream::iter(vec![item(0, 20), item(0, 40)]);
362        let slow = futures::stream::iter(vec![item(1, 10), item(1, 30)]).then(|event| async move {
363            for _ in 0..4 {
364                tokio::task::yield_now().await;
365            }
366            event
367        });
368
369        let merged = merge_time_sorted(vec![
370            Box::pin(fast) as Pin<Box<dyn Stream<Item = _> + Send>>,
371            Box::pin(slow),
372        ])
373        .collect::<Vec<_>>()
374        .await;
375
376        assert_eq!(
377            observed(&merged),
378            vec![(1, 10), (0, 20), (1, 30), (0, 40)],
379            "merge must wait for a pending input rather than emitting a later buffered event"
380        );
381    }
382
383    #[tokio::test]
384    async fn empty_input_set_ends_immediately() {
385        assert!(merge(vec![]).await.is_empty());
386    }
387
388    #[tokio::test]
389    async fn empty_inputs_are_skipped() {
390        let merged = merge(vec![vec![], vec![item(1, 20)], vec![]]).await;
391
392        assert_eq!(observed(&merged), vec![(1, 20)]);
393    }
394
395    /// A failed source is forwarded ahead of buffered events, so the consumer learns the merged
396    /// stream can no longer be complete before acting on more data.
397    #[tokio::test]
398    async fn error_is_forwarded_ahead_of_buffered_items() {
399        let merged = merge(vec![
400            vec![item(0, 10)],
401            vec![Err(TestError("boom")), item(1, 20)],
402        ])
403        .await;
404
405        assert_eq!(
406            merged.first().unwrap().clone().err(),
407            Some(TestError("boom"))
408        );
409
410        // The whole sequence, not just its head. Asserting only `first()` leaves the *other* half
411        // of this contract unpinned: forwarding the error must not also discard the events behind
412        // it. An implementation that reported the error and then ended -- the silent-truncation
413        // failure this module exists to prevent -- would satisfy the assertion above.
414        assert_eq!(
415            observed(&merged),
416            vec![(0, 10), (1, 20)],
417            "the error is forwarded ahead of the buffered events, not instead of them"
418        );
419        assert_eq!(merged.len(), 3, "exactly one error and both events");
420        assert_eq!(
421            merged.iter().filter(|event| event.is_err()).count(),
422            1,
423            "the error must be reported once, not re-yielded"
424        );
425    }
426
427    #[tokio::test]
428    async fn reconnecting_is_emitted_where_it_occurs() {
429        let merged = merge(vec![
430            vec![item(0, 10), item(0, 90)],
431            vec![
432                Ok(MarketStreamEvent::Reconnecting(ExchangeId::BinanceSpot)),
433                item(1, 20),
434            ],
435        ])
436        .await;
437
438        assert!(matches!(
439            merged.first(),
440            Some(Ok(MarketStreamEvent::Reconnecting(_)))
441        ));
442        assert_eq!(observed(&merged), vec![(0, 10), (1, 20), (0, 90)]);
443    }
444
445    #[tokio::test]
446    async fn tag_events_stamps_exchange_instrument_and_time() {
447        let payloads = futures::stream::iter(vec![Ok::<_, TestError>(at(42))]);
448        let tagged = tag_events(
449            payloads,
450            ExchangeId::BinanceSpot,
451            InstrumentIndex::new(7),
452            |time: &DateTime<Utc>| *time,
453            |time| {
454                DataKind::Trade(PublicTrade {
455                    id: time.timestamp().to_string().into(),
456                    price: dec!(1),
457                    amount: dec!(1),
458                    side: None,
459                })
460            },
461        )
462        .collect::<Vec<_>>()
463        .await;
464
465        let Some(Ok(MarketStreamEvent::Item(event))) = tagged.first() else {
466            panic!("expected a tagged Item")
467        };
468        assert_eq!(event.time_exchange, at(42));
469        assert_eq!(event.time_received, at(42));
470        assert_eq!(event.instrument, InstrumentIndex::new(7));
471        assert_eq!(event.exchange, ExchangeId::BinanceSpot);
472    }
473}