use crate::exchange::lse::data_api::LseDataApiClient;
use crate::exchange::lse::error::LseError;
use chrono::{DateTime, NaiveDate, Utc};
use rust_decimal::Decimal;
use serde::Deserialize;
use smol_str::SmolStr;
const ECONOMIC_CALENDAR_PATH: &str = "economic-calendar";
const ECONOMIC_CALENDAR_STATS_PATH: &str = "economic-calendar/stats";
const DATE_FORMAT: &str = "%Y-%m-%d";
const JSON_FORMAT: &str = "json";
const COUNTRY_SEPARATOR: &str = ",";
const UK_CODE: &str = "UK";
const GB_CODE: &str = "GB";
fn parse_instant(value: &str, field: &str) -> Result<DateTime<Utc>, LseError> {
DateTime::parse_from_rfc3339(value)
.map(|parsed| parsed.with_timezone(&Utc))
.map_err(|error| LseError::Deserialize {
message: format!("invalid economic-calendar {field} {value:?}: {error}"),
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)]
pub enum LseCalendarImpact {
High,
Medium,
Low,
None,
}
impl LseCalendarImpact {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::High => "High",
Self::Medium => "Medium",
Self::Low => "Low",
Self::None => "None",
}
}
}
impl std::fmt::Display for LseCalendarImpact {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct LseCalendarEvent {
pub event_date: String,
pub country: SmolStr,
pub event: String,
pub currency: Option<SmolStr>,
#[serde(with = "rust_decimal::serde::str_option")]
pub previous: Option<Decimal>,
#[serde(with = "rust_decimal::serde::str_option")]
pub estimate: Option<Decimal>,
#[serde(with = "rust_decimal::serde::str_option")]
pub actual: Option<Decimal>,
#[serde(with = "rust_decimal::serde::str_option")]
pub change: Option<Decimal>,
#[serde(with = "rust_decimal::serde::str_option")]
pub change_percentage: Option<Decimal>,
pub impact: LseCalendarImpact,
pub unit: Option<SmolStr>,
}
impl LseCalendarEvent {
pub fn event_time(&self) -> Result<DateTime<Utc>, LseError> {
parse_instant(&self.event_date, "event_date")
}
pub fn event_day(&self) -> Result<NaiveDate, LseError> {
Ok(self.event_time()?.date_naive())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct LseCalendarStats {
pub countries: Vec<SmolStr>,
pub impacts: Vec<SmolStr>,
pub earliest: String,
pub latest: String,
pub total_events: u64,
}
impl LseCalendarStats {
#[must_use]
pub fn normalise_country(code: &str) -> SmolStr {
let upper = code.trim().to_ascii_uppercase();
if upper == GB_CODE {
SmolStr::new_static(UK_CODE)
} else {
SmolStr::from(upper)
}
}
#[must_use]
pub fn publishes_country(&self, code: &str) -> bool {
let normalised = Self::normalise_country(code);
self.countries.iter().any(|known| known == &normalised)
}
#[must_use]
pub fn resolve_impact(&self, impact: &str) -> Option<&SmolStr> {
let wanted = impact.trim();
self.impacts
.iter()
.find(|known| known.eq_ignore_ascii_case(wanted))
}
pub fn earliest_time(&self) -> Result<DateTime<Utc>, LseError> {
parse_instant(&self.earliest, "earliest")
}
pub fn latest_time(&self) -> Result<DateTime<Utc>, LseError> {
parse_instant(&self.latest, "latest")
}
pub fn earliest_date(&self) -> Result<NaiveDate, LseError> {
Ok(self.earliest_time()?.date_naive())
}
pub fn latest_date(&self) -> Result<NaiveDate, LseError> {
Ok(self.latest_time()?.date_naive())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct LseCalendarQuery {
pub countries: Vec<SmolStr>,
pub impact: Option<SmolStr>,
pub start: NaiveDate,
pub end: NaiveDate,
}
impl LseCalendarQuery {
#[must_use]
pub fn new(start: NaiveDate, end: NaiveDate) -> Self {
Self {
countries: Vec::new(),
impact: None,
start,
end,
}
}
#[must_use]
pub fn with_country(mut self, country: impl Into<SmolStr>) -> Self {
self.countries.push(country.into());
self
}
#[must_use]
pub fn with_countries<I, C>(mut self, countries: I) -> Self
where
I: IntoIterator<Item = C>,
C: Into<SmolStr>,
{
self.countries.extend(countries.into_iter().map(Into::into));
self
}
#[must_use]
pub fn with_impact(mut self, impact: impl Into<SmolStr>) -> Self {
self.impact = Some(impact.into());
self
}
pub fn validate(&self, stats: &LseCalendarStats) -> Result<(), LseError> {
self.resolve(stats).map(|_| ())
}
fn resolve(&self, stats: &LseCalendarStats) -> Result<Vec<(&'static str, String)>, LseError> {
if self.end < self.start {
return Err(LseError::InvalidInput {
message: format!(
"economic-calendar range end {} precedes start {}",
self.end, self.start
),
});
}
let mut normalised = Vec::with_capacity(self.countries.len());
for country in &self.countries {
let code = LseCalendarStats::normalise_country(country);
if !stats.countries.iter().any(|known| known == &code) {
return Err(LseError::UnknownCalendarCountry {
requested: country.to_string(),
normalised: code.to_string(),
});
}
normalised.push(code);
}
let impact = match &self.impact {
Some(requested) => Some(
stats
.resolve_impact(requested)
.ok_or_else(|| LseError::UnknownCalendarImpact {
requested: requested.to_string(),
available: stats.impacts.iter().map(ToString::to_string).collect(),
})?
.to_string(),
),
None => None,
};
let earliest = stats.earliest_date()?;
let latest = stats.latest_date()?;
if self.end < earliest || self.start > latest {
return Err(LseError::CalendarRangeOutsideCoverage {
start: self.start,
end: self.end,
earliest,
latest,
});
}
let mut params = vec![
("start_date", self.start.format(DATE_FORMAT).to_string()),
("end_date", self.end.format(DATE_FORMAT).to_string()),
("format", JSON_FORMAT.to_string()),
];
if !normalised.is_empty() {
params.push((
"country",
normalised
.iter()
.map(SmolStr::as_str)
.collect::<Vec<_>>()
.join(COUNTRY_SEPARATOR),
));
}
if let Some(impact) = impact {
params.push(("impact", impact));
}
Ok(params)
}
}
#[derive(Debug, Deserialize)]
struct LseCalendarEnvelope {
count: usize,
data: Vec<LseCalendarEvent>,
}
impl LseDataApiClient {
pub async fn fetch_economic_calendar_stats(&self) -> Result<LseCalendarStats, LseError> {
self.get_json(
ECONOMIC_CALENDAR_STATS_PATH,
&[("format", JSON_FORMAT.to_string())],
)
.await
}
pub async fn fetch_economic_calendar(
&self,
stats: &LseCalendarStats,
query: &LseCalendarQuery,
) -> Result<Vec<LseCalendarEvent>, LseError> {
let params = query.resolve(stats)?;
let envelope: LseCalendarEnvelope = self.get_json(ECONOMIC_CALENDAR_PATH, ¶ms).await?;
if envelope.count != envelope.data.len() {
return Err(LseError::Api {
status: 200,
message: format!(
"economic-calendar response declared {} events but carried {}: the response \
appears to have been truncated",
envelope.count,
envelope.data.len()
),
});
}
Ok(envelope.data)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
use super::*;
use rust_decimal_macros::dec;
fn stats() -> LseCalendarStats {
LseCalendarStats {
countries: ["EA", "EU", "JP", "UK", "US"]
.into_iter()
.map(SmolStr::new)
.collect(),
impacts: ["High", "Low", "Medium", "None"]
.into_iter()
.map(SmolStr::new)
.collect(),
earliest: "2014-12-31 23:00:00+00:00".to_string(),
latest: "2026-03-24 20:00:00+00:00".to_string(),
total_events: 124_896,
}
}
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
}
fn params(query: &LseCalendarQuery) -> Vec<(&'static str, String)> {
query.resolve(&stats()).expect("query should validate")
}
fn param<'a>(params: &'a [(&'static str, String)], key: &str) -> Option<&'a str> {
params
.iter()
.find(|(name, _)| *name == key)
.map(|(_, value)| value.as_str())
}
#[test]
fn an_event_decodes_with_string_encoded_numerics_and_null_absences() {
let json = r#"{
"event_date": "2026-03-24 14:00:00+00:00",
"country": "US",
"event": "CPI (YoY)",
"currency": "USD",
"previous": "2.9",
"estimate": null,
"actual": "-3.1",
"change": "-0.2",
"change_percentage": "-6.9",
"impact": "High",
"unit": "%"
}"#;
let event: LseCalendarEvent = serde_json::from_str(json).unwrap();
assert_eq!(event.country, "US");
assert_eq!(event.event, "CPI (YoY)");
assert_eq!(event.currency.as_deref(), Some("USD"));
assert_eq!(event.previous, Some(dec!(2.9)));
assert_eq!(event.estimate, None);
assert_eq!(event.actual, Some(dec!(-3.1)));
assert_eq!(event.change, Some(dec!(-0.2)));
assert_eq!(event.change_percentage, Some(dec!(-6.9)));
assert_eq!(event.impact, LseCalendarImpact::High);
assert_eq!(event.unit.as_deref(), Some("%"));
}
#[test]
fn an_impact_of_none_is_a_rating_and_not_an_absent_value() {
let json = r#"{
"event_date": "2026-03-24 14:00:00+00:00",
"country": "JP", "event": "Bank Holiday", "currency": null,
"previous": null, "estimate": null, "actual": null,
"change": null, "change_percentage": null,
"impact": "None", "unit": null
}"#;
let event: LseCalendarEvent = serde_json::from_str(json).unwrap();
assert_eq!(event.impact, LseCalendarImpact::None);
assert_eq!(event.impact.as_str(), "None");
assert_eq!(event.impact.to_string(), "None");
assert_eq!(event.currency, None);
assert_eq!(event.unit, None);
}
#[test]
fn an_unknown_impact_rating_fails_the_decode_rather_than_being_absorbed() {
let json = r#"{
"event_date": "2026-03-24 14:00:00+00:00",
"country": "US", "event": "x", "currency": null,
"previous": null, "estimate": null, "actual": null,
"change": null, "change_percentage": null,
"impact": "Critical", "unit": null
}"#;
assert!(serde_json::from_str::<LseCalendarEvent>(json).is_err());
}
#[test]
fn an_event_ignores_an_unknown_field() {
let json = r#"{
"event_date": "2026-03-24 14:00:00+00:00",
"country": "US", "event": "x", "currency": null,
"previous": null, "estimate": null, "actual": null,
"change": null, "change_percentage": null,
"impact": "Low", "unit": null,
"a_field_the_provider_added_later": 1
}"#;
assert!(serde_json::from_str::<LseCalendarEvent>(json).is_ok());
}
#[test]
fn an_event_date_parses_as_an_instant_keeping_its_time_of_day() {
let event = LseCalendarEvent {
event_date: "2026-03-24 14:30:00+00:00".to_string(),
country: SmolStr::new("US"),
event: "x".to_string(),
currency: None,
previous: None,
estimate: None,
actual: None,
change: None,
change_percentage: None,
impact: LseCalendarImpact::Low,
unit: None,
};
let time = event.event_time().unwrap();
assert_eq!(time.to_rfc3339(), "2026-03-24T14:30:00+00:00");
assert_eq!(event.event_day().unwrap(), date(2026, 3, 24));
}
#[test]
fn a_malformed_event_date_is_a_typed_error_naming_the_field() {
let event = LseCalendarEvent {
event_date: "not-a-date".to_string(),
country: SmolStr::new("US"),
event: "x".to_string(),
currency: None,
previous: None,
estimate: None,
actual: None,
change: None,
change_percentage: None,
impact: LseCalendarImpact::Low,
unit: None,
};
let error = event.event_time().unwrap_err();
assert!(matches!(error, LseError::Deserialize { .. }));
assert!(error.to_string().contains("event_date"), "{error}");
}
#[test]
fn gb_normalises_to_the_providers_uk_and_everything_else_is_untouched() {
assert_eq!(LseCalendarStats::normalise_country("GB"), "UK");
assert_eq!(LseCalendarStats::normalise_country("gb"), "UK");
assert_eq!(LseCalendarStats::normalise_country(" Gb "), "UK");
assert_eq!(LseCalendarStats::normalise_country("us"), "US");
assert_eq!(LseCalendarStats::normalise_country("ea"), "EA");
assert_eq!(LseCalendarStats::normalise_country("eu"), "EU");
}
#[test]
fn a_country_lookup_finds_the_uk_under_its_iso_spelling() {
let stats = stats();
assert!(stats.publishes_country("GB"));
assert!(stats.publishes_country("gb"));
assert!(stats.publishes_country("UK"));
assert!(!stats.publishes_country("ZZ"));
}
#[test]
fn an_impact_resolves_ignoring_case_and_yields_the_providers_spelling() {
let stats = stats();
assert_eq!(stats.resolve_impact("high").unwrap(), "High");
assert_eq!(stats.resolve_impact(" HIGH ").unwrap(), "High");
assert_eq!(stats.resolve_impact("None").unwrap(), "None");
assert!(stats.resolve_impact("Critical").is_none());
}
#[test]
fn a_valid_query_passes() {
let query = LseCalendarQuery::new(date(2024, 1, 1), date(2024, 12, 31)).with_country("US");
assert!(query.validate(&stats()).is_ok());
}
#[test]
fn an_inverted_range_is_rejected() {
let query = LseCalendarQuery::new(date(2024, 12, 31), date(2024, 1, 1));
let error = query.validate(&stats()).unwrap_err();
assert!(matches!(error, LseError::InvalidInput { .. }));
}
#[test]
fn an_unknown_country_is_rejected_and_reports_both_spellings() {
let query = LseCalendarQuery::new(date(2024, 1, 1), date(2024, 12, 31)).with_country("zz");
let error = query.validate(&stats()).unwrap_err();
let LseError::UnknownCalendarCountry {
requested,
normalised,
} = &error
else {
panic!("expected UnknownCalendarCountry, got {error:?}");
};
assert_eq!(requested, "zz");
assert_eq!(normalised, "ZZ");
}
#[test]
fn an_unknown_country_anywhere_in_the_list_is_rejected() {
let query = LseCalendarQuery::new(date(2024, 1, 1), date(2024, 12, 31))
.with_countries(["US", "UK", "ZZ"]);
assert!(matches!(
query.validate(&stats()).unwrap_err(),
LseError::UnknownCalendarCountry { .. }
));
}
#[test]
fn an_unknown_impact_is_rejected_and_lists_the_published_vocabulary() {
let query =
LseCalendarQuery::new(date(2024, 1, 1), date(2024, 12, 31)).with_impact("Critical");
let error = query.validate(&stats()).unwrap_err();
let LseError::UnknownCalendarImpact {
requested,
available,
} = &error
else {
panic!("expected UnknownCalendarImpact, got {error:?}");
};
assert_eq!(requested, "Critical");
assert_eq!(available, &["High", "Low", "Medium", "None"]);
}
#[test]
fn a_window_after_the_frozen_latest_is_rejected() {
let query = LseCalendarQuery::new(date(2026, 4, 1), date(2026, 12, 31));
let error = query.validate(&stats()).unwrap_err();
let LseError::CalendarRangeOutsideCoverage {
start,
end,
earliest,
latest,
} = &error
else {
panic!("expected CalendarRangeOutsideCoverage, got {error:?}");
};
assert_eq!(*start, date(2026, 4, 1));
assert_eq!(*end, date(2026, 12, 31));
assert_eq!(*earliest, date(2014, 12, 31));
assert_eq!(*latest, date(2026, 3, 24));
}
#[test]
fn a_window_before_the_earliest_event_is_rejected() {
let query = LseCalendarQuery::new(date(2010, 1, 1), date(2014, 12, 30));
assert!(matches!(
query.validate(&stats()).unwrap_err(),
LseError::CalendarRangeOutsideCoverage { .. }
));
}
#[test]
fn a_partially_overlapping_range_passes() {
let query = LseCalendarQuery::new(date(2026, 3, 1), date(2026, 12, 31));
assert!(query.validate(&stats()).is_ok());
}
#[test]
fn a_published_country_with_no_events_in_the_window_still_validates() {
let query = LseCalendarQuery::new(date(2020, 1, 1), date(2020, 12, 31)).with_country("UK");
assert!(
query.validate(&stats()).is_ok(),
"an empty-but-legitimate window must not be an error: the provider publishes no \
per-country coverage to justify one"
);
}
#[test]
fn several_countries_are_sent_as_one_comma_joined_parameter() {
let query = LseCalendarQuery::new(date(2024, 1, 1), date(2024, 12, 31))
.with_countries(["US", "UK"]);
let params = params(&query);
assert_eq!(
params.iter().filter(|(name, _)| *name == "country").count(),
1,
"the country key must never be repeated: the provider keeps only the last value"
);
assert_eq!(param(¶ms, "country"), Some("US,UK"));
}
#[test]
fn the_request_carries_the_normalised_country_codes() {
let query = LseCalendarQuery::new(date(2024, 1, 1), date(2024, 12, 31))
.with_countries(["gb", "us"]);
assert_eq!(param(¶ms(&query), "country"), Some("UK,US"));
}
#[test]
fn the_request_carries_the_providers_own_impact_spelling() {
let query =
LseCalendarQuery::new(date(2024, 1, 1), date(2024, 12, 31)).with_impact("medium");
assert_eq!(param(¶ms(&query), "impact"), Some("Medium"));
}
#[test]
fn an_unfiltered_query_omits_country_and_impact_entirely() {
let query = LseCalendarQuery::new(date(2024, 1, 1), date(2024, 12, 31));
let params = params(&query);
assert_eq!(param(¶ms, "country"), None);
assert_eq!(param(¶ms, "impact"), None);
assert_eq!(param(¶ms, "start_date"), Some("2024-01-01"));
assert_eq!(param(¶ms, "end_date"), Some("2024-12-31"));
}
#[test]
fn every_request_asks_for_json_because_the_endpoint_defaults_to_csv() {
let query = LseCalendarQuery::new(date(2024, 1, 1), date(2024, 12, 31));
assert_eq!(param(¶ms(&query), "format"), Some("json"));
}
#[test]
fn stats_decode_with_their_integer_typed_total() {
let json = r#"{
"countries": ["EA", "UK", "US"],
"impacts": ["High", "Low", "Medium", "None"],
"earliest": "2014-12-31 23:00:00+00:00",
"latest": "2026-03-24 20:00:00+00:00",
"total_events": 124896
}"#;
let stats: LseCalendarStats = serde_json::from_str(json).unwrap();
assert_eq!(stats.total_events, 124_896);
assert_eq!(
stats.earliest_time().unwrap(),
date(2014, 12, 31).and_hms_opt(23, 0, 0).unwrap().and_utc()
);
assert_eq!(
stats.latest_time().unwrap(),
date(2026, 3, 24).and_hms_opt(20, 0, 0).unwrap().and_utc()
);
assert_eq!(stats.earliest_date().unwrap(), date(2014, 12, 31));
assert_eq!(stats.latest_date().unwrap(), date(2026, 3, 24));
}
#[test]
fn a_bare_date_stats_bound_is_rejected() {
let mut stats = stats();
stats.earliest = "2014-12-31".to_string();
let error = stats.earliest_date().unwrap_err();
assert!(matches!(error, LseError::Deserialize { .. }));
assert!(error.to_string().contains("earliest"), "{error}");
}
#[test]
fn a_malformed_stats_date_fails_validation_rather_than_being_ignored() {
let mut stats = stats();
stats.latest = "24/03/2026".to_string();
let query = LseCalendarQuery::new(date(2024, 1, 1), date(2024, 12, 31));
let error = query.validate(&stats).unwrap_err();
assert!(matches!(error, LseError::Deserialize { .. }));
assert!(error.to_string().contains("latest"), "{error}");
}
}