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}