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