Skip to main content

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, &params).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}