rustrade_data/exchange/lse/calendar.rs
1//! Economic calendar events from the London Strategic Edge data API
2//! (`GET /economic-calendar`).
3//!
4//! Scheduled macroeconomic releases โ CPI prints, rate decisions, payrolls โ across 108 country
5//! codes, each carrying the consensus estimate, the previous figure and the actual outcome where
6//! the provider has them. Served by the [data API](super::data_api) host, alongside
7//! [`bond_yield`](super::bond_yield).
8//!
9//! ```no_run
10//! # use chrono::NaiveDate;
11//! # use rustrade_data::exchange::lse::calendar::LseCalendarQuery;
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_economic_calendar_stats().await?;
19//!
20//! let query = LseCalendarQuery::new(
21//! NaiveDate::from_ymd_opt(2026, 1, 1).expect("valid date"),
22//! NaiveDate::from_ymd_opt(2026, 3, 24).expect("valid date"),
23//! )
24//! .with_country("GB") // normalised to the provider's `UK` before the request is sent
25//! .with_impact("High");
26//!
27//! for event in client.fetch_economic_calendar(&stats, &query).await? {
28//! println!("{} {} {}", event.event_date, event.country, event.event);
29//! }
30//! # Ok(())
31//! # }
32//! ```
33//!
34//! # ๐ด The feed stopped on 2026-03-24 โ this is a historical archive, not a calendar
35//!
36//! **The forward-looking use case an economic calendar exists for is not served at all.** The
37//! provider's last event is dated **2026-03-24**, and nothing has been published since. This is
38//! not staleness that a later fetch resolves โ it was measured as frozen **to the unit**, two
39//! months of wall-clock apart:
40//!
41//! | measured | 2026-07-24 | 2026-09-22 |
42//! |---|---|---|
43//! | `total_events` | 124,896 | 124,896 |
44//! | `latest` | 2026-03-24 | 2026-03-24 |
45//!
46//! Confirmed from the other side as well: a forward window of `2026-03-25`..=`2030-12-31` with no
47//! country filter answers `200` with `count: 0`, as does any window after `latest`, while
48//! `2026-03-24` itself returns 17 events. Zero events exist after that date across all 108
49//! countries. Nothing on the provider's issue tracker reports it, so no fix should be assumed.
50//!
51//! What remains is a genuine and complete historical archive: **124,896 events from 2014-12-31 to
52//! 2026-03-24**, with an `estimate` populated on 74,843 of them. That is useful for backtesting
53//! and for event-study work, and it is what this module is for. It is not useful for anything that
54//! needs to know what releases are coming, and no amount of client-side care changes that.
55//!
56//! [`LseCalendarStats::latest`] is the live figure rather than a constant here, so a caller can
57//! detect a revival: if it ever moves past 2026-03-24, the feed resumed.
58//!
59//! # โ ๏ธ The endpoint validates nothing โ a wrong query is a `200` with zero rows
60//!
61//! As with [`bond_yield`](super::bond_yield), and for the same reason
62//! [`fetch_economic_calendar`](LseDataApiClient::fetch_economic_calendar) takes an
63//! [`LseCalendarStats`] as a **required** argument. Measured against the live host:
64//!
65//! | request | answer |
66//! |---|---|
67//! | `country=UK` | `200`, rows |
68//! | `country=GB` (ISO-3166 spelling) | `200`, `count: 0` |
69//! | `country=ZZ` (nonsense) | `200`, `count: 0` |
70//! | `impact=Critical` (nonsense) | `200`, `count: 0` |
71//! | a reversed range | `200`, `count: 0` |
72//! | any window after 2026-03-24 | `200`, `count: 0` |
73//!
74//! All of those zero-row answers are identical. The one query shape that does *not* answer `200`
75//! is a malformed date โ `start_date=not-a-date` returns **`500 Internal Server Error`**, not a
76//! `400`. [`LseCalendarQuery`] holds typed [`NaiveDate`]s, which makes that unreachable.
77//!
78//! # ๐ด But an empty result inside the published range is NOT an error
79//!
80//! This is where the calendar differs from `/bond-yields`, and the difference is load-bearing.
81//! `/bond-yields/stats` publishes per-country **and** per-tenor `first_date`/`last_date`, so a
82//! window that would return nothing can be rejected before it is sent.
83//! `/economic-calendar/stats` publishes **no per-country coverage at all** โ only a flat global
84//! `earliest`/`latest` โ and coverage is wildly uneven: 88 of the 108 countries hold fewer than
85//! 100 events, the median is 13, and `UK` holds 56, all between 2026-01-16 and 2026-03-24.
86//!
87//! So `UK` over 2020โ2025 returns zero events. That is correct, it is unhelpful, and **this
88//! library cannot pre-explain it**, because the provider does not publish the coverage that would
89//! be needed to. An empty [`Vec`] from a query inside the global range is a legitimate answer and
90//! is deliberately **not** raised as an error. Inventing a fourth error variant here would mean
91//! inventing knowledge the provider does not give.
92//!
93//! # โ ๏ธ Two silent parameter traps
94//!
95//! 1. **A comma-separated country list is a real OR, and is the only form that works.**
96//! `country=US,UK` returns 36,477 events โ exactly US alone (36,421) plus UK alone (56).
97//! Supported, and undocumented by the provider.
98//! 2. **๐ด A repeated parameter silently takes the LAST value.** `country=US&country=UK` returns
99//! 56 events: the UK ones. The US rows vanish with no error and no warning, leaving a wrong but
100//! entirely plausible result. A naive `.query(&[("country", "US"), ("country", "UK")])` hits
101//! this exactly.
102//!
103//! [`LseCalendarQuery`] joins with commas and never repeats the key. Nothing here lets a caller
104//! express the broken form.
105//!
106//! # โ ๏ธ `country` is not ISO-3166, and not every code is a country
107//!
108//! The same `UK`-not-`GB` divergence as [`bond_yield`](super::bond_yield): `UK` is published and
109//! `GB` is absent, so the one documented alias carries over and
110//! [`LseCalendarStats::normalise_country`] applies it. But **the two vocabularies are not the
111//! same set** โ the calendar's 108 codes include `EA` (Euro Area) and `EU`, which are not
112//! countries at all. Do not assume a code valid on one endpoint is valid on the other.
113//!
114//! Both hosts are case- and whitespace-insensitive server-side, so `uk`, `UK` and `' Uk '` all
115//! resolve on the wire. Normalisation is still required, for a different reason than it appears:
116//! the **client-side membership check** runs against an upper-case vocabulary, and only the client
117//! can turn `GB` into `UK` at all.
118//!
119//! # โ ๏ธ `impact` of `"None"` is a literal string, not a JSON null
120//!
121//! The vocabulary is exactly `High`, `Low`, `Medium`, `None`, and 592 events carry `"None"` as a
122//! four-character string. Mapping it to an absent value would be wrong, so
123//! [`LseCalendarImpact`] models it as the variant [`None`](LseCalendarImpact::None) and the field
124//! is not an [`Option`].
125//!
126//! # โ ๏ธ Licensing โ this data may not be redistributed
127//!
128//! Calendar events are provider data. They may be used for your own research, trading and model
129//! training, including commercially, but **not** redistributed or re-served to third parties in
130//! any form. Do not commit responses as fixtures or example datasets. Terms:
131//! <https://londonstrategicedge.com/terms>
132
133use crate::exchange::lse::data_api::LseDataApiClient;
134use crate::exchange::lse::error::LseError;
135use chrono::{DateTime, NaiveDate, Utc};
136use rust_decimal::Decimal;
137use serde::Deserialize;
138use smol_str::SmolStr;
139
140/// Path of the economic-calendar events endpoint, relative to the data API base URL.
141const ECONOMIC_CALENDAR_PATH: &str = "economic-calendar";
142
143/// Path of the economic-calendar stats endpoint, relative to the data API base URL.
144const ECONOMIC_CALENDAR_STATS_PATH: &str = "economic-calendar/stats";
145
146/// Spelling of the `start_date`/`end_date` query parameters.
147///
148/// โ ๏ธ **Not** the spelling of a row's [`event_date`](LseCalendarEvent::event_date) or of the stats
149/// bounds, which carry a time of day and an offset. The query is bounded by whole days; an event is
150/// stamped to the second.
151const DATE_FORMAT: &str = "%Y-%m-%d";
152
153/// Value of the `format` parameter that selects JSON.
154///
155/// โ ๏ธ **Required, not a default.** The provider's own OpenAPI document declares `csv` as this
156/// parameter's default, and the wire agrees: omitting it โ or passing anything other than the
157/// exact string `json`, which is *not* validated โ hands a CSV body to a JSON decoder.
158const JSON_FORMAT: &str = "json";
159
160/// Separator for a multi-country filter.
161///
162/// โ ๏ธ Load-bearing. See the [module documentation](self): repeating the `country` key instead
163/// silently discards every value but the last.
164const COUNTRY_SEPARATOR: &str = ",";
165
166/// The provider's spelling of the United Kingdom, which is not its ISO-3166-1 alpha-2 code.
167const UK_CODE: &str = "UK";
168
169/// The ISO-3166-1 alpha-2 code for the United Kingdom, which this host does **not** use.
170const GB_CODE: &str = "GB";
171
172/// Parse a provider instant, naming the field so a caller can tell which one failed.
173///
174/// The provider's space-separated spelling (`2026-03-24 14:00:00+00:00`) is accepted by
175/// [`DateTime::parse_from_rfc3339`] directly, as the RFC 3339 ยง5.6 lenience it is โ the same path
176/// [`LseTick`](super::tick::LseTick) takes for the vault's timestamps. No second grammar is used,
177/// so the two cannot drift apart in what they admit.
178fn parse_instant(value: &str, field: &str) -> Result<DateTime<Utc>, LseError> {
179 DateTime::parse_from_rfc3339(value)
180 .map(|parsed| parsed.with_timezone(&Utc))
181 .map_err(|error| LseError::Deserialize {
182 message: format!("invalid economic-calendar {field} {value:?}: {error}"),
183 })
184}
185
186/// How much market impact the provider attributes to an event.
187///
188/// # โ ๏ธ [`None`](Self::None) is the provider's own literal string
189///
190/// The wire vocabulary is exactly `["High", "Low", "Medium", "None"]`, and `"None"` is a
191/// four-character JSON string carried by 592 of the 124,896 measured events โ it is **not** a JSON
192/// `null`, and no event omits the field. That is why [`LseCalendarEvent::impact`] is this type
193/// rather than an `Option<_>`: an `Option` would make `Some(Impact::None)` and `None`
194/// indistinguishable in intent while meaning quite different things.
195///
196/// The set is closed at four. An unrecognised value is a decode error rather than a silently
197/// absorbed variant, so a provider addition surfaces instead of being rounded to the nearest
198/// known rating.
199#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)]
200pub enum LseCalendarImpact {
201 /// The provider's `"High"`.
202 High,
203 /// The provider's `"Medium"`.
204 Medium,
205 /// The provider's `"Low"`.
206 Low,
207 /// The provider's literal `"None"` โ an event it rates as market-irrelevant.
208 ///
209 /// โ ๏ธ Not an absent value. See the type's documentation.
210 None,
211}
212
213impl LseCalendarImpact {
214 /// The provider's spelling of this rating, suitable for the `impact` query parameter.
215 #[must_use]
216 pub const fn as_str(self) -> &'static str {
217 match self {
218 Self::High => "High",
219 Self::Medium => "Medium",
220 Self::Low => "Low",
221 Self::None => "None",
222 }
223 }
224}
225
226impl std::fmt::Display for LseCalendarImpact {
227 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228 f.write_str(self.as_str())
229 }
230}
231
232/// One scheduled economic release, as the data API serves it.
233///
234/// # โ ๏ธ Every numeric arrives as a JSON **string**, and absent is always `null`
235///
236/// `{"event_date": "2026-03-24 14:00:00+00:00", "previous": "2.9", "estimate": null, โฆ}` โ there
237/// is not a single JSON number in an event. The numerics are therefore decoded with
238/// [`rust_decimal::serde::str_option`], **not** the `rust_decimal::serde::float` used by
239/// `AlpacaStockSplit`, which expects a JSON number and fails outright here.
240///
241/// Absence is spelled `null` and **only** `null`: measured across all 124,896 events, the
242/// empty-string count is **zero for every one of the eleven fields**. Nulls themselves are common
243/// โ `estimate` on 50,053 events, `unit` on 37,349, `change` on 25,481, `currency` on 253 โ so the
244/// optionality is real even though the empty string never appears.
245///
246/// Every one of roughly 462,000 non-null numeric values parses. The only shapes that occur are
247/// `9.9`, `-9.9`, `9` and `-9`: no thousands separators, no trailing `%`, no scientific notation.
248/// **Negatives are abundant and load-bearing** (49,080 on `change`, 48,718 on `change_percentage`),
249/// so an unsigned type would be wrong.
250///
251/// All eleven fields are present on every event โ there is exactly one key-set across the whole
252/// corpus โ so none is `#[serde(default)]`. A field the provider drops is a decode failure rather
253/// than a silent `None`, which is the intended trade.
254///
255/// Unknown fields are ignored, so a field the provider adds does not break decoding.
256#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
257pub struct LseCalendarEvent {
258 /// When the release is scheduled, spelled `2026-03-24 14:00:00+00:00`.
259 ///
260 /// # โ ๏ธ A timestamp, not a date
261 ///
262 /// Unlike [`LseBondYieldRow::date`](super::bond_yield::LseBondYieldRow::date), which is a bare
263 /// ten-character day, this carries a real time of day: 100% of the 124,896 measured events
264 /// match `YYYY-MM-DD HH:MM:SS+00:00`, the offset is `+00:00` on every one, and **348 distinct
265 /// times of day** occur. Truncating it to a calendar date would discard the intraday ordering
266 /// that makes an event study possible, so [`event_time`](Self::event_time) yields a
267 /// [`DateTime<Utc>`].
268 ///
269 /// Kept as the provider's string and parsed on demand, following the same rule as
270 /// [`LseCatalogEntry`](super::reference::LseCatalogEntry) and `LseBondYieldRow`: one malformed
271 /// event then fails at the event rather than taking the whole fetch down with it.
272 pub event_date: String,
273 /// Country code as this host spells it.
274 ///
275 /// โ ๏ธ Not ISO-3166: the United Kingdom is `UK`, not `GB`, and `EA`/`EU` are not countries at
276 /// all. See the [module documentation](self).
277 pub country: SmolStr,
278 /// The release's name, as the provider labels it (`"CPI (YoY)"`, `"Interest Rate Decision"`).
279 ///
280 /// Free text, not a closed vocabulary. `(event_date, country, event)` is a natural key โ all
281 /// 124,896 measured triples are distinct โ but it is the *provider's* shape, not a reason to
282 /// key a local store on it.
283 pub event: String,
284 /// Currency the figures are denominated in (`"GBP"`), where one applies.
285 ///
286 /// `None` on 253 events; 83 distinct values otherwise.
287 pub currency: Option<SmolStr>,
288 /// The previously reported figure, where the provider has it.
289 #[serde(with = "rust_decimal::serde::str_option")]
290 pub previous: Option<Decimal>,
291 /// The consensus forecast ahead of the release.
292 ///
293 /// Populated on 74,843 of the 124,896 measured events โ the single most useful field here for
294 /// surprise-based work, and absent on two fifths of the corpus.
295 #[serde(with = "rust_decimal::serde::str_option")]
296 pub estimate: Option<Decimal>,
297 /// The figure actually released.
298 #[serde(with = "rust_decimal::serde::str_option")]
299 pub actual: Option<Decimal>,
300 /// Absolute change from [`previous`](Self::previous) to [`actual`](Self::actual).
301 ///
302 /// Provider-computed, not derived here. Frequently negative.
303 #[serde(with = "rust_decimal::serde::str_option")]
304 pub change: Option<Decimal>,
305 /// Relative change from [`previous`](Self::previous) to [`actual`](Self::actual), in percent.
306 ///
307 /// Provider-computed, and carried as a bare number: the `%` is in
308 /// [`unit`](Self::unit) or nowhere, never in this value.
309 #[serde(with = "rust_decimal::serde::str_option")]
310 pub change_percentage: Option<Decimal>,
311 /// The provider's market-impact rating.
312 ///
313 /// โ ๏ธ Never absent, and [`LseCalendarImpact::None`] is a real rating rather than a missing
314 /// one. See [`LseCalendarImpact`].
315 pub impact: LseCalendarImpact,
316 /// Magnitude or unit suffix for the figures (`"%"`, `"K"`, `"M"`, `"B"`, `"T"`).
317 ///
318 /// A closed set of five in the measured corpus โ `%` on 65,018 events, `B` on 10,972, `K` on
319 /// 5,990, `M` on 5,108, `T` on 459 โ plus 37,349 nulls.
320 ///
321 /// โ ๏ธ **A different vocabulary from the catalog's `unit`**, which names a quote currency or an
322 /// instrument denomination. The two must not share a type, and a value here must not be looked
323 /// up against that one. Left as the provider's string rather than modelled as an enum: it is
324 /// not validated anywhere and carries no behaviour, so a sixth suffix should arrive as data
325 /// rather than as a decode failure.
326 pub unit: Option<SmolStr>,
327}
328
329impl LseCalendarEvent {
330 /// Parses [`event_date`](Self::event_date) as an instant.
331 ///
332 /// The provider's space-separated spelling (`2026-03-24 14:00:00+00:00`) is accepted as the
333 /// RFC 3339 ยง5.6 lenience it is.
334 ///
335 /// # Errors
336 /// Returns [`LseError::Deserialize`] if the value is not a valid RFC 3339 instant.
337 pub fn event_time(&self) -> Result<DateTime<Utc>, LseError> {
338 parse_instant(&self.event_date, "event_date")
339 }
340
341 /// The calendar day of [`event_date`](Self::event_date), in UTC.
342 ///
343 /// A convenience for grouping; prefer [`event_time`](Self::event_time) for ordering, since 348
344 /// distinct times of day occur and a day alone does not order events within one.
345 ///
346 /// # Errors
347 /// As [`event_time`](Self::event_time).
348 pub fn event_day(&self) -> Result<NaiveDate, LseError> {
349 Ok(self.event_time()?.date_naive())
350 }
351}
352
353/// The provider's index of what it publishes on `/economic-calendar`.
354///
355/// Fetched by [`fetch_economic_calendar_stats`](LseDataApiClient::fetch_economic_calendar_stats)
356/// and passed to [`fetch_economic_calendar`](LseDataApiClient::fetch_economic_calendar), which is
357/// what makes a query checkable against an endpoint that validates nothing itself.
358///
359/// # ๐ด Much weaker than `/bond-yields/stats`, and that shapes the whole error design
360///
361/// This is a **flat** summary: a list of country codes, a list of impact ratings, and one global
362/// `earliest`/`latest` pair. There is **no per-country coverage** โ unlike the bond-yield stats,
363/// which publish `first_date`/`last_date` per country *and* per tenor.
364///
365/// The consequence is that only three checks are possible before a request is sent: that a country
366/// is published, that an impact rating is published, and that the window overlaps the **global**
367/// range. A query inside that global range can still legitimately return nothing, and the library
368/// deliberately does not treat that as an error. See the [module documentation](self).
369///
370/// # Fetch it once
371/// It is a description of the provider's coverage, not of any one query, so one handle serves every
372/// fetch. Nothing here refreshes it.
373///
374/// # It is also the revival detector
375/// [`latest`](Self::latest) is the live figure. The feed has been frozen at 2026-03-24 across two
376/// measurements two months apart; if this ever reports a later date, the feed resumed.
377#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
378pub struct LseCalendarStats {
379 /// Every country code the provider publishes events for. Measured at 108.
380 ///
381 /// โ ๏ธ Contains `UK` and not `GB`, and contains `EA` and `EU`, which are not countries. Test
382 /// membership through [`publishes_country`](Self::publishes_country) rather than searching
383 /// this directly, so the normalisation is applied.
384 pub countries: Vec<SmolStr>,
385 /// Every impact rating in use. Measured as exactly `["High", "Low", "Medium", "None"]`.
386 ///
387 /// โ ๏ธ `"None"` is a literal string here too โ see [`LseCalendarImpact`].
388 pub impacts: Vec<SmolStr>,
389 /// Instant of the earliest published event, spelled like
390 /// [`event_date`](LseCalendarEvent::event_date): `2014-12-31 23:00:00+00:00`.
391 ///
392 /// โ ๏ธ The provider once served a bare `YYYY-MM-DD` here and changed it without notice; the
393 /// bare spelling is no longer accepted. Parse with [`earliest_time`](Self::earliest_time), or
394 /// [`earliest_date`](Self::earliest_date) for its UTC day.
395 pub earliest: String,
396 /// Instant of the latest published event, spelled like `earliest`. Measured at
397 /// `2026-03-24 20:00:00+00:00`.
398 ///
399 /// ๐ด The feed has not moved past this day since it was first measured. Parse with
400 /// [`latest_time`](Self::latest_time) or [`latest_date`](Self::latest_date); compare the day
401 /// against 2026-03-24 to detect a revival.
402 pub latest: String,
403 /// Total events across every country. Measured at 124,896, unchanged over two months.
404 pub total_events: u64,
405}
406
407impl LseCalendarStats {
408 /// Translates a country code into the provider's spelling.
409 ///
410 /// Trims, upper-cases, and maps the ISO-3166-1 alpha-2 `GB` to the provider's `UK`.
411 ///
412 /// # Why normalise at all, when the server does not care
413 /// The host is measured to be case- and whitespace-insensitive: `country=uk` and
414 /// `country=' Uk '` both resolve on the wire. The trim and upper-case are not what gets a
415 /// code accepted โ they are what lets the **client-side** membership check run against an
416 /// upper-case vocabulary. The `GB`โ`UK` mapping is different in kind: the server would answer
417 /// `GB` with a silent `200 count=0`, and only the client can bridge it.
418 ///
419 /// # Why exactly one alias
420 /// The United Kingdom is the only divergence from ISO-3166-1 alpha-2 among the published
421 /// codes, matching the [`bond_yield`](super::bond_yield) endpoint. A general alias table would
422 /// invent mappings the provider has not been measured to want.
423 #[must_use]
424 pub fn normalise_country(code: &str) -> SmolStr {
425 let upper = code.trim().to_ascii_uppercase();
426
427 if upper == GB_CODE {
428 SmolStr::new_static(UK_CODE)
429 } else {
430 SmolStr::from(upper)
431 }
432 }
433
434 /// Whether the provider publishes events for a country, in any spelling
435 /// [`normalise_country`](Self::normalise_country) accepts.
436 ///
437 /// So `"gb"`, `"GB"` and `"UK"` all find the United Kingdom.
438 #[must_use]
439 pub fn publishes_country(&self, code: &str) -> bool {
440 let normalised = Self::normalise_country(code);
441
442 self.countries.iter().any(|known| known == &normalised)
443 }
444
445 /// Resolves an impact rating to the provider's own spelling, ignoring case.
446 ///
447 /// Returns `None` if the provider does not publish that rating. The provider's spelling is
448 /// returned rather than the caller's so the request carries a value from the published
449 /// vocabulary verbatim.
450 #[must_use]
451 pub fn resolve_impact(&self, impact: &str) -> Option<&SmolStr> {
452 let wanted = impact.trim();
453
454 self.impacts
455 .iter()
456 .find(|known| known.eq_ignore_ascii_case(wanted))
457 }
458
459 /// Parses [`earliest`](Self::earliest) as an instant.
460 ///
461 /// # Errors
462 /// Returns [`LseError::Deserialize`] if the value is not a valid RFC 3339 instant.
463 pub fn earliest_time(&self) -> Result<DateTime<Utc>, LseError> {
464 parse_instant(&self.earliest, "earliest")
465 }
466
467 /// Parses [`latest`](Self::latest) as an instant.
468 ///
469 /// # Errors
470 /// Returns [`LseError::Deserialize`] if the value is not a valid RFC 3339 instant.
471 pub fn latest_time(&self) -> Result<DateTime<Utc>, LseError> {
472 parse_instant(&self.latest, "latest")
473 }
474
475 /// The calendar day of [`earliest`](Self::earliest), in UTC.
476 ///
477 /// # Errors
478 /// As [`earliest_time`](Self::earliest_time).
479 pub fn earliest_date(&self) -> Result<NaiveDate, LseError> {
480 Ok(self.earliest_time()?.date_naive())
481 }
482
483 /// The calendar day of [`latest`](Self::latest), in UTC.
484 ///
485 /// # Errors
486 /// As [`latest_time`](Self::latest_time).
487 pub fn latest_date(&self) -> Result<NaiveDate, LseError> {
488 Ok(self.latest_time()?.date_naive())
489 }
490}
491
492/// An economic-calendar request.
493///
494/// `#[non_exhaustive]`: construct with [`new`](Self::new) and the `with_*` builders so a parameter
495/// added alongside a future provider filter does not break callers.
496///
497/// # โ ๏ธ Countries are joined with commas, never repeated
498/// The provider accepts `country=US,UK` as a genuine OR, and silently keeps only the **last** of
499/// `country=US&country=UK`. This type only ever emits the first form. See the
500/// [module documentation](self).
501#[derive(Debug, Clone, PartialEq, Eq)]
502#[non_exhaustive]
503pub struct LseCalendarQuery {
504 /// Country codes, in any spelling [`LseCalendarStats::normalise_country`] accepts.
505 ///
506 /// Empty means **every** country, which is a genuine bulk request: the whole corpus is 124,896
507 /// events in a single 29 MB response.
508 pub countries: Vec<SmolStr>,
509 /// Impact rating to filter on, or `None` for every rating.
510 pub impact: Option<SmolStr>,
511 /// First date of the requested window, inclusive.
512 pub start: NaiveDate,
513 /// Last date of the requested window, inclusive.
514 pub end: NaiveDate,
515}
516
517impl LseCalendarQuery {
518 /// A query for every country and every impact rating, over `[start, end]` inclusive.
519 ///
520 /// Narrow it with [`with_country`](Self::with_country),
521 /// [`with_countries`](Self::with_countries) and [`with_impact`](Self::with_impact).
522 #[must_use]
523 pub fn new(start: NaiveDate, end: NaiveDate) -> Self {
524 Self {
525 countries: Vec::new(),
526 impact: None,
527 start,
528 end,
529 }
530 }
531
532 /// Add one country to the filter.
533 ///
534 /// Additive: calling it twice asks for both, sent as one comma-joined parameter.
535 #[must_use]
536 pub fn with_country(mut self, country: impl Into<SmolStr>) -> Self {
537 self.countries.push(country.into());
538 self
539 }
540
541 /// Add several countries to the filter.
542 ///
543 /// Additive, like [`with_country`](Self::with_country).
544 #[must_use]
545 pub fn with_countries<I, C>(mut self, countries: I) -> Self
546 where
547 I: IntoIterator<Item = C>,
548 C: Into<SmolStr>,
549 {
550 self.countries.extend(countries.into_iter().map(Into::into));
551 self
552 }
553
554 /// Restrict the query to one impact rating, named as the provider labels it (`"High"`).
555 ///
556 /// Matched against the published vocabulary ignoring case, and sent in the provider's own
557 /// spelling. Replaces any rating set earlier.
558 #[must_use]
559 pub fn with_impact(mut self, impact: impl Into<SmolStr>) -> Self {
560 self.impact = Some(impact.into());
561 self
562 }
563
564 /// Check this query against the provider's published coverage.
565 ///
566 /// Called unconditionally by
567 /// [`fetch_economic_calendar`](LseDataApiClient::fetch_economic_calendar), so it cannot be
568 /// skipped; it is public so a caller can screen a batch of queries without spending a request
569 /// on each.
570 ///
571 /// Checks, in order: that the window is not inverted, that every country named is published,
572 /// that the impact rating is published, and that the window **overlaps** the provider's global
573 /// range. A partial overlap passes โ it returns the events that exist, which is what was asked
574 /// for; only a disjoint window is rejected.
575 ///
576 /// # โ ๏ธ What it deliberately does not check
577 /// Whether *this* country has events in *this* window. The provider publishes no per-country
578 /// coverage, so that is unknowable before the request; an empty result inside the global range
579 /// is a legitimate answer rather than an error. See the [module documentation](self).
580 ///
581 /// # Errors
582 /// - [`LseError::InvalidInput`] if `end` precedes `start`.
583 /// - [`LseError::UnknownCalendarCountry`] if a country is not published.
584 /// - [`LseError::UnknownCalendarImpact`] if the impact rating is not published.
585 /// - [`LseError::CalendarRangeOutsideCoverage`] if the window is disjoint from the global one.
586 /// - [`LseError::Deserialize`] if a date in `stats` is not in the provider's spelling.
587 pub fn validate(&self, stats: &LseCalendarStats) -> Result<(), LseError> {
588 self.resolve(stats).map(|_| ())
589 }
590
591 /// Validate, and return the query parameters the request must actually be sent with.
592 ///
593 /// Returning the parameters rather than rebuilding them at the call site is what guarantees the
594 /// normalised country codes and the provider's own impact spelling are the ones that reach the
595 /// wire โ sending the caller's `GB` unchanged is precisely the silent zero-row case this module
596 /// exists to close.
597 fn resolve(&self, stats: &LseCalendarStats) -> Result<Vec<(&'static str, String)>, LseError> {
598 if self.end < self.start {
599 return Err(LseError::InvalidInput {
600 message: format!(
601 "economic-calendar range end {} precedes start {}",
602 self.end, self.start
603 ),
604 });
605 }
606
607 let mut normalised = Vec::with_capacity(self.countries.len());
608 for country in &self.countries {
609 let code = LseCalendarStats::normalise_country(country);
610
611 if !stats.countries.iter().any(|known| known == &code) {
612 return Err(LseError::UnknownCalendarCountry {
613 requested: country.to_string(),
614 normalised: code.to_string(),
615 });
616 }
617
618 normalised.push(code);
619 }
620
621 let impact = match &self.impact {
622 Some(requested) => Some(
623 stats
624 .resolve_impact(requested)
625 .ok_or_else(|| LseError::UnknownCalendarImpact {
626 requested: requested.to_string(),
627 available: stats.impacts.iter().map(ToString::to_string).collect(),
628 })?
629 .to_string(),
630 ),
631 None => None,
632 };
633
634 // The only range check the provider makes possible. Per-country coverage is not published,
635 // so a window inside this one may still hold nothing -- which is not an error here.
636 let earliest = stats.earliest_date()?;
637 let latest = stats.latest_date()?;
638
639 if self.end < earliest || self.start > latest {
640 return Err(LseError::CalendarRangeOutsideCoverage {
641 start: self.start,
642 end: self.end,
643 earliest,
644 latest,
645 });
646 }
647
648 let mut params = vec![
649 ("start_date", self.start.format(DATE_FORMAT).to_string()),
650 ("end_date", self.end.format(DATE_FORMAT).to_string()),
651 // Absent, this endpoint serves CSV.
652 ("format", JSON_FORMAT.to_string()),
653 ];
654
655 // Joined, never repeated: the provider keeps only the last of a repeated key, discarding
656 // every earlier country without an error.
657 if !normalised.is_empty() {
658 params.push((
659 "country",
660 normalised
661 .iter()
662 .map(SmolStr::as_str)
663 .collect::<Vec<_>>()
664 .join(COUNTRY_SEPARATOR),
665 ));
666 }
667
668 if let Some(impact) = impact {
669 params.push(("impact", impact));
670 }
671
672 Ok(params)
673 }
674}
675
676/// The `{count, data}` envelope `/economic-calendar` answers with.
677///
678/// `count` is carried so it can be checked against the events actually delivered; see
679/// [`fetch_economic_calendar`](LseDataApiClient::fetch_economic_calendar).
680#[derive(Debug, Deserialize)]
681struct LseCalendarEnvelope {
682 count: usize,
683 data: Vec<LseCalendarEvent>,
684}
685
686impl LseDataApiClient {
687 /// Fetch the provider's index of published economic-calendar coverage.
688 ///
689 /// Fetch this **once** and reuse the handle across queries โ see [`LseCalendarStats`]. It is
690 /// the required first argument to
691 /// [`fetch_economic_calendar`](Self::fetch_economic_calendar), which is what makes a query
692 /// checkable against an endpoint that validates nothing itself.
693 ///
694 /// It is also how a caller detects the frozen feed reviving:
695 /// [`latest`](LseCalendarStats::latest) has not moved past 2026-03-24 across measurements two
696 /// months apart.
697 ///
698 /// # Errors
699 /// See [`LseError`].
700 pub async fn fetch_economic_calendar_stats(&self) -> Result<LseCalendarStats, LseError> {
701 self.get_json(
702 ECONOMIC_CALENDAR_STATS_PATH,
703 &[("format", JSON_FORMAT.to_string())],
704 )
705 .await
706 }
707
708 /// Fetch economic-calendar events for `query`.
709 ///
710 /// `stats` is **required**, not optional, and `query` is validated against it before anything
711 /// is sent. That is a deliberate API choice: the endpoint answers `200` with zero rows for an
712 /// unknown country, an unknown impact rating, a reversed range *and* an empty window alike, so
713 /// a fetch that could skip validation would be a fetch that could silently return nothing. See
714 /// the [module documentation](self) for the measurements.
715 ///
716 /// The country codes sent are the **normalised** ones โ a caller passing `GB` reaches the
717 /// provider's `UK` โ joined into a single comma-separated parameter.
718 ///
719 /// # ๐ด Nothing is published after 2026-03-24
720 /// The feed is frozen. A window after that date is rejected by validation; a window spanning it
721 /// returns only what exists up to it. This endpoint serves a historical archive, not a
722 /// forward-looking calendar. See the [module documentation](self).
723 ///
724 /// # ๐ด An empty result is not necessarily an error
725 /// The provider publishes no per-country coverage, and coverage is very uneven โ 88 of 108
726 /// countries hold fewer than 100 events, and `UK` holds 56, all in early 2026. A query for a
727 /// published country inside the global range can legitimately return an empty [`Vec`], and
728 /// that is **not** reported as an error, because the library has no way to distinguish it from
729 /// a quiet period. Only the three checks validation can actually make are made.
730 ///
731 /// # The whole answer arrives in one response
732 /// This endpoint is **not paged**: its envelope carries no cursor, the provider's OpenAPI
733 /// document declares no `limit`, `offset`, `cursor` or `page` parameter, and a request for the
734 /// entire corpus with no country filter returned all 124,896 events in a single 29 MB
735 /// response, matching `total_events` exactly. So this returns a `Vec` rather than a stream, and
736 /// no pagination is invented for a surface that has none.
737 ///
738 /// The envelope's own `count` is checked against the events delivered and a mismatch is an
739 /// error rather than a short result โ that is the signal a silent page cap would produce if the
740 /// provider ever introduced one.
741 ///
742 /// # โ ๏ธ An unnarrowed query is a large request
743 /// That whole-corpus fetch took **6.3 seconds**, against this client's 30-second total request
744 /// timeout. It is the measured worst case rather than a typical one, but the margin is not
745 /// enormous: narrow the countries or the window for anything latency-sensitive.
746 ///
747 /// # Errors
748 /// Anything [`validate`](LseCalendarQuery::validate) raises, plus [`LseError`]'s transport and
749 /// decode variants. A response whose `count` disagrees with the events delivered is reported as
750 /// [`LseError::Api`] carrying the `200` the provider actually sent.
751 pub async fn fetch_economic_calendar(
752 &self,
753 stats: &LseCalendarStats,
754 query: &LseCalendarQuery,
755 ) -> Result<Vec<LseCalendarEvent>, LseError> {
756 let params = query.resolve(stats)?;
757
758 let envelope: LseCalendarEnvelope = self.get_json(ECONOMIC_CALENDAR_PATH, ¶ms).await?;
759
760 if envelope.count != envelope.data.len() {
761 return Err(LseError::Api {
762 status: 200,
763 message: format!(
764 "economic-calendar response declared {} events but carried {}: the response \
765 appears to have been truncated",
766 envelope.count,
767 envelope.data.len()
768 ),
769 });
770 }
771
772 Ok(envelope.data)
773 }
774}
775
776#[cfg(test)]
777#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
778mod tests {
779 use super::*;
780 use rust_decimal_macros::dec;
781
782 /// A stats handle shaped like the live one: `UK` present, `GB` absent, `EA` and `EU` among the
783 /// codes, and the four-rating impact vocabulary including the literal `"None"`.
784 fn stats() -> LseCalendarStats {
785 LseCalendarStats {
786 countries: ["EA", "EU", "JP", "UK", "US"]
787 .into_iter()
788 .map(SmolStr::new)
789 .collect(),
790 impacts: ["High", "Low", "Medium", "None"]
791 .into_iter()
792 .map(SmolStr::new)
793 .collect(),
794 earliest: "2014-12-31 23:00:00+00:00".to_string(),
795 latest: "2026-03-24 20:00:00+00:00".to_string(),
796 total_events: 124_896,
797 }
798 }
799
800 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
801 NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
802 }
803
804 fn params(query: &LseCalendarQuery) -> Vec<(&'static str, String)> {
805 query.resolve(&stats()).expect("query should validate")
806 }
807
808 fn param<'a>(params: &'a [(&'static str, String)], key: &str) -> Option<&'a str> {
809 params
810 .iter()
811 .find(|(name, _)| *name == key)
812 .map(|(_, value)| value.as_str())
813 }
814
815 /// Every numeric arrives as a JSON string and absence is spelled `null`. The decoder uses
816 /// `rust_decimal::serde::str_option`; a JSON number would fail it outright.
817 #[test]
818 fn an_event_decodes_with_string_encoded_numerics_and_null_absences() {
819 let json = r#"{
820 "event_date": "2026-03-24 14:00:00+00:00",
821 "country": "US",
822 "event": "CPI (YoY)",
823 "currency": "USD",
824 "previous": "2.9",
825 "estimate": null,
826 "actual": "-3.1",
827 "change": "-0.2",
828 "change_percentage": "-6.9",
829 "impact": "High",
830 "unit": "%"
831 }"#;
832
833 let event: LseCalendarEvent = serde_json::from_str(json).unwrap();
834
835 assert_eq!(event.country, "US");
836 assert_eq!(event.event, "CPI (YoY)");
837 assert_eq!(event.currency.as_deref(), Some("USD"));
838 assert_eq!(event.previous, Some(dec!(2.9)));
839 assert_eq!(event.estimate, None);
840 // Negatives are abundant on this feed and must survive the decode.
841 assert_eq!(event.actual, Some(dec!(-3.1)));
842 assert_eq!(event.change, Some(dec!(-0.2)));
843 assert_eq!(event.change_percentage, Some(dec!(-6.9)));
844 assert_eq!(event.impact, LseCalendarImpact::High);
845 assert_eq!(event.unit.as_deref(), Some("%"));
846 }
847
848 /// ๐ด `"None"` is a literal four-character rating carried by 592 events, not a JSON null. A
849 /// decoder that folded it into an absent value would lose a real distinction.
850 #[test]
851 fn an_impact_of_none_is_a_rating_and_not_an_absent_value() {
852 let json = r#"{
853 "event_date": "2026-03-24 14:00:00+00:00",
854 "country": "JP", "event": "Bank Holiday", "currency": null,
855 "previous": null, "estimate": null, "actual": null,
856 "change": null, "change_percentage": null,
857 "impact": "None", "unit": null
858 }"#;
859
860 let event: LseCalendarEvent = serde_json::from_str(json).unwrap();
861
862 assert_eq!(event.impact, LseCalendarImpact::None);
863 assert_eq!(event.impact.as_str(), "None");
864 assert_eq!(event.impact.to_string(), "None");
865 // Nullable fields really are null on this feed -- never an empty string.
866 assert_eq!(event.currency, None);
867 assert_eq!(event.unit, None);
868 }
869
870 /// The set is closed at four. An addition must surface rather than be absorbed.
871 #[test]
872 fn an_unknown_impact_rating_fails_the_decode_rather_than_being_absorbed() {
873 let json = r#"{
874 "event_date": "2026-03-24 14:00:00+00:00",
875 "country": "US", "event": "x", "currency": null,
876 "previous": null, "estimate": null, "actual": null,
877 "change": null, "change_percentage": null,
878 "impact": "Critical", "unit": null
879 }"#;
880
881 assert!(serde_json::from_str::<LseCalendarEvent>(json).is_err());
882 }
883
884 #[test]
885 fn an_event_ignores_an_unknown_field() {
886 let json = r#"{
887 "event_date": "2026-03-24 14:00:00+00:00",
888 "country": "US", "event": "x", "currency": null,
889 "previous": null, "estimate": null, "actual": null,
890 "change": null, "change_percentage": null,
891 "impact": "Low", "unit": null,
892 "a_field_the_provider_added_later": 1
893 }"#;
894
895 assert!(serde_json::from_str::<LseCalendarEvent>(json).is_ok());
896 }
897
898 /// ๐ด The provider's space-separated spelling is a real timestamp with 348 distinct times of
899 /// day, and `DateTime::parse_from_rfc3339` accepts the space as RFC 3339 ยง5.6 lenience. This is
900 /// the claim that licenses `DateTime<Utc>` rather than the bond endpoint's `NaiveDate`.
901 #[test]
902 fn an_event_date_parses_as_an_instant_keeping_its_time_of_day() {
903 let event = LseCalendarEvent {
904 event_date: "2026-03-24 14:30:00+00:00".to_string(),
905 country: SmolStr::new("US"),
906 event: "x".to_string(),
907 currency: None,
908 previous: None,
909 estimate: None,
910 actual: None,
911 change: None,
912 change_percentage: None,
913 impact: LseCalendarImpact::Low,
914 unit: None,
915 };
916
917 let time = event.event_time().unwrap();
918
919 assert_eq!(time.to_rfc3339(), "2026-03-24T14:30:00+00:00");
920 assert_eq!(event.event_day().unwrap(), date(2026, 3, 24));
921 }
922
923 #[test]
924 fn a_malformed_event_date_is_a_typed_error_naming_the_field() {
925 let event = LseCalendarEvent {
926 event_date: "not-a-date".to_string(),
927 country: SmolStr::new("US"),
928 event: "x".to_string(),
929 currency: None,
930 previous: None,
931 estimate: None,
932 actual: None,
933 change: None,
934 change_percentage: None,
935 impact: LseCalendarImpact::Low,
936 unit: None,
937 };
938
939 let error = event.event_time().unwrap_err();
940
941 assert!(matches!(error, LseError::Deserialize { .. }));
942 assert!(error.to_string().contains("event_date"), "{error}");
943 }
944
945 #[test]
946 fn gb_normalises_to_the_providers_uk_and_everything_else_is_untouched() {
947 assert_eq!(LseCalendarStats::normalise_country("GB"), "UK");
948 assert_eq!(LseCalendarStats::normalise_country("gb"), "UK");
949 assert_eq!(LseCalendarStats::normalise_country(" Gb "), "UK");
950 assert_eq!(LseCalendarStats::normalise_country("us"), "US");
951 // `EA` and `EU` are in this endpoint's vocabulary and are not countries; nothing maps them.
952 assert_eq!(LseCalendarStats::normalise_country("ea"), "EA");
953 assert_eq!(LseCalendarStats::normalise_country("eu"), "EU");
954 }
955
956 #[test]
957 fn a_country_lookup_finds_the_uk_under_its_iso_spelling() {
958 let stats = stats();
959
960 assert!(stats.publishes_country("GB"));
961 assert!(stats.publishes_country("gb"));
962 assert!(stats.publishes_country("UK"));
963 assert!(!stats.publishes_country("ZZ"));
964 }
965
966 #[test]
967 fn an_impact_resolves_ignoring_case_and_yields_the_providers_spelling() {
968 let stats = stats();
969
970 assert_eq!(stats.resolve_impact("high").unwrap(), "High");
971 assert_eq!(stats.resolve_impact(" HIGH ").unwrap(), "High");
972 assert_eq!(stats.resolve_impact("None").unwrap(), "None");
973 assert!(stats.resolve_impact("Critical").is_none());
974 }
975
976 #[test]
977 fn a_valid_query_passes() {
978 let query = LseCalendarQuery::new(date(2024, 1, 1), date(2024, 12, 31)).with_country("US");
979
980 assert!(query.validate(&stats()).is_ok());
981 }
982
983 #[test]
984 fn an_inverted_range_is_rejected() {
985 let query = LseCalendarQuery::new(date(2024, 12, 31), date(2024, 1, 1));
986
987 let error = query.validate(&stats()).unwrap_err();
988
989 assert!(matches!(error, LseError::InvalidInput { .. }));
990 }
991
992 #[test]
993 fn an_unknown_country_is_rejected_and_reports_both_spellings() {
994 let query = LseCalendarQuery::new(date(2024, 1, 1), date(2024, 12, 31)).with_country("zz");
995
996 let error = query.validate(&stats()).unwrap_err();
997
998 let LseError::UnknownCalendarCountry {
999 requested,
1000 normalised,
1001 } = &error
1002 else {
1003 panic!("expected UnknownCalendarCountry, got {error:?}");
1004 };
1005 assert_eq!(requested, "zz");
1006 assert_eq!(normalised, "ZZ");
1007 }
1008
1009 /// One bad code among several must not be let through by a check that only looked at the first.
1010 #[test]
1011 fn an_unknown_country_anywhere_in_the_list_is_rejected() {
1012 let query = LseCalendarQuery::new(date(2024, 1, 1), date(2024, 12, 31))
1013 .with_countries(["US", "UK", "ZZ"]);
1014
1015 assert!(matches!(
1016 query.validate(&stats()).unwrap_err(),
1017 LseError::UnknownCalendarCountry { .. }
1018 ));
1019 }
1020
1021 #[test]
1022 fn an_unknown_impact_is_rejected_and_lists_the_published_vocabulary() {
1023 let query =
1024 LseCalendarQuery::new(date(2024, 1, 1), date(2024, 12, 31)).with_impact("Critical");
1025
1026 let error = query.validate(&stats()).unwrap_err();
1027
1028 let LseError::UnknownCalendarImpact {
1029 requested,
1030 available,
1031 } = &error
1032 else {
1033 panic!("expected UnknownCalendarImpact, got {error:?}");
1034 };
1035 assert_eq!(requested, "Critical");
1036 assert_eq!(available, &["High", "Low", "Medium", "None"]);
1037 }
1038
1039 /// ๐ด The frozen feed: every forward-looking window is disjoint from the published range.
1040 #[test]
1041 fn a_window_after_the_frozen_latest_is_rejected() {
1042 let query = LseCalendarQuery::new(date(2026, 4, 1), date(2026, 12, 31));
1043
1044 let error = query.validate(&stats()).unwrap_err();
1045
1046 let LseError::CalendarRangeOutsideCoverage {
1047 start,
1048 end,
1049 earliest,
1050 latest,
1051 } = &error
1052 else {
1053 panic!("expected CalendarRangeOutsideCoverage, got {error:?}");
1054 };
1055 assert_eq!(*start, date(2026, 4, 1));
1056 assert_eq!(*end, date(2026, 12, 31));
1057 assert_eq!(*earliest, date(2014, 12, 31));
1058 assert_eq!(*latest, date(2026, 3, 24));
1059 }
1060
1061 #[test]
1062 fn a_window_before_the_earliest_event_is_rejected() {
1063 let query = LseCalendarQuery::new(date(2010, 1, 1), date(2014, 12, 30));
1064
1065 assert!(matches!(
1066 query.validate(&stats()).unwrap_err(),
1067 LseError::CalendarRangeOutsideCoverage { .. }
1068 ));
1069 }
1070
1071 /// A window straddling the frozen end returns what exists up to it, so it must pass.
1072 #[test]
1073 fn a_partially_overlapping_range_passes() {
1074 let query = LseCalendarQuery::new(date(2026, 3, 1), date(2026, 12, 31));
1075
1076 assert!(query.validate(&stats()).is_ok());
1077 }
1078
1079 /// ๐ด The validation the library deliberately does NOT do. `UK` holds 56 events, all in early
1080 /// 2026, so this window returns nothing โ and the provider publishes no per-country coverage,
1081 /// so it cannot be distinguished from a quiet period. It must validate, not error.
1082 #[test]
1083 fn a_published_country_with_no_events_in_the_window_still_validates() {
1084 let query = LseCalendarQuery::new(date(2020, 1, 1), date(2020, 12, 31)).with_country("UK");
1085
1086 assert!(
1087 query.validate(&stats()).is_ok(),
1088 "an empty-but-legitimate window must not be an error: the provider publishes no \
1089 per-country coverage to justify one"
1090 );
1091 }
1092
1093 /// ๐ด The trap. A repeated `country` key silently keeps only the last value, so the countries
1094 /// must reach the wire as ONE comma-joined parameter.
1095 #[test]
1096 fn several_countries_are_sent_as_one_comma_joined_parameter() {
1097 let query = LseCalendarQuery::new(date(2024, 1, 1), date(2024, 12, 31))
1098 .with_countries(["US", "UK"]);
1099 let params = params(&query);
1100
1101 assert_eq!(
1102 params.iter().filter(|(name, _)| *name == "country").count(),
1103 1,
1104 "the country key must never be repeated: the provider keeps only the last value"
1105 );
1106 assert_eq!(param(¶ms, "country"), Some("US,UK"));
1107 }
1108
1109 /// The normalisation has to reach the wire, not merely the membership check.
1110 #[test]
1111 fn the_request_carries_the_normalised_country_codes() {
1112 let query = LseCalendarQuery::new(date(2024, 1, 1), date(2024, 12, 31))
1113 .with_countries(["gb", "us"]);
1114
1115 assert_eq!(param(¶ms(&query), "country"), Some("UK,US"));
1116 }
1117
1118 #[test]
1119 fn the_request_carries_the_providers_own_impact_spelling() {
1120 let query =
1121 LseCalendarQuery::new(date(2024, 1, 1), date(2024, 12, 31)).with_impact("medium");
1122
1123 assert_eq!(param(¶ms(&query), "impact"), Some("Medium"));
1124 }
1125
1126 /// Absent filters are omitted rather than sent empty: this endpoint answers an unknown filter
1127 /// with zero rows rather than an error, so an empty value would be a silent empty result.
1128 #[test]
1129 fn an_unfiltered_query_omits_country_and_impact_entirely() {
1130 let query = LseCalendarQuery::new(date(2024, 1, 1), date(2024, 12, 31));
1131 let params = params(&query);
1132
1133 assert_eq!(param(¶ms, "country"), None);
1134 assert_eq!(param(¶ms, "impact"), None);
1135 assert_eq!(param(¶ms, "start_date"), Some("2024-01-01"));
1136 assert_eq!(param(¶ms, "end_date"), Some("2024-12-31"));
1137 }
1138
1139 /// โ ๏ธ Omitting `format` serves CSV, which would hand a CSV body to a JSON decoder.
1140 #[test]
1141 fn every_request_asks_for_json_because_the_endpoint_defaults_to_csv() {
1142 let query = LseCalendarQuery::new(date(2024, 1, 1), date(2024, 12, 31));
1143
1144 assert_eq!(param(¶ms(&query), "format"), Some("json"));
1145 }
1146
1147 #[test]
1148 fn stats_decode_with_their_integer_typed_total() {
1149 let json = r#"{
1150 "countries": ["EA", "UK", "US"],
1151 "impacts": ["High", "Low", "Medium", "None"],
1152 "earliest": "2014-12-31 23:00:00+00:00",
1153 "latest": "2026-03-24 20:00:00+00:00",
1154 "total_events": 124896
1155 }"#;
1156
1157 let stats: LseCalendarStats = serde_json::from_str(json).unwrap();
1158
1159 assert_eq!(stats.total_events, 124_896);
1160 assert_eq!(
1161 stats.earliest_time().unwrap(),
1162 date(2014, 12, 31).and_hms_opt(23, 0, 0).unwrap().and_utc()
1163 );
1164 assert_eq!(
1165 stats.latest_time().unwrap(),
1166 date(2026, 3, 24).and_hms_opt(20, 0, 0).unwrap().and_utc()
1167 );
1168 assert_eq!(stats.earliest_date().unwrap(), date(2014, 12, 31));
1169 assert_eq!(stats.latest_date().unwrap(), date(2026, 3, 24));
1170 }
1171
1172 /// The spelling the provider served before it switched to timestamps. Rejected rather than
1173 /// read as midnight: one grammar for every calendar instant, and a switch back surfaces here and
1174 /// in the live canary instead of being absorbed.
1175 #[test]
1176 fn a_bare_date_stats_bound_is_rejected() {
1177 let mut stats = stats();
1178 stats.earliest = "2014-12-31".to_string();
1179
1180 let error = stats.earliest_date().unwrap_err();
1181
1182 assert!(matches!(error, LseError::Deserialize { .. }));
1183 assert!(error.to_string().contains("earliest"), "{error}");
1184 }
1185
1186 #[test]
1187 fn a_malformed_stats_date_fails_validation_rather_than_being_ignored() {
1188 let mut stats = stats();
1189 stats.latest = "24/03/2026".to_string();
1190
1191 let query = LseCalendarQuery::new(date(2024, 1, 1), date(2024, 12, 31));
1192 let error = query.validate(&stats).unwrap_err();
1193
1194 assert!(matches!(error, LseError::Deserialize { .. }));
1195 assert!(error.to_string().contains("latest"), "{error}");
1196 }
1197}