use crate::exchange::lse::PROVIDER_TIMESTAMP_FORMAT;
use crate::exchange::lse::error::LseError;
use crate::exchange::lse::market::LseDataset;
use crate::exchange::lse::vault::LseVaultClient;
use chrono::{DateTime, NaiveDateTime, Utc};
use serde::Deserialize;
use smol_str::SmolStr;
const CATALOG_PATH: &str = "catalog";
#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub enum LseDatasetClass {
Price,
Reference,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct LseCatalogEntry {
pub dataset: SmolStr,
pub symbol: SmolStr,
pub name: String,
pub ticks: u64,
pub first_tick: String,
pub last_tick: String,
pub years: f64,
pub last_value: f64,
pub change_pct: f64,
pub change_1y: f64,
#[serde(default)]
pub unit: SmolStr,
#[serde(default)]
pub source: SmolStr,
#[serde(default)]
pub category: SmolStr,
#[serde(default)]
pub frequency: SmolStr,
#[serde(default)]
pub country: SmolStr,
#[serde(default)]
pub country_name: String,
pub live: u8,
}
impl LseCatalogEntry {
#[must_use]
pub fn class(&self) -> LseDatasetClass {
if self.frequency.is_empty() && self.category.is_empty() {
LseDatasetClass::Price
} else {
LseDatasetClass::Reference
}
}
#[must_use]
pub fn price_dataset(&self) -> Option<LseDataset> {
LseDataset::from_catalog_str(&self.dataset).ok()
}
#[must_use]
pub fn is_live(&self) -> bool {
self.live != 0
}
pub fn first_tick_time(&self) -> Result<DateTime<Utc>, LseError> {
parse_catalog_timestamp(&self.first_tick, "first_tick")
}
pub fn last_tick_time(&self) -> Result<DateTime<Utc>, LseError> {
parse_catalog_timestamp(&self.last_tick, "last_tick")
}
}
fn parse_catalog_timestamp(raw: &str, field: &'static str) -> Result<DateTime<Utc>, LseError> {
NaiveDateTime::parse_from_str(raw, PROVIDER_TIMESTAMP_FORMAT)
.map(|naive| naive.and_utc())
.map_err(|error| LseError::Deserialize {
message: format!("invalid catalog {field} {raw:?}: {error}"),
})
}
impl LseVaultClient {
pub async fn fetch_catalog(&self) -> Result<Vec<LseCatalogEntry>, LseError> {
self.get_json(CATALOG_PATH, &[]).await
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
use super::*;
fn price_json() -> &'static str {
r#"{
"dataset": "crypto", "symbol": "AAA/BBB", "name": "Example",
"ticks": 4145394353, "first_tick": "2017-08-17 04:00:28.322000",
"last_tick": "2026-09-21 04:32:26.473000", "years": 9.1,
"last_value": 1.5, "change_pct": 0.08, "change_1y": -29.5,
"unit": "", "source": "", "category": "", "frequency": "",
"country": "", "country_name": "", "live": 1
}"#
}
fn reference_json() -> &'static str {
r#"{
"dataset": "economics", "symbol": "exampleind", "name": "Example Indicator",
"ticks": 412, "first_tick": "1990-01-02 00:00:00.000000",
"last_tick": "2026-08-01 00:00:00.000000", "years": 36,
"last_value": 3.25, "change_pct": 0.1, "change_1y": 2,
"unit": "percent", "source": "World Bank", "category": "Inflation Index",
"frequency": "monthly", "country": "GB", "country_name": "United Kingdom",
"live": 0
}"#
}
fn parse(json: &str) -> LseCatalogEntry {
serde_json::from_str(json).expect("catalog entry should decode")
}
#[test]
fn a_price_entry_classifies_as_price_and_resolves_its_dataset() {
let entry = parse(price_json());
assert_eq!(entry.class(), LseDatasetClass::Price);
assert_eq!(entry.price_dataset(), Some(LseDataset::Crypto));
assert!(entry.is_live());
}
#[test]
fn a_reference_entry_classifies_as_reference_and_has_no_dataset() {
let entry = parse(reference_json());
assert_eq!(entry.class(), LseDatasetClass::Reference);
assert_eq!(entry.price_dataset(), None);
assert!(!entry.is_live());
}
#[test]
fn the_measured_tick_count_leaves_too_little_u32_headroom_to_narrow_the_field() {
let measured = parse(price_json()).ticks;
assert_eq!(measured, 4_145_394_353);
let headroom = u64::from(u32::MAX) - measured;
assert!(
headroom * 20 < u64::from(u32::MAX),
"{headroom} of headroom is more than 5% of u32::MAX -- re-check the claim"
);
}
#[test]
fn catalog_timestamps_parse_as_utc() {
let entry = parse(price_json());
let first = entry.first_tick_time().expect("first_tick should parse");
assert_eq!(first.to_rfc3339(), "2017-08-17T04:00:28.322+00:00");
assert!(entry.last_tick_time().is_ok());
}
#[test]
fn a_malformed_timestamp_is_a_typed_error_naming_its_field() {
let mut entry = parse(price_json());
entry.first_tick = "17/08/2017".to_owned();
let error = entry
.first_tick_time()
.expect_err("a bad timestamp should not parse");
assert!(matches!(error, LseError::Deserialize { .. }));
assert!(error.to_string().contains("first_tick"), "{error}");
assert!(entry.last_tick_time().is_ok());
}
#[test]
fn a_seconds_only_timestamp_still_parses() {
let mut entry = parse(price_json());
entry.first_tick = "2017-08-17 04:00:28".to_owned();
assert!(entry.first_tick_time().is_ok());
}
#[test]
fn the_six_descriptive_fields_may_be_absent_entirely() {
let json = r#"{
"dataset": "fx", "symbol": "AAA/BBB", "name": "Example",
"ticks": 1, "first_tick": "2020-01-01 00:00:00.000000",
"last_tick": "2020-01-02 00:00:00.000000", "years": 1,
"last_value": 1, "change_pct": 0, "change_1y": 0, "live": 1
}"#;
let entry = parse(json);
assert_eq!(entry.class(), LseDatasetClass::Price);
assert!(entry.unit.is_empty());
assert!(entry.country.is_empty());
}
#[test]
fn an_unknown_field_does_not_break_decoding() {
let json = price_json().replace(r#""live": 1"#, r#""live": 1, "added_later": "whatever""#);
assert_eq!(parse(&json).class(), LseDatasetClass::Price);
}
#[test]
fn integer_and_float_spellings_of_the_same_number_both_decode() {
let entry = parse(reference_json());
assert!((entry.years - 36.0).abs() < f64::EPSILON);
assert!((entry.change_1y - 2.0).abs() < f64::EPSILON);
}
#[test]
fn a_dirty_frequency_spelling_is_preserved_rather_than_normalised() {
for spelling in ["bi-annually", "quarter", "biweekly", "something-new"] {
let json = reference_json().replace(
r#""frequency": "monthly""#,
&format!(r#""frequency": "{spelling}""#),
);
let entry = parse(&json);
assert_eq!(entry.frequency, spelling);
assert_eq!(entry.class(), LseDatasetClass::Reference);
}
}
#[test]
fn an_options_entry_is_a_price_dataset_that_still_resolves_to_no_variant() {
let json = price_json().replace(r#""dataset": "crypto""#, r#""dataset": "options""#);
let entry = parse(&json);
assert_eq!(entry.class(), LseDatasetClass::Price);
assert_eq!(entry.price_dataset(), None);
}
#[test]
fn an_entry_populating_only_category_is_treated_as_reference() {
let json = price_json().replace(r#""category": """#, r#""category": "Credit index""#);
assert_eq!(parse(&json).class(), LseDatasetClass::Reference);
}
}