use crate::{
error::DataError,
event::DataKind,
exchange::lse::{error::LseError, market, vault::LseVaultClient},
streams::{
consumer::MarketStreamEvent,
merge::{merge_time_sorted, tag_events},
},
subscription::candle::{Candle, CandleInterval},
};
use async_stream::try_stream;
use chrono::{DateTime, Utc};
use futures::{Stream, StreamExt};
use rustrade_instrument::{
exchange::ExchangeId, index::IndexedInstruments, instrument::InstrumentIndex,
};
use smol_str::SmolStr;
use std::sync::Arc;
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct LseCandleSource {
pub symbol: SmolStr,
pub instrument: InstrumentIndex,
pub exchange: ExchangeId,
}
impl LseCandleSource {
pub fn new(
symbol: impl Into<SmolStr>,
instrument: InstrumentIndex,
exchange: ExchangeId,
) -> Self {
Self {
symbol: symbol.into(),
instrument,
exchange,
}
}
pub fn resolve(
instruments: &IndexedInstruments,
exchange: ExchangeId,
symbol: impl Into<SmolStr>,
) -> Result<Self, LseError> {
let symbol = symbol.into();
let instrument = market::instrument_index_for(instruments, exchange, &symbol)?;
Ok(Self {
symbol,
instrument,
exchange,
})
}
}
pub fn replay_candles(
client: Arc<LseVaultClient>,
sources: Vec<LseCandleSource>,
interval: CandleInterval,
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> impl Stream<Item = Result<MarketStreamEvent<InstrumentIndex, DataKind>, DataError>> + Send + 'static
{
merge_time_sorted(sources.into_iter().map(move |source| {
tag_events(
owned_candles(
Arc::clone(&client),
source.symbol.clone(),
interval,
start,
end,
),
source.exchange,
source.instrument,
|candle: &Candle| candle.close_time,
DataKind::Candle,
)
}))
}
fn owned_candles(
client: Arc<LseVaultClient>,
symbol: SmolStr,
interval: CandleInterval,
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> impl Stream<Item = Result<Candle, DataError>> + Send + 'static {
try_stream! {
let candles = client.fetch_candles(&symbol, interval, start, end);
futures::pin_mut!(candles);
while let Some(candle) = candles.next().await {
yield candle.map_err(DataError::from)?;
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
use super::*;
use crate::event::MarketEvent;
use std::time::Duration;
fn observed(
events: &[Result<MarketStreamEvent<InstrumentIndex, DataKind>, DataError>],
) -> Vec<(usize, DateTime<Utc>)> {
events
.iter()
.map(|event| match event {
Ok(MarketStreamEvent::Item(MarketEvent {
time_exchange,
instrument,
kind: DataKind::Candle(_),
..
})) => (instrument.index(), *time_exchange),
other => panic!("expected a candle Item, got {other:?}"),
})
.collect()
}
fn rows(count: i64, first_minute: i64, step: i64) -> String {
let rows: Vec<String> = (0..count)
.map(|index| {
let minute = first_minute + index * step;
format!(
r#"{{"ts":"2024-01-02 {:02}:{:02}:00","open":1.0,"high":1.0,"low":1.0,"close":1.0,"volume":1}}"#,
minute / 60,
minute % 60
)
})
.collect();
format!("[{}]", rows.join(","))
}
#[tokio::test]
async fn replay_merges_two_symbols_into_one_time_ordered_stream() {
let server = wiremock::MockServer::start().await;
for (symbol, first_minute) in [("AAPL", 0), ("MSFT", 1)] {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/vault/candles"))
.and(wiremock::matchers::query_param("symbol", symbol))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_raw(rows(2, first_minute, 2), "application/json"),
)
.up_to_n_times(1)
.mount(&server)
.await;
}
wiremock::Mock::given(wiremock::matchers::method("GET"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_raw("[]", "application/json"),
)
.mount(&server)
.await;
let client = Arc::new(
LseVaultClient::new("key")
.unwrap()
.with_base_url(format!("{}/vault", server.uri()))
.with_pace(Duration::ZERO),
);
let events = replay_candles(
client,
vec![
LseCandleSource::new("AAPL", InstrumentIndex::new(0), ExchangeId::LseEquities),
LseCandleSource::new("MSFT", InstrumentIndex::new(1), ExchangeId::LseEquities),
],
CandleInterval::Min1,
"2024-01-01T00:00:00Z".parse().unwrap(),
"2024-01-03T00:00:00Z".parse().unwrap(),
)
.collect::<Vec<_>>()
.await;
let observed = observed(&events);
assert!(
observed.windows(2).all(|pair| pair[0].1 <= pair[1].1),
"merged replay must be time-ordered: {observed:?}"
);
assert_eq!(
observed.iter().map(|(index, _)| *index).collect::<Vec<_>>(),
vec![0, 1, 0, 1]
);
}
#[tokio::test]
async fn replay_of_no_sources_is_empty() {
let client = Arc::new(LseVaultClient::new("key").unwrap());
let events = replay_candles(
client,
vec![],
CandleInterval::Min1,
"2024-01-01T00:00:00Z".parse().unwrap(),
"2024-01-03T00:00:00Z".parse().unwrap(),
)
.collect::<Vec<_>>()
.await;
assert!(events.is_empty());
}
#[tokio::test]
async fn replay_surfaces_a_source_failure() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.respond_with(
wiremock::ResponseTemplate::new(500)
.set_body_raw(r#"{"detail":"upstream unavailable"}"#, "application/json"),
)
.mount(&server)
.await;
let client = Arc::new(
LseVaultClient::new("key")
.unwrap()
.with_base_url(format!("{}/vault", server.uri()))
.with_pace(Duration::ZERO),
);
let events = replay_candles(
client,
vec![LseCandleSource::new(
"AAPL",
InstrumentIndex::new(0),
ExchangeId::LseEquities,
)],
CandleInterval::Min1,
"2024-01-01T00:00:00Z".parse().unwrap(),
"2024-01-03T00:00:00Z".parse().unwrap(),
)
.collect::<Vec<_>>()
.await;
assert!(matches!(events.first(), Some(Err(DataError::Lse { .. }))));
}
#[test]
fn resolve_derives_the_index_and_rejects_a_london_listing_registered_in_pounds() {
use rustrade_instrument::Underlying;
use rustrade_instrument::index::builder::IndexedInstrumentsBuilder;
use rustrade_instrument::instrument::name::{
InstrumentNameExchange, InstrumentNameInternal,
};
use rustrade_instrument::instrument::quote::InstrumentQuoteAsset;
use rustrade_instrument::instrument::{Instrument, kind::InstrumentKind};
use rustrade_instrument::test_utils::asset;
let register = |symbol: &str, quote: &str| {
let name = InstrumentNameExchange::from(symbol);
IndexedInstrumentsBuilder::default()
.add_instrument(Instrument::new(
ExchangeId::LseEquities,
InstrumentNameInternal::new_from_exchange(
ExchangeId::LseEquities,
name.clone(),
),
name,
Underlying::new(asset(&symbol.to_lowercase()), asset(quote)),
InstrumentQuoteAsset::UnderlyingQuote,
InstrumentKind::Spot,
None,
))
.build()
};
let instruments = register("BP.L", "gbp");
let error = LseCandleSource::resolve(&instruments, ExchangeId::LseEquities, "BP.L")
.expect_err("a pence-quoted listing registered in pounds must not build a source");
assert!(matches!(error, LseError::QuoteAssetMismatch { .. }));
let instruments = register("BP.L", "gbx");
let source =
LseCandleSource::resolve(&instruments, ExchangeId::LseEquities, "BP.L").unwrap();
assert_eq!(source.instrument, InstrumentIndex::new(0));
assert_eq!(source.symbol, "BP.L");
assert_eq!(source.exchange, ExchangeId::LseEquities);
let error = LseCandleSource::resolve(&instruments, ExchangeId::LseEquities, "BP")
.expect_err("an unregistered symbol must not build a source");
assert!(matches!(error, LseError::UnknownInstrument { .. }));
assert!(error.to_string().contains("BP.L"));
}
}