use crate::exchange::lse::data_api::LseDataApiClient;
use crate::exchange::lse::error::LseError;
use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::Deserialize;
use smol_str::SmolStr;
use std::collections::BTreeMap;
const BOND_YIELDS_PATH: &str = "bond-yields";
const BOND_YIELDS_STATS_PATH: &str = "bond-yields/stats";
const DATE_FORMAT: &str = "%Y-%m-%d";
const JSON_FORMAT: &str = "json";
const UK_CODE: &str = "UK";
const GB_CODE: &str = "GB";
fn parse_date(value: &str, field: &str) -> Result<NaiveDate, LseError> {
NaiveDate::parse_from_str(value, DATE_FORMAT).map_err(|error| LseError::Deserialize {
message: format!("invalid bond-yield {field} {value:?}: {error}"),
})
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct LseBondYieldRow {
pub date: String,
pub symbol: SmolStr,
pub country_iso2: SmolStr,
pub country_name: String,
pub maturity: SmolStr,
pub maturity_days: SmolStr,
pub currency: SmolStr,
#[serde(with = "rust_decimal::serde::str")]
pub open: Decimal,
#[serde(with = "rust_decimal::serde::str")]
pub high: Decimal,
#[serde(with = "rust_decimal::serde::str")]
pub low: Decimal,
#[serde(with = "rust_decimal::serde::str")]
pub close: Decimal,
}
impl LseBondYieldRow {
pub fn observation_date(&self) -> Result<NaiveDate, LseError> {
parse_date(&self.date, "date")
}
pub fn maturity_days_value(&self) -> Result<u32, LseError> {
self.maturity_days
.parse()
.map_err(|error| LseError::Deserialize {
message: format!(
"invalid bond-yield maturity_days {:?}: {error}",
self.maturity_days
),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct LseBondYieldStats {
pub total_countries: u32,
pub total_observations: u64,
pub columns: Vec<SmolStr>,
pub countries: BTreeMap<SmolStr, LseBondYieldCountry>,
}
impl LseBondYieldStats {
#[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 country(&self, code: &str) -> Option<&LseBondYieldCountry> {
self.countries.get(&Self::normalise_country(code))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct LseBondYieldCountry {
pub code: SmolStr,
pub name: String,
pub currency: SmolStr,
pub first_date: String,
pub last_date: String,
pub observations: u64,
pub maturities: Vec<LseBondYieldMaturity>,
}
impl LseBondYieldCountry {
#[must_use]
pub fn maturity(&self, tenor: &str) -> Option<&LseBondYieldMaturity> {
let wanted = tenor.trim();
self.maturities
.iter()
.find(|maturity| maturity.tenor.eq_ignore_ascii_case(wanted))
}
pub fn first_date_value(&self) -> Result<NaiveDate, LseError> {
parse_date(&self.first_date, "first_date")
}
pub fn last_date_value(&self) -> Result<NaiveDate, LseError> {
parse_date(&self.last_date, "last_date")
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct LseBondYieldMaturity {
pub tenor: SmolStr,
pub days: u32,
pub first_date: String,
pub last_date: String,
pub observations: u64,
}
impl LseBondYieldMaturity {
pub fn first_date_value(&self) -> Result<NaiveDate, LseError> {
parse_date(&self.first_date, "first_date")
}
pub fn last_date_value(&self) -> Result<NaiveDate, LseError> {
parse_date(&self.last_date, "last_date")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct LseBondYieldQuery {
pub country: SmolStr,
pub maturity: Option<SmolStr>,
pub start: NaiveDate,
pub end: NaiveDate,
}
impl LseBondYieldQuery {
#[must_use]
pub fn new(country: impl Into<SmolStr>, start: NaiveDate, end: NaiveDate) -> Self {
Self {
country: country.into(),
maturity: None,
start,
end,
}
}
#[must_use]
pub fn with_maturity(mut self, maturity: impl Into<SmolStr>) -> Self {
self.maturity = Some(maturity.into());
self
}
pub fn validate(&self, stats: &LseBondYieldStats) -> Result<(), LseError> {
self.resolve(stats).map(|_| ())
}
fn resolve(&self, stats: &LseBondYieldStats) -> Result<SmolStr, LseError> {
if self.end < self.start {
return Err(LseError::InvalidInput {
message: format!(
"bond-yield range end {} precedes start {}",
self.end, self.start
),
});
}
let normalised = LseBondYieldStats::normalise_country(&self.country);
let country =
stats
.countries
.get(&normalised)
.ok_or_else(|| LseError::UnknownBondYieldCountry {
requested: self.country.to_string(),
normalised: normalised.to_string(),
})?;
let (first, last) = match &self.maturity {
Some(tenor) => {
let maturity =
country
.maturity(tenor)
.ok_or_else(|| LseError::UnknownBondYieldMaturity {
country: normalised.to_string(),
requested: tenor.to_string(),
available: country
.maturities
.iter()
.map(|maturity| maturity.tenor.to_string())
.collect(),
})?;
(maturity.first_date_value()?, maturity.last_date_value()?)
}
None => (country.first_date_value()?, country.last_date_value()?),
};
if self.end < first || self.start > last {
return Err(LseError::BondYieldRangeOutsideCoverage {
country: normalised.to_string(),
maturity: self.maturity.as_ref().map(ToString::to_string),
start: self.start,
end: self.end,
first_date: first,
last_date: last,
});
}
Ok(normalised)
}
}
#[derive(Debug, Deserialize)]
struct LseBondYieldEnvelope {
count: usize,
data: Vec<LseBondYieldRow>,
}
impl LseDataApiClient {
pub async fn fetch_bond_yield_stats(&self) -> Result<LseBondYieldStats, LseError> {
self.get_json(
BOND_YIELDS_STATS_PATH,
&[("format", JSON_FORMAT.to_string())],
)
.await
}
pub async fn fetch_bond_yields(
&self,
stats: &LseBondYieldStats,
query: &LseBondYieldQuery,
) -> Result<Vec<LseBondYieldRow>, LseError> {
let country = query.resolve(stats)?;
let mut params = vec![
("country", country.to_string()),
("start_date", query.start.format(DATE_FORMAT).to_string()),
("end_date", query.end.format(DATE_FORMAT).to_string()),
("format", JSON_FORMAT.to_string()),
];
if let Some(maturity) = &query.maturity {
params.push(("maturity", maturity.to_string()));
}
let envelope: LseBondYieldEnvelope = self.get_json(BOND_YIELDS_PATH, ¶ms).await?;
if envelope.count != envelope.data.len() {
return Err(LseError::Api {
status: 200,
message: format!(
"bond-yield response declared {} rows 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() -> LseBondYieldStats {
serde_json::from_str(
r#"{
"total_countries": 2,
"total_observations": 30,
"columns": ["date", "symbol", "country_iso2", "country_name", "maturity",
"maturity_days", "currency", "open", "high", "low", "close"],
"countries": {
"UK": {
"code": "UK", "name": "United Kingdom", "currency": "GBP",
"first_date": "2020-01-01", "last_date": "2024-12-31", "observations": 10,
"maturities": [
{"tenor": "5Y", "days": 1825, "first_date": "2020-01-01",
"last_date": "2024-12-31", "observations": 10}
]
},
"US": {
"code": "US", "name": "United States", "currency": "USD",
"first_date": "2000-01-01", "last_date": "2024-12-31", "observations": 20,
"maturities": [
{"tenor": "10Y", "days": 3650, "first_date": "2000-01-01",
"last_date": "2024-12-31", "observations": 15},
{"tenor": "10Y TIPS", "days": 3650, "first_date": "2024-06-01",
"last_date": "2024-12-31", "observations": 5}
]
}
}
}"#,
)
.unwrap()
}
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).unwrap()
}
#[test]
fn a_row_decodes_with_every_field_arriving_as_a_string() {
let row: LseBondYieldRow = serde_json::from_str(
r#"{"date":"2024-03-14","symbol":"UK5Y","country_iso2":"UK",
"country_name":"United Kingdom","maturity":"5Y","maturity_days":"1825",
"currency":"GBP","open":"1.2500","high":"1.3750","low":"1.2000","close":"1.3125"}"#,
)
.unwrap();
assert_eq!(row.open, dec!(1.2500));
assert_eq!(row.high, dec!(1.3750));
assert_eq!(row.low, dec!(1.2000));
assert_eq!(row.close, dec!(1.3125));
assert_eq!(row.observation_date().unwrap(), date(2024, 3, 14));
assert_eq!(row.maturity_days_value().unwrap(), 1825);
}
#[test]
fn a_row_ignores_an_unknown_field() {
let row: LseBondYieldRow = serde_json::from_str(
r#"{"date":"2024-03-14","symbol":"UK5Y","country_iso2":"UK",
"country_name":"United Kingdom","maturity":"5Y","maturity_days":"1825",
"currency":"GBP","open":"1.0","high":"1.0","low":"1.0","close":"1.0",
"some_new_field":"whatever"}"#,
)
.unwrap();
assert_eq!(row.symbol, "UK5Y");
}
#[test]
fn a_malformed_date_is_a_typed_error_naming_the_field() {
let row = LseBondYieldRow {
date: "14/03/2024".to_string(),
symbol: "UK5Y".into(),
country_iso2: "UK".into(),
country_name: "United Kingdom".to_string(),
maturity: "5Y".into(),
maturity_days: "not-a-number".into(),
currency: "GBP".into(),
open: dec!(1),
high: dec!(1),
low: dec!(1),
close: dec!(1),
};
let error = row.observation_date().unwrap_err();
assert!(matches!(error, LseError::Deserialize { .. }));
assert!(error.to_string().contains("date"), "{error}");
let error = row.maturity_days_value().unwrap_err();
assert!(matches!(error, LseError::Deserialize { .. }));
assert!(error.to_string().contains("maturity_days"), "{error}");
}
#[test]
fn gb_normalises_to_the_providers_uk_and_everything_else_is_untouched() {
assert_eq!(LseBondYieldStats::normalise_country("GB"), "UK");
assert_eq!(LseBondYieldStats::normalise_country("gb"), "UK");
assert_eq!(LseBondYieldStats::normalise_country(" Gb "), "UK");
assert_eq!(LseBondYieldStats::normalise_country("UK"), "UK");
assert_eq!(LseBondYieldStats::normalise_country("us"), "US");
assert_eq!(LseBondYieldStats::normalise_country("DE"), "DE");
}
#[test]
fn a_country_lookup_finds_the_uk_under_its_iso_spelling() {
let stats = stats();
assert_eq!(stats.country("GB").unwrap().name, "United Kingdom");
assert_eq!(stats.country("gb").unwrap().name, "United Kingdom");
assert_eq!(stats.country("UK").unwrap().name, "United Kingdom");
assert!(stats.country("ZZ").is_none());
assert!(!stats.countries.contains_key("GB"));
}
#[test]
fn a_tenor_matches_ignoring_case_but_not_ignoring_its_inner_space() {
let stats = stats();
let us = stats.country("US").unwrap();
assert_eq!(us.maturity("10Y TIPS").unwrap().tenor, "10Y TIPS");
assert_eq!(us.maturity("10y tips").unwrap().tenor, "10Y TIPS");
assert_eq!(us.maturity(" 10Y TIPS ").unwrap().tenor, "10Y TIPS");
assert!(us.maturity("10YTIPS").is_none());
assert!(us.maturity("99Y").is_none());
}
#[test]
fn two_tenors_of_one_country_share_a_day_count() {
let stats = stats();
let us = stats.country("US").unwrap();
assert_eq!(us.maturity("10Y").unwrap().days, 3650);
assert_eq!(us.maturity("10Y TIPS").unwrap().days, 3650);
}
#[test]
fn a_valid_query_passes() {
let stats = stats();
let query =
LseBondYieldQuery::new("UK", date(2021, 1, 1), date(2022, 1, 1)).with_maturity("5Y");
assert!(query.validate(&stats).is_ok());
}
#[test]
fn an_inverted_range_is_rejected() {
let stats = stats();
let query = LseBondYieldQuery::new("UK", date(2022, 1, 1), date(2021, 1, 1));
assert!(matches!(
query.validate(&stats).unwrap_err(),
LseError::InvalidInput { .. }
));
}
#[test]
fn an_unknown_country_is_rejected_before_the_request_is_sent() {
let stats = stats();
let query = LseBondYieldQuery::new("ZZ", date(2021, 1, 1), date(2022, 1, 1));
let error = query.validate(&stats).unwrap_err();
assert!(matches!(error, LseError::UnknownBondYieldCountry { .. }));
let rendered = error.to_string();
assert!(rendered.contains("ZZ"), "{rendered}");
}
#[test]
fn an_unknown_country_error_reports_both_the_requested_and_the_normalised_spelling() {
let stats = stats();
let query = LseBondYieldQuery::new("gb-typo", date(2021, 1, 1), date(2022, 1, 1));
match query.validate(&stats).unwrap_err() {
LseError::UnknownBondYieldCountry {
requested,
normalised,
} => {
assert_eq!(requested, "gb-typo");
assert_eq!(normalised, "GB-TYPO");
}
other => panic!("expected UnknownBondYieldCountry, got {other}"),
}
}
#[test]
fn an_unknown_maturity_is_rejected_and_lists_the_published_tenors() {
let stats = stats();
let query =
LseBondYieldQuery::new("US", date(2021, 1, 1), date(2022, 1, 1)).with_maturity("99Y");
match query.validate(&stats).unwrap_err() {
LseError::UnknownBondYieldMaturity {
country,
requested,
available,
} => {
assert_eq!(country, "US");
assert_eq!(requested, "99Y");
assert_eq!(available, vec!["10Y".to_string(), "10Y TIPS".to_string()]);
}
other => panic!("expected UnknownBondYieldMaturity, got {other}"),
}
}
#[test]
fn a_range_before_a_tenors_own_coverage_is_rejected_though_the_country_spans_it() {
let stats = stats();
let us = stats.country("US").unwrap();
assert!(us.first_date_value().unwrap() < date(2021, 1, 1));
let query = LseBondYieldQuery::new("US", date(2021, 1, 1), date(2021, 12, 31))
.with_maturity("10Y TIPS");
match query.validate(&stats).unwrap_err() {
LseError::BondYieldRangeOutsideCoverage {
country,
maturity,
first_date,
..
} => {
assert_eq!(country, "US");
assert_eq!(maturity.as_deref(), Some("10Y TIPS"));
assert_eq!(first_date, date(2024, 6, 1));
}
other => panic!("expected BondYieldRangeOutsideCoverage, got {other}"),
}
assert!(
LseBondYieldQuery::new("US", date(2021, 1, 1), date(2021, 12, 31))
.with_maturity("10Y")
.validate(&stats)
.is_ok()
);
}
#[test]
fn a_range_after_the_published_coverage_is_rejected() {
let stats = stats();
let query = LseBondYieldQuery::new("UK", date(2030, 1, 1), date(2031, 1, 1));
assert!(matches!(
query.validate(&stats).unwrap_err(),
LseError::BondYieldRangeOutsideCoverage { .. }
));
}
#[test]
fn a_partially_overlapping_range_passes() {
let stats = stats();
assert!(
LseBondYieldQuery::new("UK", date(1990, 1, 1), date(2021, 1, 1))
.validate(&stats)
.is_ok()
);
assert!(
LseBondYieldQuery::new("UK", date(2024, 1, 1), date(2099, 1, 1))
.validate(&stats)
.is_ok()
);
assert!(
LseBondYieldQuery::new("UK", date(2020, 1, 1), date(2020, 1, 1))
.validate(&stats)
.is_ok()
);
}
#[test]
fn a_query_without_a_maturity_is_bounded_by_the_countrys_own_coverage() {
let stats = stats();
assert!(
LseBondYieldQuery::new("US", date(2021, 1, 1), date(2021, 12, 31))
.validate(&stats)
.is_ok()
);
match LseBondYieldQuery::new("US", date(1950, 1, 1), date(1960, 1, 1))
.validate(&stats)
.unwrap_err()
{
LseError::BondYieldRangeOutsideCoverage { maturity, .. } => assert!(maturity.is_none()),
other => panic!("expected BondYieldRangeOutsideCoverage, got {other}"),
}
}
#[test]
fn validation_yields_the_normalised_country_code_the_request_is_sent_with() {
let stats = stats();
let query = LseBondYieldQuery::new("GB", date(2021, 1, 1), date(2022, 1, 1));
assert_eq!(query.resolve(&stats).unwrap(), "UK");
}
#[test]
fn stats_decode_with_their_integer_typed_counts() {
let stats = stats();
assert_eq!(stats.total_countries, 2);
assert_eq!(stats.total_observations, 30);
assert_eq!(stats.columns.len(), 11);
assert_eq!(stats.columns.first().unwrap(), "date");
assert_eq!(stats.country("US").unwrap().observations, 20);
assert_eq!(stats.country("US").unwrap().maturities.len(), 2);
}
#[test]
fn a_malformed_stats_date_fails_validation_rather_than_being_ignored() {
let mut stats = stats();
stats
.countries
.get_mut("UK")
.unwrap()
.maturities
.get_mut(0)
.unwrap()
.first_date = "not-a-date".to_string();
let query =
LseBondYieldQuery::new("UK", date(2021, 1, 1), date(2022, 1, 1)).with_maturity("5Y");
assert!(matches!(
query.validate(&stats).unwrap_err(),
LseError::Deserialize { .. }
));
}
}