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}