rustrade_data/exchange/lse/bond_yield.rs
1//! Sovereign bond yields from the London Strategic Edge data API (`GET /bond-yields`).
2//!
3//! Daily open/high/low/close yields for 34 countries across the tenors each publishes — 716,820
4//! observations at the last measurement. Served by the [data API](super::data_api) host, not by the
5//! [vault](super::vault): a bond-yield symbol has **no candle data** there (`GET
6//! /vault/candles?symbol=UK5Y` answers `404`), so this is a separate fetch path rather than a
7//! resolution of the candle one.
8//!
9//! ```no_run
10//! # use chrono::NaiveDate;
11//! # use rustrade_data::exchange::lse::bond_yield::LseBondYieldQuery;
12//! # use rustrade_data::exchange::lse::data_api::LseDataApiClient;
13//! # use rustrade_data::exchange::lse::error::LseError;
14//! # async fn example() -> Result<(), LseError> {
15//! let client = LseDataApiClient::from_env()?;
16//!
17//! // The stats handle is fetched once and reused: it is what makes a query checkable.
18//! let stats = client.fetch_bond_yield_stats().await?;
19//!
20//! let query = LseBondYieldQuery::new(
21//! "GB", // normalised to the provider's `UK` before the request is sent
22//! NaiveDate::from_ymd_opt(2024, 1, 1).expect("valid date"),
23//! NaiveDate::from_ymd_opt(2024, 12, 31).expect("valid date"),
24//! )
25//! .with_maturity("5Y");
26//!
27//! for row in client.fetch_bond_yields(&stats, &query).await? {
28//! println!("{} {} {}", row.date, row.symbol, row.close);
29//! }
30//! # Ok(())
31//! # }
32//! ```
33//!
34//! # ⚠️ The endpoint validates nothing — a wrong query is a `200` with zero rows
35//!
36//! This is the single most important property of the surface, and it is why
37//! [`fetch_bond_yields`](LseDataApiClient::fetch_bond_yields) takes a
38//! [`LseBondYieldStats`] as a **required** argument rather than offering validation as an option.
39//! Measured against the live host:
40//!
41//! | request | answer |
42//! |---|---|
43//! | `country=UK` | `200`, rows |
44//! | `country=GB` (ISO-3166 spelling) | `200`, `count: 0` |
45//! | `country=ZZ` (nonsense) | `200`, `count: 0` |
46//! | `maturity=99Y` (nonsense) | `200`, `count: 0` |
47//! | `US 10Y TIPS` over 2023–2024 | `200`, `count: 0` — **and correct**, coverage starts 2025-07-07 |
48//!
49//! All four zero-row answers are byte-identical. Nothing in the response distinguishes a typo from
50//! a quiet market, so the distinction has to be made before the request is sent, against the only
51//! source of truth the provider offers: `/bond-yields/stats`.
52//!
53//! # ⚠️ Membership is not enough — the range must be checked too
54//!
55//! The last row of that table is the reason. `US 10Y TIPS` and `TR 2Y` both **exist** in stats and
56//! both return nothing for 2023–2024, because neither was published before 2025-07-07. A validator
57//! that only checked that the `(country, maturity)` pair existed would pass that query and hand
58//! back the same indistinguishable empty result it was built to prevent. So
59//! [`LseBondYieldQuery::validate`] checks the requested window against the **pair's own**
60//! `first_date`/`last_date`.
61//!
62//! # ⚠️ A tenor is a label, not a duration
63//!
64//! `maturity` is the provider's own string — `5Y`, `10Y TIPS` — and it carries instrument *type* as
65//! well as term. Each US TIPS tenor reports the **same `maturity_days` as its nominal twin**
66//! (`5Y` and `5Y TIPS` both `1825`, `10Y` and `10Y TIPS` both `3650`, `30Y` and `30Y TIPS` both
67//! `10950`), so keying a series on `(country, maturity_days)` silently merges a real yield with an
68//! inflation-linked one. Key on [`maturity`](LseBondYieldRow::maturity); treat
69//! [`maturity_days`](LseBondYieldRow::maturity_days) as a derived hint and nothing more. The same
70//! fact rules out modelling a tenor as a [`Duration`](std::time::Duration).
71//!
72//! # ⚠️ `country_iso2` is not ISO-3166
73//!
74//! The column is named `country_iso2` and the United Kingdom is keyed **`UK`**, which is not an
75//! ISO-3166-1 alpha-2 code; `GB` is absent from all 34 keys. It is the only divergence in the set,
76//! and the vault's catalog spells the same country `GB` — so a code crossing between the two hosts
77//! must be translated. [`LseBondYieldStats::normalise_country`] does it, and every entry point here
78//! applies it.
79//!
80//! # The OHLC is genuine
81//!
82//! Roughly one observation per business day invites the assumption that these are close-only
83//! figures reshaped into bars, which would make the open/high/low legs carry no information.
84//! Measured over calendar 2024 on three independent series, they are not: degenerate
85//! (`open == high == low == close`) rows are **0 of 250** on US 10Y, 1 of 256 on UK 5Y and 5 of 280
86//! on CH 2Y, with zero nulls in any leg. The handful that are degenerate are ordinary quiet days.
87//!
88//! # ⚠️ Licensing — this data may not be redistributed
89//!
90//! Bond-yield rows are provider data. They may be used for your own research, trading and model
91//! training, including commercially, but **not** redistributed or re-served to third parties in any
92//! form. Do not commit responses as fixtures or example datasets. Terms:
93//! <https://londonstrategicedge.com/terms>
94
95use crate::exchange::lse::data_api::LseDataApiClient;
96use crate::exchange::lse::error::LseError;
97use chrono::NaiveDate;
98use rust_decimal::Decimal;
99use serde::Deserialize;
100use smol_str::SmolStr;
101use std::collections::BTreeMap;
102
103/// Path of the bond-yield rows endpoint, relative to the data API base URL.
104const BOND_YIELDS_PATH: &str = "bond-yields";
105
106/// Path of the bond-yield stats endpoint, relative to the data API base URL.
107const BOND_YIELDS_STATS_PATH: &str = "bond-yields/stats";
108
109/// Spelling of every date on this endpoint — rows and stats alike.
110///
111/// ⚠️ **Not** the vault's
112/// [`PROVIDER_TIMESTAMP_FORMAT`](super::PROVIDER_TIMESTAMP_FORMAT), which is a 26-character
113/// microsecond stamp (`2024-01-02 09:09:00.000000`). Two hosts, two spellings; a bond yield is
114/// dated to the day and carries no time of day at all, which is why the query below takes a
115/// [`NaiveDate`] rather than an instant.
116const DATE_FORMAT: &str = "%Y-%m-%d";
117
118/// Value of the `format` parameter that selects JSON.
119///
120/// ⚠️ **Required, not a default.** This endpoint serves **CSV** when `format` is absent, so
121/// omitting it hands a CSV body to a JSON decoder.
122const JSON_FORMAT: &str = "json";
123
124/// The provider's spelling of the United Kingdom, which is not its ISO-3166-1 alpha-2 code.
125const UK_CODE: &str = "UK";
126
127/// The ISO-3166-1 alpha-2 code for the United Kingdom, which this host does **not** use.
128const GB_CODE: &str = "GB";
129
130/// Parse a provider date, naming the field so a caller can tell which one failed.
131fn parse_date(value: &str, field: &str) -> Result<NaiveDate, LseError> {
132 NaiveDate::parse_from_str(value, DATE_FORMAT).map_err(|error| LseError::Deserialize {
133 message: format!("invalid bond-yield {field} {value:?}: {error}"),
134 })
135}
136
137/// One daily bond-yield observation, as the data API serves it.
138///
139/// # ⚠️ Every field arrives as a JSON **string**, numerics included
140///
141/// `{"date": "2023-01-03", "maturity_days": "1825", "open": "3.5840", …}` — there is not a single
142/// JSON number in a row. The prices are therefore decoded with
143/// [`rust_decimal::serde::str`], **not** with the `rust_decimal::serde::float` used by
144/// `AlpacaStockSplit`: that helper expects a JSON number and copying it here fails outright.
145///
146/// All eleven fields were present and non-null, with a stable type, on every one of the 1,678 rows
147/// measured across five series spanning four countries — so none is optional.
148///
149/// Unknown fields are ignored, so a field the provider adds does not break decoding.
150#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
151pub struct LseBondYieldRow {
152 /// Observation date, spelled `2023-01-03` — ten characters, no time of day.
153 ///
154 /// Kept as the provider's string and parsed on demand by
155 /// [`observation_date`](Self::observation_date), following the same rule as
156 /// [`LseCatalogEntry`](super::reference::LseCatalogEntry)'s timestamps: one malformed row then
157 /// fails at the row rather than taking the whole fetch down with it.
158 pub date: String,
159 /// Provider symbol for the series, formed by concatenating country and tenor **without
160 /// spaces**: `UK5Y`, and `US10YTIPS` for the `10Y TIPS` tenor.
161 ///
162 /// Because the spaces are dropped, this is not a reliable key back to
163 /// [`maturity`](Self::maturity) — read the two separately.
164 pub symbol: SmolStr,
165 /// Country code as this host spells it.
166 ///
167 /// ⚠️ **Despite the name, this is not ISO-3166-1 alpha-2**: the United Kingdom is `UK`, not
168 /// `GB`. See the [module documentation](self).
169 pub country_iso2: SmolStr,
170 /// Country name (`"United Kingdom"`).
171 pub country_name: String,
172 /// Tenor as the provider labels it (`"5Y"`, `"10Y TIPS"`).
173 ///
174 /// ⚠️ **This, not [`maturity_days`](Self::maturity_days), identifies the series.** See the
175 /// [module documentation](self) for the TIPS collision that makes the distinction load-bearing.
176 pub maturity: SmolStr,
177 /// Term in days, as a string (`"1825"`).
178 ///
179 /// ⚠️ **A derived hint, never a key.** `5Y` and `5Y TIPS` both report `1825`. Parse it with
180 /// [`maturity_days_value`](Self::maturity_days_value) if you want the number; kept as the
181 /// provider's string so a malformed value on one row cannot fail a whole fetch over a field
182 /// that carries no identity.
183 pub maturity_days: SmolStr,
184 /// Currency the yield's underlying is denominated in (`"GBP"`).
185 ///
186 /// Descriptive only — a yield is a percentage, and this does not denominate it.
187 pub currency: SmolStr,
188 /// Opening yield, in percent.
189 #[serde(with = "rust_decimal::serde::str")]
190 pub open: Decimal,
191 /// Highest yield of the session, in percent.
192 #[serde(with = "rust_decimal::serde::str")]
193 pub high: Decimal,
194 /// Lowest yield of the session, in percent.
195 #[serde(with = "rust_decimal::serde::str")]
196 pub low: Decimal,
197 /// Closing yield, in percent.
198 #[serde(with = "rust_decimal::serde::str")]
199 pub close: Decimal,
200}
201
202impl LseBondYieldRow {
203 /// Parses [`date`](Self::date) as a calendar date.
204 ///
205 /// # Errors
206 /// Returns [`LseError::Deserialize`] if the value is not in the provider's `YYYY-MM-DD`
207 /// spelling.
208 pub fn observation_date(&self) -> Result<NaiveDate, LseError> {
209 parse_date(&self.date, "date")
210 }
211
212 /// Parses [`maturity_days`](Self::maturity_days) as a day count.
213 ///
214 /// ⚠️ A hint only — see the field's documentation and the [module documentation](self) before
215 /// using it for anything but display.
216 ///
217 /// # Errors
218 /// Returns [`LseError::Deserialize`] if the value is not a non-negative integer.
219 pub fn maturity_days_value(&self) -> Result<u32, LseError> {
220 self.maturity_days
221 .parse()
222 .map_err(|error| LseError::Deserialize {
223 message: format!(
224 "invalid bond-yield maturity_days {:?}: {error}",
225 self.maturity_days
226 ),
227 })
228 }
229}
230
231/// The provider's index of what it publishes on `/bond-yields`.
232///
233/// Fetched by [`fetch_bond_yield_stats`](LseDataApiClient::fetch_bond_yield_stats) and passed to
234/// [`fetch_bond_yields`](LseDataApiClient::fetch_bond_yields), which is what makes a query
235/// checkable against an endpoint that checks nothing itself.
236///
237/// # Fetch it once
238/// It is a description of the provider's coverage, not of any one query, so one handle serves every
239/// fetch. Nothing here refreshes it: a long-lived process that wants to see newly published series
240/// must fetch it again, and the cost of *not* doing so is a query rejected for a range the provider
241/// has since filled — visible and typed, never a silent wrong answer.
242#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
243pub struct LseBondYieldStats {
244 /// Number of countries published. Measured at 34.
245 pub total_countries: u32,
246 /// Total observations across every country and tenor. Measured at 716,820.
247 pub total_observations: u64,
248 /// Column names of a row, in the order the CSV form emits them.
249 ///
250 /// Matches [`LseBondYieldRow`]'s fields exactly at the time of writing, and is carried through
251 /// so a caller can detect a provider-side schema change without decoding rows.
252 pub columns: Vec<SmolStr>,
253 /// Per-country coverage, keyed by the provider's own country code.
254 ///
255 /// ⚠️ Keyed **`UK`**, not `GB`. Look countries up through [`country`](Self::country) rather
256 /// than indexing this directly, so the normalisation is applied.
257 pub countries: BTreeMap<SmolStr, LseBondYieldCountry>,
258}
259
260impl LseBondYieldStats {
261 /// Translates a country code into the provider's spelling.
262 ///
263 /// Trims, upper-cases, and maps the ISO-3166-1 alpha-2 `GB` to the provider's `UK`.
264 ///
265 /// # Why exactly one alias
266 /// The United Kingdom is the **only** divergence among the 34 codes — every other one matches
267 /// ISO-3166-1 alpha-2 and matches the vault catalog's spelling. A general alias table would
268 /// invent mappings the provider has not been measured to want; a single documented exception
269 /// matches a single measured exception.
270 #[must_use]
271 pub fn normalise_country(code: &str) -> SmolStr {
272 let upper = code.trim().to_ascii_uppercase();
273
274 if upper == GB_CODE {
275 SmolStr::new_static(UK_CODE)
276 } else {
277 SmolStr::from(upper)
278 }
279 }
280
281 /// Looks up a country, applying [`normalise_country`](Self::normalise_country) first.
282 ///
283 /// So `"gb"`, `"GB"` and `"UK"` all find the United Kingdom.
284 #[must_use]
285 pub fn country(&self, code: &str) -> Option<&LseBondYieldCountry> {
286 self.countries.get(&Self::normalise_country(code))
287 }
288}
289
290/// One country's bond-yield coverage.
291#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
292pub struct LseBondYieldCountry {
293 /// The provider's country code, repeating the map key.
294 pub code: SmolStr,
295 /// Country name (`"United Kingdom"`).
296 pub name: String,
297 /// Currency of the country's sovereign debt (`"GBP"`).
298 pub currency: SmolStr,
299 /// Earliest observation across **any** tenor, spelled `YYYY-MM-DD`. Parse with
300 /// [`first_date_value`](Self::first_date_value).
301 pub first_date: String,
302 /// Latest observation across **any** tenor, spelled `YYYY-MM-DD`. Parse with
303 /// [`last_date_value`](Self::last_date_value).
304 pub last_date: String,
305 /// Total observations across every tenor. A JSON integer here, where a row's own numerics are
306 /// strings.
307 pub observations: u64,
308 /// The tenors this country publishes.
309 pub maturities: Vec<LseBondYieldMaturity>,
310}
311
312impl LseBondYieldCountry {
313 /// Looks up one of this country's tenors by its provider label.
314 ///
315 /// Matched case-insensitively and ignoring surrounding whitespace, but **not** ignoring inner
316 /// spaces: `10Y TIPS` and `10YTIPS` are different strings and only the former is a tenor. The
317 /// row's `symbol` field is the spaceless form, which is why it is not a key.
318 #[must_use]
319 pub fn maturity(&self, tenor: &str) -> Option<&LseBondYieldMaturity> {
320 let wanted = tenor.trim();
321
322 self.maturities
323 .iter()
324 .find(|maturity| maturity.tenor.eq_ignore_ascii_case(wanted))
325 }
326
327 /// Parses [`first_date`](Self::first_date).
328 ///
329 /// # Errors
330 /// Returns [`LseError::Deserialize`] if the value is not in the provider's `YYYY-MM-DD`
331 /// spelling.
332 pub fn first_date_value(&self) -> Result<NaiveDate, LseError> {
333 parse_date(&self.first_date, "first_date")
334 }
335
336 /// Parses [`last_date`](Self::last_date).
337 ///
338 /// # Errors
339 /// Returns [`LseError::Deserialize`] if the value is not in the provider's `YYYY-MM-DD`
340 /// spelling.
341 pub fn last_date_value(&self) -> Result<NaiveDate, LseError> {
342 parse_date(&self.last_date, "last_date")
343 }
344}
345
346/// One tenor's coverage within a country.
347#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
348pub struct LseBondYieldMaturity {
349 /// The provider's tenor label (`"5Y"`, `"10Y TIPS"`).
350 ///
351 /// ⚠️ This identifies the series; [`days`](Self::days) does not.
352 pub tenor: SmolStr,
353 /// Term in days, as a JSON **integer** here — where a row's `maturity_days` is a string. Same
354 /// host, same quantity, two encodings.
355 ///
356 /// ⚠️ **Not unique within a country.** Each US TIPS tenor reports the same value as its nominal
357 /// twin.
358 pub days: u32,
359 /// Earliest observation for this tenor, spelled `YYYY-MM-DD`. Parse with
360 /// [`first_date_value`](Self::first_date_value).
361 ///
362 /// ⚠️ Can be far later than the country's own `first_date` — `US 10Y TIPS` starts 2025-07-07
363 /// against the United States' 1990-01-08. Checking the country's span instead of this one is
364 /// exactly the mistake that produces an unexplained empty result.
365 pub first_date: String,
366 /// Latest observation for this tenor, spelled `YYYY-MM-DD`. Parse with
367 /// [`last_date_value`](Self::last_date_value).
368 pub last_date: String,
369 /// Observations published for this tenor.
370 pub observations: u64,
371}
372
373impl LseBondYieldMaturity {
374 /// Parses [`first_date`](Self::first_date).
375 ///
376 /// # Errors
377 /// Returns [`LseError::Deserialize`] if the value is not in the provider's `YYYY-MM-DD`
378 /// spelling.
379 pub fn first_date_value(&self) -> Result<NaiveDate, LseError> {
380 parse_date(&self.first_date, "first_date")
381 }
382
383 /// Parses [`last_date`](Self::last_date).
384 ///
385 /// # Errors
386 /// Returns [`LseError::Deserialize`] if the value is not in the provider's `YYYY-MM-DD`
387 /// spelling.
388 pub fn last_date_value(&self) -> Result<NaiveDate, LseError> {
389 parse_date(&self.last_date, "last_date")
390 }
391}
392
393/// A bond-yield request.
394///
395/// `#[non_exhaustive]`: construct with [`new`](Self::new) and
396/// [`with_maturity`](Self::with_maturity) so a parameter added alongside a future provider filter
397/// does not break callers.
398#[derive(Debug, Clone, PartialEq, Eq)]
399#[non_exhaustive]
400pub struct LseBondYieldQuery {
401 /// Country code, in any spelling [`LseBondYieldStats::normalise_country`] accepts.
402 pub country: SmolStr,
403 /// Tenor label, or `None` for **every** tenor the country publishes.
404 ///
405 /// Omitting it is a genuine bulk request: all of the United States over its full history is
406 /// 76,406 rows across 15 tenors.
407 pub maturity: Option<SmolStr>,
408 /// First date of the requested window, inclusive.
409 pub start: NaiveDate,
410 /// Last date of the requested window, inclusive.
411 pub end: NaiveDate,
412}
413
414impl LseBondYieldQuery {
415 /// A query for every tenor a country publishes, over `[start, end]` inclusive.
416 ///
417 /// Narrow it to one tenor with [`with_maturity`](Self::with_maturity).
418 #[must_use]
419 pub fn new(country: impl Into<SmolStr>, start: NaiveDate, end: NaiveDate) -> Self {
420 Self {
421 country: country.into(),
422 maturity: None,
423 start,
424 end,
425 }
426 }
427
428 /// Restrict the query to one tenor, named as the provider labels it (`"5Y"`, `"10Y TIPS"`).
429 #[must_use]
430 pub fn with_maturity(mut self, maturity: impl Into<SmolStr>) -> Self {
431 self.maturity = Some(maturity.into());
432 self
433 }
434
435 /// Check this query against the provider's published coverage.
436 ///
437 /// Called unconditionally by
438 /// [`fetch_bond_yields`](LseDataApiClient::fetch_bond_yields), so it cannot be skipped; it is
439 /// public so a caller can screen a batch of queries without spending a request on each.
440 ///
441 /// Checks, in order: that the window is not inverted, that the country is published, that the
442 /// tenor is published for it, and that the window **overlaps** that series' own coverage. A
443 /// partial overlap passes — it returns the rows that exist, which is what was asked for; only a
444 /// disjoint window is rejected.
445 ///
446 /// # Errors
447 /// - [`LseError::InvalidInput`] if `end` precedes `start`.
448 /// - [`LseError::UnknownBondYieldCountry`] if the country is not published.
449 /// - [`LseError::UnknownBondYieldMaturity`] if the tenor is not published for that country.
450 /// - [`LseError::BondYieldRangeOutsideCoverage`] if the window is disjoint from the series'.
451 /// - [`LseError::Deserialize`] if a date in `stats` is not in the provider's spelling.
452 pub fn validate(&self, stats: &LseBondYieldStats) -> Result<(), LseError> {
453 self.resolve(stats).map(|_| ())
454 }
455
456 /// Validate, and return the normalised country code the request must actually be sent with.
457 ///
458 /// The normalisation is the point: sending the caller's `GB` unchanged is precisely the silent
459 /// zero-row case this module exists to close.
460 fn resolve(&self, stats: &LseBondYieldStats) -> Result<SmolStr, LseError> {
461 if self.end < self.start {
462 return Err(LseError::InvalidInput {
463 message: format!(
464 "bond-yield range end {} precedes start {}",
465 self.end, self.start
466 ),
467 });
468 }
469
470 let normalised = LseBondYieldStats::normalise_country(&self.country);
471 let country =
472 stats
473 .countries
474 .get(&normalised)
475 .ok_or_else(|| LseError::UnknownBondYieldCountry {
476 requested: self.country.to_string(),
477 normalised: normalised.to_string(),
478 })?;
479
480 // Coverage is per tenor where one is named, and per country otherwise. Reading the
481 // country's span for a single tenor would accept `US 10Y TIPS` over 2023 -- the United
482 // States reaches back to 1990, that tenor only to 2025-07-07 -- which is the exact query
483 // that motivated range checking.
484 let (first, last) = match &self.maturity {
485 Some(tenor) => {
486 let maturity =
487 country
488 .maturity(tenor)
489 .ok_or_else(|| LseError::UnknownBondYieldMaturity {
490 country: normalised.to_string(),
491 requested: tenor.to_string(),
492 available: country
493 .maturities
494 .iter()
495 .map(|maturity| maturity.tenor.to_string())
496 .collect(),
497 })?;
498
499 (maturity.first_date_value()?, maturity.last_date_value()?)
500 }
501 None => (country.first_date_value()?, country.last_date_value()?),
502 };
503
504 if self.end < first || self.start > last {
505 return Err(LseError::BondYieldRangeOutsideCoverage {
506 country: normalised.to_string(),
507 maturity: self.maturity.as_ref().map(ToString::to_string),
508 start: self.start,
509 end: self.end,
510 first_date: first,
511 last_date: last,
512 });
513 }
514
515 Ok(normalised)
516 }
517}
518
519/// The `{count, data}` envelope `/bond-yields` answers with.
520///
521/// `count` is carried so it can be checked against the rows actually delivered; see
522/// [`fetch_bond_yields`](LseDataApiClient::fetch_bond_yields).
523#[derive(Debug, Deserialize)]
524struct LseBondYieldEnvelope {
525 count: usize,
526 data: Vec<LseBondYieldRow>,
527}
528
529impl LseDataApiClient {
530 /// Fetch the provider's index of published bond-yield coverage.
531 ///
532 /// Fetch this **once** and reuse the handle across queries — see [`LseBondYieldStats`]. It is
533 /// the required first argument to [`fetch_bond_yields`](Self::fetch_bond_yields), which is what
534 /// makes a query checkable against an endpoint that validates nothing itself.
535 ///
536 /// # Errors
537 /// See [`LseError`].
538 pub async fn fetch_bond_yield_stats(&self) -> Result<LseBondYieldStats, LseError> {
539 self.get_json(
540 BOND_YIELDS_STATS_PATH,
541 &[("format", JSON_FORMAT.to_string())],
542 )
543 .await
544 }
545
546 /// Fetch daily bond-yield observations for `query`.
547 ///
548 /// `stats` is **required**, not optional, and `query` is validated against it before anything is
549 /// sent. That is a deliberate API choice: the endpoint answers `200` with zero rows for an
550 /// unknown country, an unknown tenor *and* an empty window alike, so a fetch that could skip
551 /// validation would be a fetch that could silently return nothing. See the
552 /// [module documentation](self) for the measurements.
553 ///
554 /// Rows arrive ascending by date, oldest first, and the country code sent is the **normalised**
555 /// one — a caller passing `GB` reaches the provider's `UK`.
556 ///
557 /// # The whole answer arrives in one response
558 /// This endpoint is **not paged**: its envelope carries no cursor, and a request for the full
559 /// published history of every United States tenor returned all 76,406 rows in a single 4-second
560 /// response, matching `/bond-yields/stats` exactly. So this returns a `Vec` rather than a
561 /// stream, and no pagination is invented for a surface that has none.
562 ///
563 /// The envelope's own `count` is checked against the rows delivered and a mismatch is an error
564 /// rather than a short result — that is the signal a silent page cap would produce if the
565 /// provider ever introduced one.
566 ///
567 /// # ⚠️ An unnarrowed query is a large request
568 /// Omitting [`maturity`](LseBondYieldQuery::maturity) fetches every tenor, which for a
569 /// deep country over its full history is tens of thousands of rows against the client's 30s
570 /// total request timeout. Narrow the tenor or the window for anything latency-sensitive.
571 ///
572 /// # Errors
573 /// Anything [`validate`](LseBondYieldQuery::validate) raises, plus [`LseError`]'s transport and
574 /// decode variants. A response whose `count` disagrees with the rows delivered is reported as
575 /// [`LseError::Api`] carrying the `200` the provider actually sent.
576 pub async fn fetch_bond_yields(
577 &self,
578 stats: &LseBondYieldStats,
579 query: &LseBondYieldQuery,
580 ) -> Result<Vec<LseBondYieldRow>, LseError> {
581 let country = query.resolve(stats)?;
582
583 let mut params = vec![
584 ("country", country.to_string()),
585 ("start_date", query.start.format(DATE_FORMAT).to_string()),
586 ("end_date", query.end.format(DATE_FORMAT).to_string()),
587 // Absent, this endpoint serves CSV.
588 ("format", JSON_FORMAT.to_string()),
589 ];
590
591 // Omitted entirely rather than sent empty: an empty `maturity` is an unknown one, and this
592 // endpoint answers an unknown filter with zero rows rather than an error.
593 if let Some(maturity) = &query.maturity {
594 params.push(("maturity", maturity.to_string()));
595 }
596
597 let envelope: LseBondYieldEnvelope = self.get_json(BOND_YIELDS_PATH, ¶ms).await?;
598
599 if envelope.count != envelope.data.len() {
600 return Err(LseError::Api {
601 status: 200,
602 message: format!(
603 "bond-yield response declared {} rows but carried {}: the response appears to \
604 have been truncated",
605 envelope.count,
606 envelope.data.len()
607 ),
608 });
609 }
610
611 Ok(envelope.data)
612 }
613}
614
615#[cfg(test)]
616#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
617mod tests {
618 use super::*;
619 use rust_decimal_macros::dec;
620
621 /// A stats handle shaped exactly like the provider's, with **invented** dates and counts.
622 ///
623 /// The provider prohibits redistributing its data, so nothing here is a recorded response. What
624 /// is reproduced is the *structure* that the validation rules turn on:
625 ///
626 /// - the United Kingdom keyed `UK`, with no `GB` entry;
627 /// - a tenor whose coverage starts far later than its country's (`US`/`10Y TIPS`), which is the
628 /// shape that makes membership-only validation wrong;
629 /// - two tenors sharing a `days` value (`10Y` and `10Y TIPS`), which is the shape that makes
630 /// `maturity_days` unusable as a key.
631 fn stats() -> LseBondYieldStats {
632 serde_json::from_str(
633 r#"{
634 "total_countries": 2,
635 "total_observations": 30,
636 "columns": ["date", "symbol", "country_iso2", "country_name", "maturity",
637 "maturity_days", "currency", "open", "high", "low", "close"],
638 "countries": {
639 "UK": {
640 "code": "UK", "name": "United Kingdom", "currency": "GBP",
641 "first_date": "2020-01-01", "last_date": "2024-12-31", "observations": 10,
642 "maturities": [
643 {"tenor": "5Y", "days": 1825, "first_date": "2020-01-01",
644 "last_date": "2024-12-31", "observations": 10}
645 ]
646 },
647 "US": {
648 "code": "US", "name": "United States", "currency": "USD",
649 "first_date": "2000-01-01", "last_date": "2024-12-31", "observations": 20,
650 "maturities": [
651 {"tenor": "10Y", "days": 3650, "first_date": "2000-01-01",
652 "last_date": "2024-12-31", "observations": 15},
653 {"tenor": "10Y TIPS", "days": 3650, "first_date": "2024-06-01",
654 "last_date": "2024-12-31", "observations": 5}
655 ]
656 }
657 }
658 }"#,
659 )
660 .unwrap()
661 }
662
663 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
664 NaiveDate::from_ymd_opt(year, month, day).unwrap()
665 }
666
667 /// Every field on the wire is a JSON string, numerics included — the property that rules out
668 /// the `rust_decimal::serde::float` helper the Alpaca corporate-action types use.
669 #[test]
670 fn a_row_decodes_with_every_field_arriving_as_a_string() {
671 let row: LseBondYieldRow = serde_json::from_str(
672 r#"{"date":"2024-03-14","symbol":"UK5Y","country_iso2":"UK",
673 "country_name":"United Kingdom","maturity":"5Y","maturity_days":"1825",
674 "currency":"GBP","open":"1.2500","high":"1.3750","low":"1.2000","close":"1.3125"}"#,
675 )
676 .unwrap();
677
678 assert_eq!(row.open, dec!(1.2500));
679 assert_eq!(row.high, dec!(1.3750));
680 assert_eq!(row.low, dec!(1.2000));
681 assert_eq!(row.close, dec!(1.3125));
682 assert_eq!(row.observation_date().unwrap(), date(2024, 3, 14));
683 assert_eq!(row.maturity_days_value().unwrap(), 1825);
684 }
685
686 /// A field the provider adds must not break decoding.
687 #[test]
688 fn a_row_ignores_an_unknown_field() {
689 let row: LseBondYieldRow = serde_json::from_str(
690 r#"{"date":"2024-03-14","symbol":"UK5Y","country_iso2":"UK",
691 "country_name":"United Kingdom","maturity":"5Y","maturity_days":"1825",
692 "currency":"GBP","open":"1.0","high":"1.0","low":"1.0","close":"1.0",
693 "some_new_field":"whatever"}"#,
694 )
695 .unwrap();
696
697 assert_eq!(row.symbol, "UK5Y");
698 }
699
700 /// The date is kept as the provider's string precisely so a malformed one fails at the row
701 /// rather than taking a whole fetch down, so the parse must be fallible and must name the field.
702 #[test]
703 fn a_malformed_date_is_a_typed_error_naming_the_field() {
704 let row = LseBondYieldRow {
705 date: "14/03/2024".to_string(),
706 symbol: "UK5Y".into(),
707 country_iso2: "UK".into(),
708 country_name: "United Kingdom".to_string(),
709 maturity: "5Y".into(),
710 maturity_days: "not-a-number".into(),
711 currency: "GBP".into(),
712 open: dec!(1),
713 high: dec!(1),
714 low: dec!(1),
715 close: dec!(1),
716 };
717
718 let error = row.observation_date().unwrap_err();
719 assert!(matches!(error, LseError::Deserialize { .. }));
720 assert!(error.to_string().contains("date"), "{error}");
721
722 let error = row.maturity_days_value().unwrap_err();
723 assert!(matches!(error, LseError::Deserialize { .. }));
724 assert!(error.to_string().contains("maturity_days"), "{error}");
725 }
726
727 /// The one divergence in the provider's country vocabulary, in both directions and in any case.
728 #[test]
729 fn gb_normalises_to_the_providers_uk_and_everything_else_is_untouched() {
730 assert_eq!(LseBondYieldStats::normalise_country("GB"), "UK");
731 assert_eq!(LseBondYieldStats::normalise_country("gb"), "UK");
732 assert_eq!(LseBondYieldStats::normalise_country(" Gb "), "UK");
733 assert_eq!(LseBondYieldStats::normalise_country("UK"), "UK");
734 assert_eq!(LseBondYieldStats::normalise_country("us"), "US");
735 assert_eq!(LseBondYieldStats::normalise_country("DE"), "DE");
736 }
737
738 /// The lookup applies the normalisation, so the ISO spelling a caller is likeliest to reach for
739 /// finds the country instead of silently missing it.
740 #[test]
741 fn a_country_lookup_finds_the_uk_under_its_iso_spelling() {
742 let stats = stats();
743
744 assert_eq!(stats.country("GB").unwrap().name, "United Kingdom");
745 assert_eq!(stats.country("gb").unwrap().name, "United Kingdom");
746 assert_eq!(stats.country("UK").unwrap().name, "United Kingdom");
747 assert!(stats.country("ZZ").is_none());
748 // The vault's spelling is absent from the provider's own keys; only the lookup bridges them.
749 assert!(!stats.countries.contains_key("GB"));
750 }
751
752 /// Tenors are matched case-insensitively, but the inner space is significant: the row `symbol`
753 /// field drops it (`US10YTIPS`), so that form is not a tenor and must not resolve to one.
754 #[test]
755 fn a_tenor_matches_ignoring_case_but_not_ignoring_its_inner_space() {
756 let stats = stats();
757 let us = stats.country("US").unwrap();
758
759 assert_eq!(us.maturity("10Y TIPS").unwrap().tenor, "10Y TIPS");
760 assert_eq!(us.maturity("10y tips").unwrap().tenor, "10Y TIPS");
761 assert_eq!(us.maturity(" 10Y TIPS ").unwrap().tenor, "10Y TIPS");
762 assert!(us.maturity("10YTIPS").is_none());
763 assert!(us.maturity("99Y").is_none());
764 }
765
766 /// The measured collision that rules out keying on the day count.
767 #[test]
768 fn two_tenors_of_one_country_share_a_day_count() {
769 let stats = stats();
770 let us = stats.country("US").unwrap();
771
772 assert_eq!(us.maturity("10Y").unwrap().days, 3650);
773 assert_eq!(us.maturity("10Y TIPS").unwrap().days, 3650);
774 }
775
776 #[test]
777 fn a_valid_query_passes() {
778 let stats = stats();
779 let query =
780 LseBondYieldQuery::new("UK", date(2021, 1, 1), date(2022, 1, 1)).with_maturity("5Y");
781
782 assert!(query.validate(&stats).is_ok());
783 }
784
785 #[test]
786 fn an_inverted_range_is_rejected() {
787 let stats = stats();
788 let query = LseBondYieldQuery::new("UK", date(2022, 1, 1), date(2021, 1, 1));
789
790 assert!(matches!(
791 query.validate(&stats).unwrap_err(),
792 LseError::InvalidInput { .. }
793 ));
794 }
795
796 /// `country=ZZ` answers `200 count=0` at the provider, so the rejection has to happen here.
797 #[test]
798 fn an_unknown_country_is_rejected_before_the_request_is_sent() {
799 let stats = stats();
800 let query = LseBondYieldQuery::new("ZZ", date(2021, 1, 1), date(2022, 1, 1));
801
802 let error = query.validate(&stats).unwrap_err();
803 assert!(matches!(error, LseError::UnknownBondYieldCountry { .. }));
804 // Both spellings are reported so a caller can see which one was looked up.
805 let rendered = error.to_string();
806 assert!(rendered.contains("ZZ"), "{rendered}");
807 }
808
809 /// The error names the normalised form, so a caller passing `GB` for a country the provider
810 /// genuinely lacks is not left wondering which string was searched for.
811 #[test]
812 fn an_unknown_country_error_reports_both_the_requested_and_the_normalised_spelling() {
813 let stats = stats();
814 let query = LseBondYieldQuery::new("gb-typo", date(2021, 1, 1), date(2022, 1, 1));
815
816 match query.validate(&stats).unwrap_err() {
817 LseError::UnknownBondYieldCountry {
818 requested,
819 normalised,
820 } => {
821 assert_eq!(requested, "gb-typo");
822 assert_eq!(normalised, "GB-TYPO");
823 }
824 other => panic!("expected UnknownBondYieldCountry, got {other}"),
825 }
826 }
827
828 /// `maturity=99Y` also answers `200 count=0`, and the error carries the list a caller needs
829 /// because a tenor is a label there is no way to compute.
830 #[test]
831 fn an_unknown_maturity_is_rejected_and_lists_the_published_tenors() {
832 let stats = stats();
833 let query =
834 LseBondYieldQuery::new("US", date(2021, 1, 1), date(2022, 1, 1)).with_maturity("99Y");
835
836 match query.validate(&stats).unwrap_err() {
837 LseError::UnknownBondYieldMaturity {
838 country,
839 requested,
840 available,
841 } => {
842 assert_eq!(country, "US");
843 assert_eq!(requested, "99Y");
844 assert_eq!(available, vec!["10Y".to_string(), "10Y TIPS".to_string()]);
845 }
846 other => panic!("expected UnknownBondYieldMaturity, got {other}"),
847 }
848 }
849
850 /// 🔴 The decisive case. `US 10Y TIPS` **exists**, and the United States spans 2000–2024, so a
851 /// membership-only validator passes this query — and the provider answers `200 count=0` because
852 /// that tenor does not start until 2024-06-01. Coverage must be read per tenor, not per country.
853 #[test]
854 fn a_range_before_a_tenors_own_coverage_is_rejected_though_the_country_spans_it() {
855 let stats = stats();
856 let us = stats.country("US").unwrap();
857
858 // The premise: the country covers the window, the tenor does not.
859 assert!(us.first_date_value().unwrap() < date(2021, 1, 1));
860
861 let query = LseBondYieldQuery::new("US", date(2021, 1, 1), date(2021, 12, 31))
862 .with_maturity("10Y TIPS");
863
864 match query.validate(&stats).unwrap_err() {
865 LseError::BondYieldRangeOutsideCoverage {
866 country,
867 maturity,
868 first_date,
869 ..
870 } => {
871 assert_eq!(country, "US");
872 assert_eq!(maturity.as_deref(), Some("10Y TIPS"));
873 assert_eq!(first_date, date(2024, 6, 1));
874 }
875 other => panic!("expected BondYieldRangeOutsideCoverage, got {other}"),
876 }
877
878 // The same window against the nominal tenor is fine, which is what makes the rejection
879 // above about the tenor rather than about the window.
880 assert!(
881 LseBondYieldQuery::new("US", date(2021, 1, 1), date(2021, 12, 31))
882 .with_maturity("10Y")
883 .validate(&stats)
884 .is_ok()
885 );
886 }
887
888 #[test]
889 fn a_range_after_the_published_coverage_is_rejected() {
890 let stats = stats();
891 let query = LseBondYieldQuery::new("UK", date(2030, 1, 1), date(2031, 1, 1));
892
893 assert!(matches!(
894 query.validate(&stats).unwrap_err(),
895 LseError::BondYieldRangeOutsideCoverage { .. }
896 ));
897 }
898
899 /// A window that only partly overlaps returns the rows that exist, which is what was asked for.
900 /// Rejecting it would make the validator refuse legitimate open-ended requests.
901 #[test]
902 fn a_partially_overlapping_range_passes() {
903 let stats = stats();
904
905 // Starts before coverage, ends inside it.
906 assert!(
907 LseBondYieldQuery::new("UK", date(1990, 1, 1), date(2021, 1, 1))
908 .validate(&stats)
909 .is_ok()
910 );
911 // Starts inside coverage, ends after it.
912 assert!(
913 LseBondYieldQuery::new("UK", date(2024, 1, 1), date(2099, 1, 1))
914 .validate(&stats)
915 .is_ok()
916 );
917 // A single day exactly on the boundary.
918 assert!(
919 LseBondYieldQuery::new("UK", date(2020, 1, 1), date(2020, 1, 1))
920 .validate(&stats)
921 .is_ok()
922 );
923 }
924
925 /// Without a tenor the bound is the country's own span, which is wider than any single tenor's.
926 #[test]
927 fn a_query_without_a_maturity_is_bounded_by_the_countrys_own_coverage() {
928 let stats = stats();
929
930 // Inside the country's span but before the `10Y TIPS` tenor's — allowed, because the query
931 // asks for every tenor and the nominal one does cover it.
932 assert!(
933 LseBondYieldQuery::new("US", date(2021, 1, 1), date(2021, 12, 31))
934 .validate(&stats)
935 .is_ok()
936 );
937
938 match LseBondYieldQuery::new("US", date(1950, 1, 1), date(1960, 1, 1))
939 .validate(&stats)
940 .unwrap_err()
941 {
942 LseError::BondYieldRangeOutsideCoverage { maturity, .. } => assert!(maturity.is_none()),
943 other => panic!("expected BondYieldRangeOutsideCoverage, got {other}"),
944 }
945 }
946
947 /// The caller's spelling must not reach the wire: `GB` has to become `UK` or the request comes
948 /// back empty with a `200`.
949 #[test]
950 fn validation_yields_the_normalised_country_code_the_request_is_sent_with() {
951 let stats = stats();
952 let query = LseBondYieldQuery::new("GB", date(2021, 1, 1), date(2022, 1, 1));
953
954 assert_eq!(query.resolve(&stats).unwrap(), "UK");
955 }
956
957 #[test]
958 fn stats_decode_with_their_integer_typed_counts() {
959 let stats = stats();
960
961 assert_eq!(stats.total_countries, 2);
962 assert_eq!(stats.total_observations, 30);
963 assert_eq!(stats.columns.len(), 11);
964 assert_eq!(stats.columns.first().unwrap(), "date");
965 assert_eq!(stats.country("US").unwrap().observations, 20);
966 assert_eq!(stats.country("US").unwrap().maturities.len(), 2);
967 }
968
969 /// The stats dates are the provider's strings, so a malformed one must surface as a typed
970 /// decode error from validation rather than being silently treated as no constraint.
971 #[test]
972 fn a_malformed_stats_date_fails_validation_rather_than_being_ignored() {
973 let mut stats = stats();
974 stats
975 .countries
976 .get_mut("UK")
977 .unwrap()
978 .maturities
979 .get_mut(0)
980 .unwrap()
981 .first_date = "not-a-date".to_string();
982
983 let query =
984 LseBondYieldQuery::new("UK", date(2021, 1, 1), date(2022, 1, 1)).with_maturity("5Y");
985
986 assert!(matches!(
987 query.validate(&stats).unwrap_err(),
988 LseError::Deserialize { .. }
989 ));
990 }
991}