use crate::{event::MarketEvent, streams::consumer::MarketStreamEvent};
use chrono::{DateTime, Utc};
use futures::{
Stream, StreamExt,
stream::{FusedStream, Peekable},
};
use rustrade_instrument::exchange::ExchangeId;
use std::{
pin::Pin,
task::{Context, Poll},
};
pub fn merge_time_sorted<St, InstrumentKey, Kind, Error>(
streams: impl IntoIterator<Item = St>,
) -> TimeSortedMerge<St, InstrumentKey, Kind, Error>
where
St: Stream<Item = Result<MarketStreamEvent<InstrumentKey, Kind>, Error>>,
{
let inputs = streams
.into_iter()
.map(|stream| Box::pin(stream.peekable()))
.collect::<Vec<_>>();
TimeSortedMerge {
#[cfg(debug_assertions)]
last_peeked: vec![None; inputs.len()],
inputs,
}
}
#[derive(Debug)]
pub struct TimeSortedMerge<St, InstrumentKey, Kind, Error>
where
St: Stream<Item = Result<MarketStreamEvent<InstrumentKey, Kind>, Error>>,
{
inputs: Vec<Pin<Box<Peekable<St>>>>,
#[cfg(debug_assertions)]
last_peeked: Vec<Option<DateTime<Utc>>>,
}
impl<St, InstrumentKey, Kind, Error> Stream for TimeSortedMerge<St, InstrumentKey, Kind, Error>
where
St: Stream<Item = Result<MarketStreamEvent<InstrumentKey, Kind>, Error>>,
{
type Item = Result<MarketStreamEvent<InstrumentKey, Kind>, Error>;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = self.get_mut();
let mut earliest: Option<(usize, DateTime<Utc>)> = None;
let mut pending = false;
for (index, input) in this.inputs.iter_mut().enumerate() {
match input.as_mut().poll_peek(cx) {
Poll::Pending => pending = true,
Poll::Ready(None) => {}
Poll::Ready(Some(Err(_))) => {
return input.as_mut().poll_next(cx);
}
Poll::Ready(Some(Ok(MarketStreamEvent::Reconnecting(_)))) => {
return input.as_mut().poll_next(cx);
}
Poll::Ready(Some(Ok(MarketStreamEvent::Item(event)))) => {
let time = event.time_exchange;
#[cfg(debug_assertions)]
{
debug_assert!(
this.last_peeked[index].is_none_or(|previous| previous <= time),
"merge_time_sorted input {index} is not sorted ascending by \
time_exchange: {:?} was followed by {time}. An unsorted input yields \
an unsorted output, which a backtest cannot detect downstream",
this.last_peeked[index]
);
this.last_peeked[index] = Some(time);
}
if earliest.is_none_or(|(_, seen)| time < seen) {
earliest = Some((index, time));
}
}
}
}
if pending {
return Poll::Pending;
}
match earliest {
Some((index, _)) => this.inputs[index].as_mut().poll_next(cx),
None => Poll::Ready(None),
}
}
}
impl<St, InstrumentKey, Kind, Error> FusedStream for TimeSortedMerge<St, InstrumentKey, Kind, Error>
where
St: Stream<Item = Result<MarketStreamEvent<InstrumentKey, Kind>, Error>>,
{
fn is_terminated(&self) -> bool {
self.inputs.iter().all(|input| input.is_terminated())
}
}
pub fn tag_events<St, Payload, InstrumentKey, Kind, Error>(
stream: St,
exchange: ExchangeId,
instrument: InstrumentKey,
time: impl Fn(&Payload) -> DateTime<Utc>,
kind: impl Fn(Payload) -> Kind,
) -> impl Stream<Item = Result<MarketStreamEvent<InstrumentKey, Kind>, Error>>
where
St: Stream<Item = Result<Payload, Error>>,
InstrumentKey: Clone,
{
stream.map(move |payload| {
payload.map(|payload| {
let time_exchange = time(&payload);
MarketStreamEvent::Item(MarketEvent {
time_exchange,
time_received: time_exchange,
exchange,
instrument: instrument.clone(),
kind: kind(payload),
})
})
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::*;
use crate::{event::DataKind, subscription::trade::PublicTrade};
use rust_decimal_macros::dec;
use rustrade_instrument::{Side, instrument::InstrumentIndex};
#[derive(Debug, Clone, PartialEq)]
struct TestError(&'static str);
fn at(secs: i64) -> DateTime<Utc> {
DateTime::from_timestamp(secs, 0).unwrap()
}
fn item(
instrument: usize,
secs: i64,
) -> Result<MarketStreamEvent<InstrumentIndex, DataKind>, TestError> {
Ok(MarketStreamEvent::Item(MarketEvent {
time_exchange: at(secs),
time_received: at(secs),
exchange: ExchangeId::BinanceSpot,
instrument: InstrumentIndex::new(instrument),
kind: DataKind::Trade(PublicTrade {
id: "1".into(),
price: dec!(100),
amount: dec!(1),
side: Some(Side::Buy),
}),
}))
}
fn observed(
events: &[Result<MarketStreamEvent<InstrumentIndex, DataKind>, TestError>],
) -> Vec<(usize, i64)> {
events
.iter()
.filter_map(|event| match event {
Ok(MarketStreamEvent::Item(event)) => {
Some((event.instrument.index(), event.time_exchange.timestamp()))
}
_ => None,
})
.collect()
}
async fn merge(
inputs: Vec<Vec<Result<MarketStreamEvent<InstrumentIndex, DataKind>, TestError>>>,
) -> Vec<Result<MarketStreamEvent<InstrumentIndex, DataKind>, TestError>> {
merge_time_sorted(inputs.into_iter().map(futures::stream::iter))
.collect::<Vec<_>>()
.await
}
#[tokio::test]
async fn the_merge_reports_itself_terminated_once_every_input_has_ended() {
let mut merged = merge_time_sorted(
vec![vec![item(0, 10)], vec![item(1, 20)]]
.into_iter()
.map(futures::stream::iter),
);
assert!(!merged.is_terminated());
while merged.next().await.is_some() {}
assert!(merged.is_terminated());
}
#[tokio::test]
async fn interleaves_three_streams_by_time() {
let merged = merge(vec![
vec![item(0, 10), item(0, 40)],
vec![item(1, 20), item(1, 50)],
vec![item(2, 30)],
])
.await;
assert_eq!(
observed(&merged),
vec![(0, 10), (1, 20), (2, 30), (0, 40), (1, 50)]
);
}
#[tokio::test]
async fn ties_resolve_to_the_earliest_listed_input() {
let merged = merge(vec![
vec![item(0, 10)],
vec![item(1, 10)],
vec![item(2, 10)],
])
.await;
assert_eq!(observed(&merged), vec![(0, 10), (1, 10), (2, 10)]);
}
#[tokio::test]
async fn drains_remaining_inputs_after_others_end() {
let merged = merge(vec![
vec![item(0, 10)],
vec![item(1, 20), item(1, 30), item(1, 40)],
])
.await;
assert_eq!(observed(&merged), vec![(0, 10), (1, 20), (1, 30), (1, 40)]);
}
#[cfg(debug_assertions)]
#[tokio::test]
#[should_panic(expected = "is not sorted ascending")]
async fn an_input_that_steps_backwards_trips_the_debug_assert() {
let _merged = merge(vec![vec![item(0, 20), item(0, 10)]]).await;
}
#[tokio::test]
async fn a_slow_input_is_not_overtaken_by_a_ready_one() {
let fast = futures::stream::iter(vec![item(0, 20), item(0, 40)]);
let slow = futures::stream::iter(vec![item(1, 10), item(1, 30)]).then(|event| async move {
for _ in 0..4 {
tokio::task::yield_now().await;
}
event
});
let merged = merge_time_sorted(vec![
Box::pin(fast) as Pin<Box<dyn Stream<Item = _> + Send>>,
Box::pin(slow),
])
.collect::<Vec<_>>()
.await;
assert_eq!(
observed(&merged),
vec![(1, 10), (0, 20), (1, 30), (0, 40)],
"merge must wait for a pending input rather than emitting a later buffered event"
);
}
#[tokio::test]
async fn empty_input_set_ends_immediately() {
assert!(merge(vec![]).await.is_empty());
}
#[tokio::test]
async fn empty_inputs_are_skipped() {
let merged = merge(vec![vec![], vec![item(1, 20)], vec![]]).await;
assert_eq!(observed(&merged), vec![(1, 20)]);
}
#[tokio::test]
async fn error_is_forwarded_ahead_of_buffered_items() {
let merged = merge(vec![
vec![item(0, 10)],
vec![Err(TestError("boom")), item(1, 20)],
])
.await;
assert_eq!(
merged.first().unwrap().clone().err(),
Some(TestError("boom"))
);
assert_eq!(
observed(&merged),
vec![(0, 10), (1, 20)],
"the error is forwarded ahead of the buffered events, not instead of them"
);
assert_eq!(merged.len(), 3, "exactly one error and both events");
assert_eq!(
merged.iter().filter(|event| event.is_err()).count(),
1,
"the error must be reported once, not re-yielded"
);
}
#[tokio::test]
async fn reconnecting_is_emitted_where_it_occurs() {
let merged = merge(vec![
vec![item(0, 10), item(0, 90)],
vec![
Ok(MarketStreamEvent::Reconnecting(ExchangeId::BinanceSpot)),
item(1, 20),
],
])
.await;
assert!(matches!(
merged.first(),
Some(Ok(MarketStreamEvent::Reconnecting(_)))
));
assert_eq!(observed(&merged), vec![(0, 10), (1, 20), (0, 90)]);
}
#[tokio::test]
async fn tag_events_stamps_exchange_instrument_and_time() {
let payloads = futures::stream::iter(vec![Ok::<_, TestError>(at(42))]);
let tagged = tag_events(
payloads,
ExchangeId::BinanceSpot,
InstrumentIndex::new(7),
|time: &DateTime<Utc>| *time,
|time| {
DataKind::Trade(PublicTrade {
id: time.timestamp().to_string().into(),
price: dec!(1),
amount: dec!(1),
side: None,
})
},
)
.collect::<Vec<_>>()
.await;
let Some(Ok(MarketStreamEvent::Item(event))) = tagged.first() else {
panic!("expected a tagged Item")
};
assert_eq!(event.time_exchange, at(42));
assert_eq!(event.time_received, at(42));
assert_eq!(event.instrument, InstrumentIndex::new(7));
assert_eq!(event.exchange, ExchangeId::BinanceSpot);
}
}