Skip to main content

rustrade_data/exchange/lse/
reference.rs

1//! London Strategic Edge catalog records (`GET /vault/catalog`).
2//!
3//! The catalog is the provider's index of everything it publishes: 22,966 entries at the last
4//! measurement, spanning the price datasets that [`LseDataset`] models, the reference families
5//! (economics, bond yields, credit indices, …) that it deliberately does not, and `options`, which
6//! is a price dataset that has no [`LseDataset`] variant either — see
7//! [`LseCatalogEntry::price_dataset`].
8//!
9//! [`LseCatalogEntry`] is the record **exactly as the vault serves it** — a provider-shaped type in
10//! the same spirit as `AlpacaStockSplit`, not a provider-agnostic abstraction. Nothing here is
11//! wired into the engine: reference series are `live == 0` across the board and reach a backtest,
12//! if at all, through an auxiliary event source rather than through `price()` or orders.
13//!
14//! # Classifying an entry
15//!
16//! [`LseCatalogEntry::class`] separates price datasets from reference series using the provider's
17//! own fields, and the rule is not a heuristic: across all 22,966 entries, a populated `frequency`
18//! **and** `category` identifies a reference series and an empty pair identifies a price dataset,
19//! with **zero rows falling outside either bucket** (15,537 reference / 7,429 price). A new
20//! dataset the provider adds tomorrow therefore classifies itself, with no list here to update.
21//!
22//! # ⚠️ `frequency` is a label, not a cadence contract
23//!
24//! It is kept as the provider's own string rather than parsed into an enum, for two measured
25//! reasons. The vocabulary is **dirty**: ten distinct spellings including `biannually` *and*
26//! `bi-annually`, `quarterly` *and* `quarter` — so a closed enum built from the obvious six values
27//! would have silently mishandled seven rows. And the label **does not predict observed spacing**:
28//! `weekly` series range from 52 to 248 observations per year, and `quarter` yields roughly 1.5,
29//! not 4. Derive expected spacing from the observations themselves, never from this field.
30//!
31//! # ⚠️ Licensing — this data may not be redistributed
32//!
33//! Catalog contents are provider data. They may be used for your own research, trading and model
34//! training, including commercially, but **not** redistributed or re-served to third parties in any
35//! form. Do not commit catalog responses as fixtures or example datasets. Terms:
36//! <https://londonstrategicedge.com/terms>
37
38use crate::exchange::lse::PROVIDER_TIMESTAMP_FORMAT;
39use crate::exchange::lse::error::LseError;
40use crate::exchange::lse::market::LseDataset;
41use crate::exchange::lse::vault::LseVaultClient;
42use chrono::{DateTime, NaiveDateTime, Utc};
43use serde::Deserialize;
44use smol_str::SmolStr;
45
46/// Path of the catalog endpoint, relative to the vault base URL.
47const CATALOG_PATH: &str = "catalog";
48
49/// Whether a catalog entry describes a tradeable price dataset or a reference series.
50///
51/// See the [module documentation](self) for the classification rule and the measurement behind it.
52#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
53pub enum LseDatasetClass {
54    /// A price dataset — an instrument, modelled by [`LseDataset`].
55    Price,
56    /// A reference series — macro, bond-yield, credit or derivative reference data. Not an
57    /// instrument, and never streamed: every reference entry measured carries `live == 0`.
58    Reference,
59}
60
61/// One catalog record, as the vault serves it.
62///
63/// All seventeen fields were present and non-null on every one of the 22,966 entries measured, so
64/// none is optional. The six descriptive fields nonetheless carry `#[serde(default)]`: they are
65/// routinely the empty string already (`unit` on 7,805 entries, `source` on 8,057), so an absent
66/// field and an empty one mean the same thing here and tolerating absence hides nothing.
67///
68/// Unknown fields are ignored, so a field the provider adds does not break decoding.
69#[derive(Debug, Clone, PartialEq, Deserialize)]
70pub struct LseCatalogEntry {
71    /// Dataset name as the catalog spells it (`"crypto"`, `"economics"`, …).
72    ///
73    /// Kept as the provider's string rather than as an [`LseDataset`], which models price datasets
74    /// only. Use [`price_dataset`](Self::price_dataset) to resolve one where it exists.
75    pub dataset: SmolStr,
76    /// Symbol within the dataset (`"BTC/USD"`, `"chlinfind"`, …).
77    ///
78    /// Unique only together with [`dataset`](Self::dataset), not on its own.
79    pub symbol: SmolStr,
80    /// Human-readable series name (`"Bitcoin"`).
81    pub name: String,
82    /// Observation count the provider reports for the series.
83    ///
84    /// **`u64`, not `u32`, and that is not defensive.** The largest measured value is 4,145,394,353
85    /// — 96.5% of [`u32::MAX`] — on a live crypto tape that keeps growing, so a 32-bit count would
86    /// overflow within months of the measurement.
87    pub ticks: u64,
88    /// First observation, spelled `2017-08-17 04:00:28.322000` (UTC, no zone marker, the
89    /// fractional part optional). Parse with [`first_tick_time`](Self::first_tick_time).
90    pub first_tick: String,
91    /// Last observation, spelled as [`first_tick`](Self::first_tick) is. Parse with
92    /// [`last_tick_time`](Self::last_tick_time).
93    ///
94    /// ⚠️ This is a **catalog summary statistic and it lags the live tape** — measured six hours
95    /// behind on a continuously trading crypto symbol. It describes the catalog's own refresh, not
96    /// feed liveness, and must not be used as a staleness check on a stream.
97    pub last_tick: String,
98    /// Span of the series in years, as the provider computes it.
99    pub years: f64,
100    /// Most recent value, in whatever [`unit`](Self::unit) the series is quoted in.
101    ///
102    /// A display statistic spanning nine orders of magnitude across the catalog
103    /// (-20,458,587 to 74,226,775,000), carried as `f64` because that is the precision the provider
104    /// serves it at. Never an execution input — fetch the series itself for that.
105    pub last_value: f64,
106    /// Most recent change, percent.
107    pub change_pct: f64,
108    /// Change over the trailing year, percent.
109    pub change_1y: f64,
110    /// Unit of quotation (`"percent"`, `"USD Million"`, `"index"`, …); empty for price datasets.
111    #[serde(default)]
112    pub unit: SmolStr,
113    /// Upstream publisher. Populated only for `economics`, and **not a clean vocabulary** — two
114    /// spellings of one source were measured (`"World Bank"` 728 entries, `"Worldbank"` 344).
115    #[serde(default)]
116    pub source: SmolStr,
117    /// Reference category (`"Government bond yield"`, `"Credit index"`, …); empty for price
118    /// datasets. Half of the [`class`](Self::class) rule.
119    #[serde(default)]
120    pub category: SmolStr,
121    /// Publication frequency as the provider labels it; empty for price datasets. The other half of
122    /// the [`class`](Self::class) rule.
123    ///
124    /// ⚠️ A dirty label that does not predict observed spacing — see the
125    /// [module documentation](self) before relying on it.
126    #[serde(default)]
127    pub frequency: SmolStr,
128    /// Country code.
129    ///
130    /// ⚠️ **The vault spells the United Kingdom `GB` here, while the `data-api` host's
131    /// `/bond-yields` endpoint spells the same country `UK`** — the only divergence among the 34
132    /// codes, and neither host errors on the other's spelling. Normalise before crossing hosts.
133    #[serde(default)]
134    pub country: SmolStr,
135    /// Country name (`"United States"`).
136    #[serde(default)]
137    pub country_name: String,
138    /// Whether the series streams: `1` for a live price dataset, `0` otherwise.
139    ///
140    /// Kept as the provider's integer rather than a `bool` so a third value would survive decoding
141    /// instead of being coerced. Read it through [`is_live`](Self::is_live). Every one of the
142    /// 15,537 reference entries measured carries `0`.
143    pub live: u8,
144}
145
146impl LseCatalogEntry {
147    /// Classifies the entry as a price dataset or a reference series.
148    ///
149    /// Uses the provider's own `frequency`/`category` pair; see the [module documentation](self)
150    /// for the rule and the measurement behind it. An entry populating exactly one of the two —
151    /// which no measured entry does — is reported as [`Reference`](LseDatasetClass::Reference),
152    /// since a populated `category` is what distinguishes a non-instrument.
153    #[must_use]
154    pub fn class(&self) -> LseDatasetClass {
155        if self.frequency.is_empty() && self.category.is_empty() {
156            LseDatasetClass::Price
157        } else {
158            LseDatasetClass::Reference
159        }
160    }
161
162    /// Resolves [`dataset`](Self::dataset) to an [`LseDataset`], where one exists.
163    ///
164    /// # `None` does not mean "reference"
165    ///
166    /// It means "no [`LseDataset`] variant", and the catalog has **two** sources of that:
167    ///
168    /// - every reference family, which is [`LseDataset::from_catalog_str`]'s documented contract
169    ///   rather than a failure — those are not instruments and have no variant; and
170    /// - **`options`**, which *is* a price dataset and still has no variant. Measured: of 7,429
171    ///   price-classified entries, 4,243 resolve and the remaining 3,186 are all `options`.
172    ///
173    /// So pair this with [`class`](Self::class) rather than reading `None` as a classification.
174    /// An entry with [`LseDatasetClass::Price`] and `None` here is an option contract.
175    #[must_use]
176    pub fn price_dataset(&self) -> Option<LseDataset> {
177        LseDataset::from_catalog_str(&self.dataset).ok()
178    }
179
180    /// Whether the series streams live.
181    #[must_use]
182    pub fn is_live(&self) -> bool {
183        self.live != 0
184    }
185
186    /// Parses [`first_tick`](Self::first_tick) as an instant.
187    ///
188    /// # Errors
189    /// Returns [`LseError::Deserialize`] if the value is not in the provider's timestamp
190    /// spelling, naming the field so the caller can tell which of the two failed.
191    pub fn first_tick_time(&self) -> Result<DateTime<Utc>, LseError> {
192        parse_catalog_timestamp(&self.first_tick, "first_tick")
193    }
194
195    /// Parses [`last_tick`](Self::last_tick) as an instant.
196    ///
197    /// ⚠️ Lags the live tape — see the field's own documentation.
198    ///
199    /// # Errors
200    /// Returns [`LseError::Deserialize`] if the value is not in the provider's timestamp
201    /// spelling, naming the field so the caller can tell which of the two failed.
202    pub fn last_tick_time(&self) -> Result<DateTime<Utc>, LseError> {
203        parse_catalog_timestamp(&self.last_tick, "last_tick")
204    }
205}
206
207/// Parse one catalog timestamp, naming the field in any error.
208fn parse_catalog_timestamp(raw: &str, field: &'static str) -> Result<DateTime<Utc>, LseError> {
209    NaiveDateTime::parse_from_str(raw, PROVIDER_TIMESTAMP_FORMAT)
210        .map(|naive| naive.and_utc())
211        .map_err(|error| LseError::Deserialize {
212            message: format!("invalid catalog {field} {raw:?}: {error}"),
213        })
214}
215
216impl LseVaultClient {
217    /// Fetches the whole catalog.
218    ///
219    /// The endpoint is unpaginated and returns every entry in one response — 22,966 at the last
220    /// measurement, and **9.1 MB of JSON** — all of it buffered and decoded, so treat this as an
221    /// occasional call rather than something to poll.
222    ///
223    /// It is a **discovery and validation helper**: it does not register instruments, which the
224    /// caller declares explicitly as with every other connector.
225    ///
226    /// Timestamps are returned unparsed, so a single malformed `first_tick` surfaces through
227    /// [`LseCatalogEntry::first_tick_time`] on that one entry instead of failing the whole
228    /// fetch.
229    ///
230    /// # Errors
231    /// Returns [`LseError::Api`] for a non-success status, [`LseError::RateLimited`] on a `429`,
232    /// [`LseError::Http`] on a transport failure, and [`LseError::Deserialize`] if the response
233    /// does not match [`LseCatalogEntry`].
234    ///
235    /// # ⚠️ Licensing
236    /// The response is provider data and must not be redistributed or committed. See the
237    /// [module documentation](self).
238    pub async fn fetch_catalog(&self) -> Result<Vec<LseCatalogEntry>, LseError> {
239        self.get_json(CATALOG_PATH, &[]).await
240    }
241}
242
243#[cfg(test)]
244#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
245mod tests {
246    use super::*;
247
248    /// A price entry as the catalog serves one: `frequency`, `category`, `unit` and `source` all
249    /// empty, `live == 1`. Values are structural only — no provider data is reproduced here.
250    fn price_json() -> &'static str {
251        r#"{
252            "dataset": "crypto", "symbol": "AAA/BBB", "name": "Example",
253            "ticks": 4145394353, "first_tick": "2017-08-17 04:00:28.322000",
254            "last_tick": "2026-09-21 04:32:26.473000", "years": 9.1,
255            "last_value": 1.5, "change_pct": 0.08, "change_1y": -29.5,
256            "unit": "", "source": "", "category": "", "frequency": "",
257            "country": "", "country_name": "", "live": 1
258        }"#
259    }
260
261    /// A reference entry: `frequency` and `category` populated, `live == 0`.
262    fn reference_json() -> &'static str {
263        r#"{
264            "dataset": "economics", "symbol": "exampleind", "name": "Example Indicator",
265            "ticks": 412, "first_tick": "1990-01-02 00:00:00.000000",
266            "last_tick": "2026-08-01 00:00:00.000000", "years": 36,
267            "last_value": 3.25, "change_pct": 0.1, "change_1y": 2,
268            "unit": "percent", "source": "World Bank", "category": "Inflation Index",
269            "frequency": "monthly", "country": "GB", "country_name": "United Kingdom",
270            "live": 0
271        }"#
272    }
273
274    fn parse(json: &str) -> LseCatalogEntry {
275        serde_json::from_str(json).expect("catalog entry should decode")
276    }
277
278    #[test]
279    fn a_price_entry_classifies_as_price_and_resolves_its_dataset() {
280        let entry = parse(price_json());
281
282        assert_eq!(entry.class(), LseDatasetClass::Price);
283        assert_eq!(entry.price_dataset(), Some(LseDataset::Crypto));
284        assert!(entry.is_live());
285    }
286
287    #[test]
288    fn a_reference_entry_classifies_as_reference_and_has_no_dataset() {
289        let entry = parse(reference_json());
290
291        assert_eq!(entry.class(), LseDatasetClass::Reference);
292        // `economics` is not a price dataset, so `from_catalog_str`'s documented
293        // `UnknownDataset` contract is preserved rather than inverted.
294        assert_eq!(entry.price_dataset(), None);
295        assert!(!entry.is_live());
296    }
297
298    #[test]
299    fn the_measured_tick_count_leaves_too_little_u32_headroom_to_narrow_the_field() {
300        // Pins the reason `ticks` is `u64`. The largest measured value sits at 96.5% of
301        // `u32::MAX` on a tape that is still growing, so `u32` would overflow within months --
302        // it has not overflowed yet, which is exactly why the field type must not wait for it.
303        let measured = parse(price_json()).ticks;
304
305        assert_eq!(measured, 4_145_394_353);
306        let headroom = u64::from(u32::MAX) - measured;
307        assert!(
308            headroom * 20 < u64::from(u32::MAX),
309            "{headroom} of headroom is more than 5% of u32::MAX -- re-check the claim"
310        );
311    }
312
313    #[test]
314    fn catalog_timestamps_parse_as_utc() {
315        let entry = parse(price_json());
316
317        let first = entry.first_tick_time().expect("first_tick should parse");
318        assert_eq!(first.to_rfc3339(), "2017-08-17T04:00:28.322+00:00");
319        assert!(entry.last_tick_time().is_ok());
320    }
321
322    #[test]
323    fn a_malformed_timestamp_is_a_typed_error_naming_its_field() {
324        let mut entry = parse(price_json());
325        entry.first_tick = "17/08/2017".to_owned();
326
327        let error = entry
328            .first_tick_time()
329            .expect_err("a bad timestamp should not parse");
330
331        assert!(matches!(error, LseError::Deserialize { .. }));
332        // The field is named so a caller can tell which of the two failed.
333        assert!(error.to_string().contains("first_tick"), "{error}");
334        // The other field is unaffected: one bad value does not poison the entry.
335        assert!(entry.last_tick_time().is_ok());
336    }
337
338    #[test]
339    fn a_seconds_only_timestamp_still_parses() {
340        // `%.f` makes the fractional part optional, so a response that drops microseconds works.
341        let mut entry = parse(price_json());
342        entry.first_tick = "2017-08-17 04:00:28".to_owned();
343
344        assert!(entry.first_tick_time().is_ok());
345    }
346
347    #[test]
348    fn the_six_descriptive_fields_may_be_absent_entirely() {
349        // They are already empty on thousands of entries, so absence means the same thing.
350        let json = r#"{
351            "dataset": "fx", "symbol": "AAA/BBB", "name": "Example",
352            "ticks": 1, "first_tick": "2020-01-01 00:00:00.000000",
353            "last_tick": "2020-01-02 00:00:00.000000", "years": 1,
354            "last_value": 1, "change_pct": 0, "change_1y": 0, "live": 1
355        }"#;
356
357        let entry = parse(json);
358
359        assert_eq!(entry.class(), LseDatasetClass::Price);
360        assert!(entry.unit.is_empty());
361        assert!(entry.country.is_empty());
362    }
363
364    #[test]
365    fn an_unknown_field_does_not_break_decoding() {
366        let json = price_json().replace(r#""live": 1"#, r#""live": 1, "added_later": "whatever""#);
367
368        assert_eq!(parse(&json).class(), LseDatasetClass::Price);
369    }
370
371    #[test]
372    fn integer_and_float_spellings_of_the_same_number_both_decode() {
373        // Measured: `years`, `last_value`, `change_pct` and `change_1y` each arrive as a JSON int
374        // on some entries and a float on others.
375        let entry = parse(reference_json());
376
377        assert!((entry.years - 36.0).abs() < f64::EPSILON);
378        assert!((entry.change_1y - 2.0).abs() < f64::EPSILON);
379    }
380
381    #[test]
382    fn a_dirty_frequency_spelling_is_preserved_rather_than_normalised() {
383        // `bi-annually` and `quarter` are real measured spellings that a closed enum would have
384        // dropped. They round-trip because the field is the provider's own string.
385        for spelling in ["bi-annually", "quarter", "biweekly", "something-new"] {
386            let json = reference_json().replace(
387                r#""frequency": "monthly""#,
388                &format!(r#""frequency": "{spelling}""#),
389            );
390            let entry = parse(&json);
391
392            assert_eq!(entry.frequency, spelling);
393            assert_eq!(entry.class(), LseDatasetClass::Reference);
394        }
395    }
396
397    #[test]
398    fn an_options_entry_is_a_price_dataset_that_still_resolves_to_no_variant() {
399        // `options` is the 11th price dataset in the catalog and has no `LseDataset` variant, so
400        // `None` here cannot be read as "this is reference data". Measured: 3,186 such entries.
401        let json = price_json().replace(r#""dataset": "crypto""#, r#""dataset": "options""#);
402
403        let entry = parse(&json);
404
405        assert_eq!(entry.class(), LseDatasetClass::Price);
406        assert_eq!(entry.price_dataset(), None);
407    }
408
409    #[test]
410    fn an_entry_populating_only_category_is_treated_as_reference() {
411        let json = price_json().replace(r#""category": """#, r#""category": "Credit index""#);
412
413        assert_eq!(parse(&json).class(), LseDatasetClass::Reference);
414    }
415}