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}