#![cfg(feature = "lse")]
#![allow(clippy::unwrap_used, clippy::expect_used)]
use chrono::{DateTime, TimeDelta, Utc};
use futures::StreamExt;
use rust_decimal_macros::dec;
use rustrade_data::exchange::lse::error::LseError;
use rustrade_data::exchange::lse::vault::LseVaultClient;
use rustrade_data::subscription::candle::CandleInterval;
use std::num::NonZeroU32;
use std::time::Duration;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn client(server: &MockServer) -> LseVaultClient {
LseVaultClient::new("test-key")
.unwrap()
.with_base_url(format!("{}/vault", server.uri()))
.with_pace(Duration::ZERO)
}
fn utc(raw: &str) -> DateTime<Utc> {
raw.parse().unwrap()
}
fn equity_row(ts: &str, close: &str) -> String {
format!(
r#"{{"ts":"{ts}","symbol":"AAPL","open":1.0,"high":2.0,"low":0.5,"close":{close},"volume":1000}}"#
)
}
async fn mount_page(server: &MockServer, start: &str, rows: &[String]) {
Mock::given(method("GET"))
.and(path("/vault/candles"))
.and(query_param("start", start))
.respond_with(ResponseTemplate::new(200).set_body_string(format!("[{}]", rows.join(","))))
.mount(server)
.await;
}
#[tokio::test]
async fn paginates_by_advancing_the_cursor_one_second_past_the_last_bars_open() {
let server = MockServer::start().await;
mount_page(
&server,
"2024-01-01 00:00:00",
&[
equity_row("2024-01-01 00:00:00.000000", "10.0"),
equity_row("2024-01-02 00:00:00.000000", "11.0"),
],
)
.await;
mount_page(
&server,
"2024-01-02 00:00:01",
&[equity_row("2024-01-03 00:00:00.000000", "12.0")],
)
.await;
let candles = client(&server)
.collect_candles(
"AAPL",
CandleInterval::Day1,
utc("2024-01-02T00:00:00Z"),
utc("2024-01-04T00:00:00Z"),
)
.await
.unwrap();
let closes = candles
.iter()
.map(|candle| candle.close_time)
.collect::<Vec<_>>();
assert_eq!(
closes,
vec![
utc("2024-01-02T00:00:00Z"),
utc("2024-01-03T00:00:00Z"),
utc("2024-01-04T00:00:00Z"),
]
);
assert_eq!(candles[2].close, dec!(12.0));
}
#[tokio::test]
async fn a_short_page_does_not_end_pagination() {
let server = MockServer::start().await;
mount_page(
&server,
"2024-01-01 00:00:00",
&[equity_row("2024-01-01 00:00:00.000000", "10.0")],
)
.await;
mount_page(
&server,
"2024-01-01 00:00:01",
&[equity_row("2024-01-05 00:00:00.000000", "14.0")],
)
.await;
mount_page(&server, "2024-01-05 00:00:01", &[]).await;
let candles = client(&server)
.collect_candles(
"AAPL",
CandleInterval::Day1,
utc("2024-01-02T00:00:00Z"),
utc("2024-01-10T00:00:00Z"),
)
.await
.unwrap();
assert_eq!(candles.len(), 2);
assert_eq!(candles[1].close_time, utc("2024-01-06T00:00:00Z"));
}
#[tokio::test]
async fn pins_the_resolution_parameter_to_timeframe() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/vault/candles"))
.and(query_param("timeframe", "1d"))
.and(query_param("limit", "5000"))
.respond_with(ResponseTemplate::new(200).set_body_string("[]"))
.expect(1)
.mount(&server)
.await;
client(&server)
.collect_candles(
"AAPL",
CandleInterval::Day1,
utc("2024-01-02T00:00:00Z"),
utc("2024-01-03T00:00:00Z"),
)
.await
.unwrap();
}
#[tokio::test]
async fn with_page_limit_overrides_the_rows_requested_per_page() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/vault/candles"))
.and(query_param("limit", "250"))
.respond_with(ResponseTemplate::new(200).set_body_string("[]"))
.expect(1)
.mount(&server)
.await;
client(&server)
.with_page_limit(NonZeroU32::new(250).unwrap())
.collect_candles(
"AAPL",
CandleInterval::Day1,
utc("2024-01-02T00:00:00Z"),
utc("2024-01-03T00:00:00Z"),
)
.await
.unwrap();
}
#[tokio::test]
async fn sends_the_provider_spelling_of_one_month() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/vault/candles"))
.and(query_param("timeframe", "1mo"))
.respond_with(ResponseTemplate::new(200).set_body_string("[]"))
.expect(1)
.mount(&server)
.await;
client(&server)
.collect_candles(
"AAPL",
CandleInterval::Month1,
utc("2024-02-01T00:00:00Z"),
utc("2024-03-01T00:00:00Z"),
)
.await
.unwrap();
}
#[tokio::test]
async fn trims_bars_outside_the_close_time_contract() {
let server = MockServer::start().await;
mount_page(
&server,
"2024-01-03 00:00:00",
&[
equity_row("2024-01-02 00:00:00.000000", "11.0"), equity_row("2024-01-03 00:00:00.000000", "12.0"), ],
)
.await;
let candles = client(&server)
.collect_candles(
"AAPL",
CandleInterval::Day1,
utc("2024-01-04T00:00:00Z"),
utc("2024-01-04T00:00:00Z"),
)
.await
.unwrap();
assert_eq!(candles.len(), 1);
assert_eq!(candles[0].close_time, utc("2024-01-04T00:00:00Z"));
assert_eq!(candles[0].close, dec!(12.0));
}
#[tokio::test]
async fn a_candle_closing_past_the_requested_end_is_an_error() {
let server = MockServer::start().await;
mount_page(
&server,
"2024-01-03 00:00:00",
&[
equity_row("2024-01-03 00:00:00.000000", "12.0"), equity_row("2024-01-04 00:00:00.000000", "13.0"), ],
)
.await;
let error = client(&server)
.collect_candles(
"AAPL",
CandleInterval::Day1,
utc("2024-01-04T00:00:00Z"),
utc("2024-01-04T00:00:00Z"),
)
.await
.unwrap_err();
let LseError::UnexpectedCandleRange {
symbol, page, end, ..
} = &error
else {
panic!("expected LseError::UnexpectedCandleRange, got {error:?}");
};
assert_eq!(symbol, "AAPL");
assert_eq!(*page, 1);
assert_eq!(*end, utc("2024-01-04T00:00:00Z"));
}
#[tokio::test]
async fn fx_candles_report_absent_volume_as_none() {
let server = MockServer::start().await;
let row = r#"{"ts":"2024-01-01 00:00:00.000000","symbol":"EUR/USD","open":1.05,"high":1.06,"low":1.04,"close":1.055}"#;
mount_page(&server, "2024-01-01 00:00:00", &[row.to_owned()]).await;
let candles = client(&server)
.collect_candles(
"EUR/USD",
CandleInterval::Day1,
utc("2024-01-02T00:00:00Z"),
utc("2024-01-02T00:00:00Z"),
)
.await
.unwrap();
assert_eq!(candles[0].volume, None);
assert_eq!(candles[0].trade_count, None);
}
#[tokio::test]
async fn an_inverted_range_fails_before_any_request_is_sent() {
let server = MockServer::start().await;
let error = client(&server)
.collect_candles(
"AAPL",
CandleInterval::Day1,
utc("2024-01-05T00:00:00Z"),
utc("2024-01-01T00:00:00Z"),
)
.await
.unwrap_err();
assert!(matches!(error, LseError::InvalidInput { .. }));
assert_eq!(server.received_requests().await.unwrap().len(), 0);
}
#[tokio::test]
async fn an_unserved_resolution_is_rejected_before_any_request_is_sent() {
let server = MockServer::start().await;
let error = client(&server)
.collect_candles(
"AAPL",
CandleInterval::Hour6,
utc("2024-01-01T00:00:00Z"),
utc("2024-01-02T00:00:00Z"),
)
.await
.unwrap_err();
assert!(matches!(
error,
LseError::UnsupportedInterval {
interval: CandleInterval::Hour6
}
));
assert_eq!(server.received_requests().await.unwrap().len(), 0);
}
#[tokio::test]
async fn a_rate_limit_is_terminal_and_carries_retry_after() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(
ResponseTemplate::new(429)
.insert_header("retry-after", "42")
.set_body_string(r#"{"detail":"slow down"}"#),
)
.expect(1)
.mount(&server)
.await;
let error = client(&server)
.collect_candles(
"AAPL",
CandleInterval::Day1,
utc("2024-01-02T00:00:00Z"),
utc("2024-01-03T00:00:00Z"),
)
.await
.unwrap_err();
assert!(matches!(
error,
LseError::RateLimited {
retry_after: Some(delay),
} if delay == Duration::from_secs(42)
));
}
#[tokio::test]
async fn a_double_encoded_error_body_surfaces_the_inner_message() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(400).set_body_string(
r#"{"detail":"{\"detail\":\"invalid timeframe '7q'; valid: 1s, 5s\"}"}"#,
))
.mount(&server)
.await;
let error = client(&server)
.collect_candles(
"AAPL",
CandleInterval::Day1,
utc("2024-01-02T00:00:00Z"),
utc("2024-01-03T00:00:00Z"),
)
.await
.unwrap_err();
match error {
LseError::Api { status, message } => {
assert_eq!(status, 400);
assert_eq!(message, "invalid timeframe '7q'; valid: 1s, 5s");
}
other => panic!("expected Api, got {other:?}"),
}
}
#[tokio::test]
async fn a_single_encoded_error_body_surfaces_the_message() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(401).set_body_string(r#"{"detail":"invalid api key"}"#))
.mount(&server)
.await;
let error = client(&server).usage().await.unwrap_err();
match error {
LseError::Api { status, message } => {
assert_eq!(status, 401);
assert_eq!(message, "invalid api key");
}
other => panic!("expected Api, got {other:?}"),
}
}
#[tokio::test]
async fn usage_reports_the_shared_allowance() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/vault/usage"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"bytes_used_month":5962,"bytes_cap_month":53687091200,
"bytes_used_week":5962,"bytes_cap_week":16106127360,
"exports_this_hour":0,"exports_cap_hour":5,
"historical_data_months":-1,"calls_per_minute":200,
"max_rows_per_request":5000,"vault_concurrency":2}"#,
))
.mount(&server)
.await;
let status = client(&server).usage().await.unwrap();
assert_eq!(status.exports_cap_hour, 5);
assert_eq!(status.max_rows_per_request, 5000);
assert_eq!(status.historical_limit_months(), None);
assert!(!status.is_exhausted());
}
#[tokio::test]
async fn a_page_that_does_not_advance_the_cursor_is_an_error_not_an_infinite_loop() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/vault/candles"))
.respond_with(ResponseTemplate::new(200).set_body_string(format!(
"[{}]",
equity_row("2023-01-01 00:00:00.000000", "9.0")
)))
.mount(&server)
.await;
let error = tokio::time::timeout(
Duration::from_secs(10),
client(&server).collect_candles(
"AAPL",
CandleInterval::Day1,
utc("2024-01-02T00:00:00Z"),
utc("2024-01-10T00:00:00Z"),
),
)
.await
.expect("pagination did not terminate: the cursor non-advance guard is gone")
.unwrap_err();
let LseError::Api { message, .. } = &error else {
panic!("expected LseError::Api, got {error:?}");
};
assert!(
message.contains("did not advance"),
"expected the cursor non-advance diagnostic, got: {message}"
);
}
#[tokio::test]
async fn a_malformed_timestamp_surfaces_rather_than_being_skipped() {
let server = MockServer::start().await;
mount_page(
&server,
"2024-01-01 00:00:00",
&[r#"{"ts":"not-a-timestamp","symbol":"AAPL","open":1.0,"high":2.0,"low":0.5,"close":1.5,"volume":1}"#.to_owned()],
)
.await;
let error = client(&server)
.collect_candles(
"AAPL",
CandleInterval::Day1,
utc("2024-01-02T00:00:00Z"),
utc("2024-01-03T00:00:00Z"),
)
.await
.unwrap_err();
assert!(matches!(error, LseError::Deserialize { .. }));
}
#[tokio::test]
async fn a_descending_page_fails_without_yielding_any_of_its_bars() {
let server = MockServer::start().await;
mount_page(
&server,
"2024-01-03 00:00:00",
&[
equity_row("2024-01-04 00:00:00.000000", "13.0"), equity_row("2024-01-03 00:00:00.000000", "12.0"), ],
)
.await;
let client = client(&server);
let stream = client.fetch_candles(
"AAPL",
CandleInterval::Day1,
utc("2024-01-04T00:00:00Z"),
utc("2024-01-04T00:00:00Z"),
);
futures::pin_mut!(stream);
let error = stream
.next()
.await
.expect("the ordering violation must terminate the stream")
.unwrap_err();
assert!(
matches!(error, LseError::NonMonotonicCandlePage { .. }),
"expected the ordering violation, got {error:?}"
);
assert!(
stream.next().await.is_none(),
"the stream must end at the ordering violation"
);
}
#[tokio::test]
async fn an_ascending_page_yields_its_in_range_bars_before_the_range_violation() {
let server = MockServer::start().await;
mount_page(
&server,
"2024-01-03 00:00:00",
&[
equity_row("2024-01-03 00:00:00.000000", "12.0"), equity_row("2024-01-04 00:00:00.000000", "13.0"), ],
)
.await;
let client = client(&server);
let stream = client.fetch_candles(
"AAPL",
CandleInterval::Day1,
utc("2024-01-04T00:00:00Z"),
utc("2024-01-04T00:00:00Z"),
);
futures::pin_mut!(stream);
let candle = stream
.next()
.await
.expect("the in-range bar must be yielded before the range violation")
.expect("the in-range bar must arrive as Ok, not as the error");
assert_eq!(candle.close_time, utc("2024-01-04T00:00:00Z"));
assert_eq!(candle.close, dec!(12.0));
let error = stream
.next()
.await
.expect("the range violation must terminate the stream")
.unwrap_err();
assert!(
matches!(error, LseError::UnexpectedCandleRange { .. }),
"expected the range violation, got {error:?}"
);
assert!(
stream.next().await.is_none(),
"the stream must end at the range violation"
);
}
#[tokio::test]
async fn a_full_page_at_the_row_cap_seams_onto_the_next_without_gap_or_duplicate() {
let server = MockServer::start().await;
const CAP: i64 = 5000;
let first_open = utc("2024-01-01T00:00:00Z");
let minute_row =
|open: DateTime<Utc>| equity_row(&open.format("%Y-%m-%d %H:%M:%S%.6f").to_string(), "10.0");
let full_page: Vec<String> = (0..CAP)
.map(|index| minute_row(first_open + TimeDelta::minutes(index)))
.collect();
mount_page(&server, "2024-01-01 00:00:00", &full_page).await;
let last_open_page_one = first_open + TimeDelta::minutes(CAP - 1);
let next_open = last_open_page_one + TimeDelta::minutes(1);
mount_page(
&server,
&(last_open_page_one + TimeDelta::seconds(1))
.format("%Y-%m-%d %H:%M:%S")
.to_string(),
&[minute_row(next_open)],
)
.await;
let candles = client(&server)
.collect_candles(
"AAPL",
CandleInterval::Min1,
first_open + TimeDelta::minutes(1), next_open + TimeDelta::minutes(1), )
.await
.unwrap();
assert_eq!(candles.len(), CAP as usize + 1);
for (index, candle) in candles.iter().enumerate() {
assert_eq!(
candle.close_time,
first_open + TimeDelta::minutes(index as i64 + 1),
"bar {index} breaks the one-minute sequence across the cap boundary"
);
}
}
#[tokio::test]
async fn a_candle_fetch_does_not_follow_a_redirect_off_host() {
let attacker = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_string("[]"))
.mount(&attacker)
.await;
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/vault/candles"))
.respond_with(ResponseTemplate::new(302).insert_header(
"location",
format!("{}/vault/candles", attacker.uri()).as_str(),
))
.mount(&server)
.await;
let error = client(&server)
.collect_candles(
"AAPL",
CandleInterval::Day1,
utc("2024-01-02T00:00:00Z"),
utc("2024-01-03T00:00:00Z"),
)
.await
.unwrap_err();
assert!(
matches!(error, LseError::Api { status, .. } if (300..400).contains(&status)),
"expected an unfollowed 3xx as LseError::Api, got {error:?}"
);
assert!(
attacker.received_requests().await.unwrap().is_empty(),
"the client must not follow a redirect off-host: the x-api-key would ride along"
);
}
#[tokio::test]
async fn the_exclusive_upper_bound_is_extended_past_the_final_bars_open() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/vault/candles"))
.and(query_param("start", "2024-01-01 00:00:00"))
.and(query_param("end", "2024-01-03 00:00:01"))
.respond_with(ResponseTemplate::new(200).set_body_string(format!(
"[{}]",
[
equity_row("2024-01-01 00:00:00.000000", "10.0"),
equity_row("2024-01-02 00:00:00.000000", "11.0"),
equity_row("2024-01-03 00:00:00.000000", "12.0"),
]
.join(",")
)))
.expect(1)
.mount(&server)
.await;
let candles = client(&server)
.collect_candles(
"AAPL",
CandleInterval::Day1,
utc("2024-01-02T00:00:00Z"),
utc("2024-01-04T00:00:00Z"),
)
.await
.unwrap();
assert_eq!(
candles.len(),
3,
"the bar closing on `end` must be included"
);
assert_eq!(candles[2].close_time, utc("2024-01-04T00:00:00Z"));
}
#[tokio::test]
async fn a_page_that_steps_backwards_in_open_time_is_a_terminal_error() {
let server = MockServer::start().await;
mount_page(
&server,
"2024-01-01 00:00:00",
&[
equity_row("2024-01-03 00:00:00.000000", "12.0"),
equity_row("2024-01-02 00:00:00.000000", "11.0"),
equity_row("2024-01-01 00:00:00.000000", "10.0"),
],
)
.await;
let error = client(&server)
.collect_candles(
"AAPL",
CandleInterval::Day1,
utc("2024-01-02T00:00:00Z"),
utc("2024-01-04T00:00:00Z"),
)
.await
.unwrap_err();
assert!(
matches!(error, LseError::NonMonotonicCandlePage { .. }),
"expected NonMonotonicCandlePage, got {error:?}"
);
}
#[tokio::test]
async fn candles_spaced_closer_than_the_requested_interval_are_rejected() {
let server = MockServer::start().await;
mount_page(
&server,
"2024-01-01 00:00:00",
&[
equity_row("2024-01-01 00:00:00.000000", "10.0"),
equity_row("2024-01-01 00:01:00.000000", "10.1"),
equity_row("2024-01-01 00:02:00.000000", "10.2"),
],
)
.await;
let error = client(&server)
.collect_candles(
"AAPL",
CandleInterval::Day1,
utc("2024-01-02T00:00:00Z"),
utc("2024-01-04T00:00:00Z"),
)
.await
.unwrap_err();
assert!(
matches!(error, LseError::UnexpectedCandleResolution { .. }),
"expected UnexpectedCandleResolution, got {error:?}"
);
}
#[tokio::test]
async fn a_gap_wider_than_the_interval_is_accepted() {
let server = MockServer::start().await;
mount_page(
&server,
"2024-01-05 00:00:00",
&[
equity_row("2024-01-05 00:00:00.000000", "10.0"),
equity_row("2024-01-08 00:00:00.000000", "11.0"),
],
)
.await;
mount_page(&server, "2024-01-08 00:00:01", &[]).await;
let candles = client(&server)
.collect_candles(
"AAPL",
CandleInterval::Day1,
utc("2024-01-06T00:00:00Z"),
utc("2024-01-10T00:00:00Z"),
)
.await
.unwrap();
assert_eq!(
candles.len(),
2,
"a weekend gap is not a resolution failure"
);
}