Skip to main content

rustrade_data/exchange/lse/
error.rs

1use crate::exchange::lse::quota::QuotaStatus;
2use crate::subscription::candle::CandleInterval;
3use chrono::{DateTime, NaiveDate, Utc};
4use std::time::Duration;
5use thiserror::Error;
6
7/// Errors produced by the London Strategic Edge integration.
8///
9/// `#[non_exhaustive]`: further variants are added alongside the endpoints that raise them.
10///
11/// Deliberately not `Clone`/`PartialEq` — [`Http`](Self::Http) wraps a [`reqwest::Error`], which is
12/// neither, matching every other REST-backed integration in this crate.
13#[derive(Debug, Error)]
14#[non_exhaustive]
15pub enum LseError {
16    /// More than one distinct dataset series resolves to the requested slug, so no single dataset
17    /// can be identified.
18    ///
19    /// Returned rather than picking one, because the provider's `/dataset/info` endpoint answers
20    /// `200` for an ambiguous slug — silently serving whichever series it prefers. Two measured
21    /// families produce this: eleven Eurex futures that publish both a bare and a `.F` series
22    /// containing *different* data (the bare series is frequently the far larger one and has no
23    /// slug of its own), and futures whose stripped symbol collides with an unrelated equity
24    /// ticker.
25    #[error(
26        "ambiguous dataset slug {slug:?} for symbol {symbol:?}: more than one series resolves to \
27         it, so it cannot identify a dataset - query the catalog and select explicitly"
28    )]
29    AmbiguousSlug { symbol: String, slug: String },
30
31    /// The provided string does not name a known London Strategic Edge price dataset.
32    #[error("unknown dataset {0:?}")]
33    UnknownDataset(String),
34
35    /// A required environment variable is not set, or does not hold valid UTF-8 (see
36    /// [`LseVaultClient::from_env`]).
37    ///
38    /// The message names the variable and never its value, so a mis-encoded key cannot reach a log
39    /// line through this error.
40    ///
41    /// [`LseVaultClient::from_env`]: super::vault::LseVaultClient::from_env
42    #[error("environment variable error: {0}")]
43    EnvVar(String),
44
45    /// The supplied API key cannot be encoded as an HTTP header value (e.g. non-ASCII bytes).
46    #[error("invalid credential: {0}")]
47    InvalidCredential(String),
48
49    /// The request could not be completed at the transport layer.
50    #[error("HTTP error: {0}")]
51    Http(#[from] reqwest::Error),
52
53    /// The API returned a non-success status, or a success response this integration cannot use.
54    ///
55    /// `message` is the provider's own diagnostic, unwrapped from its response envelope and
56    /// truncated to a bounded length — or, where `status` is a success code, this integration's own
57    /// diagnostic for a response that violated the contract that status implies. A `206` whose
58    /// `Content-Range` is missing, unparseable, or does not resume where the `Range` asked, and a
59    /// `200` page that repeats the cursor it was given, all arrive here: the status is the one the
60    /// provider sent, so it stays reportable, but the fault is one only the client can see.
61    #[error("API error ({status}): {message}")]
62    Api { status: u16, message: String },
63
64    /// The request rate was exceeded.
65    ///
66    /// Terminal — this integration never sleeps and retries on the caller's behalf. A paged fetch
67    /// yields this and **ends**; resume by re-requesting from the last `close_time` received.
68    ///
69    /// The provider permits [`calls_per_minute`](QuotaStatus::calls_per_minute) requests per
70    /// minute, reported by [`usage`](super::vault::LseVaultClient::usage).
71    ///
72    /// # Caveat
73    /// On the *export* endpoints an exhausted allowance is distinguished and reported as
74    /// [`QuotaExceeded`](Self::QuotaExceeded). On the candle path the provider offers no way to
75    /// tell a per-minute rate limit from an exhausted byte allowance, so both arrive here. Either
76    /// way it is observable and terminal, and never silently retried.
77    #[error("rate limited{}", match .retry_after {
78        Some(delay) => format!("; retry after {}s", delay.as_secs()),
79        None => String::new(),
80    })]
81    RateLimited { retry_after: Option<Duration> },
82
83    /// A response body could not be decoded into the expected shape.
84    #[error("invalid response: {message}")]
85    Deserialize { message: String },
86
87    /// The request is malformed in a way the caller must fix, detected before it is sent.
88    #[error("invalid input: {message}")]
89    InvalidInput { message: String },
90
91    /// The requested country is not one the provider publishes bond yields for.
92    ///
93    /// Raised before the request is sent, because `/bond-yields` performs **no validation of its
94    /// own**: a country it does not know answers `200` with `count: 0`, which is byte-for-byte
95    /// indistinguishable from an unknown maturity and from a range that is simply empty. Measured:
96    /// `country=ZZ` and `country=GB` both return `200 count=0`, against `country=UK` returning rows.
97    ///
98    /// `normalised` is what `requested` became before the lookup — the provider keys the United
99    /// Kingdom `UK`, **not** the ISO-3166 `GB` its own `country_iso2` column name implies, and that
100    /// is the only divergence among its 34 codes. Both forms are reported so a caller can see which
101    /// one was actually looked up.
102    #[error(
103        "unknown bond-yield country {requested:?} (looked up as {normalised:?}): the provider does          not publish it - consult the stats handle for the countries it does"
104    )]
105    UnknownBondYieldCountry {
106        requested: String,
107        normalised: String,
108    },
109
110    /// The requested maturity is not one the provider publishes for that country.
111    ///
112    /// Raised before the request is sent, for the same reason as
113    /// [`UnknownBondYieldCountry`](Self::UnknownBondYieldCountry): measured, `maturity=99Y` answers
114    /// `200` with `count: 0` rather than an error.
115    ///
116    /// `available` is the country's published tenor list, carried in full because it is short
117    /// (fifteen at the widest measured) and because a tenor is a **provider label**, not a
118    /// duration — `5Y` and `5Y TIPS` are distinct series that report the same `maturity_days`, so
119    /// there is nothing a caller can compute the right spelling from.
120    #[error(
121        "unknown bond-yield maturity {requested:?} for country {country}: the provider publishes          {available:?}"
122    )]
123    UnknownBondYieldMaturity {
124        country: String,
125        requested: String,
126        available: Vec<String>,
127    },
128
129    /// The requested range lies entirely outside the published coverage of that series.
130    ///
131    /// The third cause of a `200 count=0`, and the one that membership alone cannot catch: a
132    /// `(country, maturity)` pair can exist and still hold nothing in the window asked for.
133    /// Measured on `US 10Y TIPS` and `TR 2Y`, both of which return zero rows for 2023-2024 and both
134    /// of which report a `first_date` of 2025-07-07 — correct behaviour from the provider, and
135    /// silent.
136    ///
137    /// Raised only when the requested range and the published one are **disjoint**. A partial
138    /// overlap is not an error: it returns the rows that exist, which is what was asked for.
139    #[error(
140        "bond-yield range {start}..={end} lies outside the published coverage of {country}{} \
141         ({first_date}..={last_date})",
142        match .maturity {
143            Some(maturity) => format!(" {maturity}"),
144            None => String::new(),
145        }
146    )]
147    BondYieldRangeOutsideCoverage {
148        country: String,
149        /// `None` when the query named no maturity, so the bound is the country's own coverage.
150        maturity: Option<String>,
151        start: NaiveDate,
152        end: NaiveDate,
153        first_date: NaiveDate,
154        last_date: NaiveDate,
155    },
156
157    /// The requested country is not one the economic calendar publishes.
158    ///
159    /// Raised before the request is sent, for the same reason as
160    /// [`UnknownBondYieldCountry`](Self::UnknownBondYieldCountry): the endpoint answers an unknown
161    /// country with `200` and `count: 0`, indistinguishable from a quiet window.
162    ///
163    /// `normalised` is what `requested` became before the lookup. The calendar keys the United
164    /// Kingdom `UK` rather than `GB`, exactly as `/bond-yields` does — but the two vocabularies are
165    /// **not** the same set: the calendar's 108 codes also include `EA` and `EU`, which are not
166    /// countries. Both forms are reported so a caller can see which one was actually looked up.
167    #[error(
168        "unknown economic-calendar country {requested:?} (looked up as {normalised:?}): the          provider does not publish it - consult the stats handle for the countries it does"
169    )]
170    UnknownCalendarCountry {
171        requested: String,
172        normalised: String,
173    },
174
175    /// The requested impact rating is not one the economic calendar publishes.
176    ///
177    /// Measured: `impact=Critical` answers `200` with `count: 0` rather than an error, so this is
178    /// raised before the request is sent.
179    ///
180    /// `available` is carried in full because the vocabulary is closed and short — exactly
181    /// `["High", "Low", "Medium", "None"]`. ⚠️ `"None"` in that list is a literal rating, not an
182    /// absent value.
183    #[error("unknown economic-calendar impact {requested:?}: the provider publishes {available:?}")]
184    UnknownCalendarImpact {
185        requested: String,
186        available: Vec<String>,
187    },
188
189    /// The requested range lies entirely outside the calendar's published coverage.
190    ///
191    /// ⚠️ **A global bound, not a per-country one**, which makes this weaker than
192    /// [`BondYieldRangeOutsideCoverage`](Self::BondYieldRangeOutsideCoverage).
193    /// `/economic-calendar/stats` publishes no per-country coverage at all, so a window *inside*
194    /// this range may still legitimately return nothing — 88 of the 108 countries hold fewer than
195    /// 100 events. That case is deliberately **not** an error: the library has no way to
196    /// distinguish it from a quiet period, and inventing an error for it would mean inventing
197    /// knowledge the provider does not publish.
198    ///
199    /// 🔴 `latest` has been 2026-03-24 across measurements two months apart — the feed is frozen,
200    /// so **every** forward-looking window lands here.
201    ///
202    /// Raised only when the requested range and the published one are **disjoint**. A partial
203    /// overlap is not an error: it returns the events that exist, which is what was asked for.
204    #[error(
205        "economic-calendar range {start}..={end} lies outside the published coverage          ({earliest}..={latest})"
206    )]
207    CalendarRangeOutsideCoverage {
208        start: NaiveDate,
209        end: NaiveDate,
210        earliest: NaiveDate,
211        latest: NaiveDate,
212    },
213
214    /// The requested resolution is not one the provider serves.
215    ///
216    /// [`CandleInterval`] is the venue-agnostic union of every resolution any connector in this
217    /// crate serves; this provider serves 14 of them. Rejected before the request is sent rather
218    /// than relayed as a `400`, so the caller gets a typed answer.
219    #[error("unsupported candle interval {interval}: the provider does not serve this resolution")]
220    UnsupportedInterval { interval: CandleInterval },
221
222    /// A candle's period-end boundary is not representable.
223    ///
224    /// `close_time == open_time + interval` can overflow the representable [`DateTime<Utc>`]
225    /// range. Surfaced rather than clamped: a silently substituted boundary would be a
226    /// plausible-looking wrong timestamp on a real candle.
227    #[error("candle boundary overflow: open time {open} + {interval} is not representable")]
228    TimestampOverflow {
229        open: DateTime<Utc>,
230        interval: CandleInterval,
231    },
232
233    /// The pagination cursor could not be advanced past the last bar received.
234    ///
235    /// The cursor steps one second past the newest open time on a page, which is not representable
236    /// within a second of [`DateTime::MAX_UTC`]. Distinct from
237    /// [`TimestampOverflow`](Self::TimestampOverflow), which is a *candle's* period end: the addend
238    /// here is the cursor step, not the interval, and naming the interval would send a reader
239    /// looking at arithmetic the code never performed.
240    #[error("pagination cursor overflow: open time {last_open} + 1s is not representable")]
241    CursorOverflow { last_open: DateTime<Utc> },
242
243    /// The vault served a candle closing past the requested `end`.
244    ///
245    /// The upper bound sent to the vault is exact by construction: it is `end - interval + 1s`
246    /// against a parameter that is *exclusive* on open time, so the newest bar a compliant page can
247    /// carry is the one whose close falls exactly on `end`. A later one means the range parameters
248    /// were not honoured — the same silently-ignored-parameter failure as a page that repeats the
249    /// cursor it was given, which is why both are terminal rather than quietly repaired.
250    ///
251    /// # Not symmetric with the lower bound, deliberately
252    /// The lower bound is widened by one interval *on purpose*, to readmit the bar whose close
253    /// equals `start`, so a page legitimately carries bars closing before `start` and those are
254    /// trimmed without comment. Nothing widens the upper bound, so there is no benign reading of a
255    /// bar past it.
256    ///
257    /// Every in-range bar on the offending page is yielded **before** this arrives: the page is
258    /// scanned to the end first, so failing here costs none of the data the response did contain.
259    #[error(
260        "page {page} for {symbol:?} returned a candle closing after {end} (cursor {cursor}): the \
261         range parameters appear to have been ignored"
262    )]
263    UnexpectedCandleRange {
264        symbol: String,
265        cursor: DateTime<Utc>,
266        page: usize,
267        end: DateTime<Utc>,
268    },
269
270    /// A candle page stepped **backwards** in open time.
271    ///
272    /// Ascending order is how the vault answers today, not something it guarantees, and the
273    /// pagination cursor depends on it being at least non-descending *across* pages: the cursor
274    /// advances past the newest open a page carried, so a page served newest-first advances it to
275    /// the end of the range immediately. The next page is then empty, which is the documented
276    /// end-of-data signal, and `collect_candles` returns `Ok` with the last page of a multi-million
277    /// bar range and no indication that the rest was skipped.
278    ///
279    /// Terminal rather than repaired by sorting: a page arriving in an order the cursor arithmetic
280    /// was not built for means the vault changed behaviour, and every later assumption in this
281    /// module — the trim bounds, the cursor step, the end-of-data signal — was derived from the old
282    /// one. The same property is a typed rejection on the Parquet path
283    /// ([`NonMonotonicTimestamps`](Self::NonMonotonicTimestamps)).
284    #[error(
285        "page {page} for {symbol:?} stepped backwards in open time: {open} follows {previous_open}"
286    )]
287    NonMonotonicCandlePage {
288        symbol: String,
289        page: usize,
290        previous_open: DateTime<Utc>,
291        open: DateTime<Utc>,
292    },
293
294    /// Two consecutive candles are spaced closer together than the interval they were asked for.
295    ///
296    /// Impossible for a compliant fixed-interval series: consecutive bars are exactly one interval
297    /// apart, and a gap (a weekend, a holiday, a quiet symbol) only ever makes the spacing *wider*.
298    /// Spacing narrower than the interval means the candles are at a **finer resolution than the one
299    /// requested**, so every `close_time` derived for them overlaps the bars that follow.
300    ///
301    /// # The failure this exists to catch, on the vault path
302    /// The vault answers `200` to a misspelled resolution parameter and silently defaults to
303    /// 1-minute bars, in a byte-identical response shape (see the `vault` module docs). A `Day1`
304    /// request would then receive 1,440 one-minute rows per day, each stamped with a `close_time` of
305    /// `open + 24h` by the `historical` module's boundary arithmetic — overlapping, wrong, and
306    /// invisible to every other check there, because the range bounds are still satisfied and the
307    /// series still ascends. Spacing is the one property that distinguishes it.
308    ///
309    /// # And on the export path
310    /// `parquet::read_export` derives `close_time` from a **caller-supplied** interval, since
311    /// nothing in a Parquet artifact records the resolution it was written at. The wrong resolution
312    /// therefore reaches the same arithmetic from a different origin — the caller's declaration
313    /// rather than the provider's answer — and produces the same overlapping bars, so the same
314    /// check guards it: a file of 1-minute bars decoded as `Day1` is rejected rather than yielding
315    /// 390 "daily" bars per trading day. Reported in terms of the artifact's `ts` column, which
316    /// carries the bar's open — the close is a value that decoder derived, and only the open is
317    /// something a caller can find in the file itself.
318    ///
319    /// # Limits
320    /// Only the **finer-than-asked-for** direction is detectable. Coarser is not: daily bars taken
321    /// for `Min1` are spaced 24 hours apart, which is wider than 60 seconds and indistinguishable
322    /// from a genuine gap. A single candle is not checked at all, having no pair to measure.
323    ///
324    /// Only [`IntervalStep::Fixed`](crate::subscription::candle::IntervalStep::Fixed) resolutions
325    /// are checked. A calendar step (`month`, `quarter`, `year`) has no single width to compare
326    /// against, and February would false-positive against a 31-day one.
327    #[error(
328        "candles for {symbol:?} are spaced {actual} apart but {interval} was asked for ({open} \
329         follows {previous_open}): they appear to be at a finer resolution than that"
330    )]
331    UnexpectedCandleResolution {
332        symbol: String,
333        interval: CandleInterval,
334        previous_open: DateTime<Utc>,
335        open: DateTime<Utc>,
336        // `chrono::TimeDelta`, not the `std::time::Duration` imported above: the spacing is a
337        // difference between two instants and the type that produces it is chrono's.
338        actual: chrono::TimeDelta,
339    },
340
341    /// A single second of the option print tape holds more prints than one page can carry.
342    ///
343    /// The flow endpoint truncates silently at its row cap and accepts only whole-second bounds, so
344    /// a one-second window that still comes back full cannot be narrowed further, and the prints
345    /// beyond the cap cannot be read at all. Terminal rather than returned short: the alternative is
346    /// a tape missing prints with nothing to say so.
347    #[error(
348        "the option print tape holds at least {rows} prints in the one second from {window_start}, \
349         more than one page can carry"
350    )]
351    OptionFlowWindowSaturated {
352        window_start: DateTime<Utc>,
353        rows: usize,
354    },
355
356    /// An option print came back outside the window or underlying it was requested for.
357    ///
358    /// Means the provider ignored a request parameter, which it is known to do silently — the flow
359    /// endpoint answers a `ticker` filter by returning every contract. Raised before any print of
360    /// the offending window is yielded.
361    #[error("option print {id} does not match its request: {message}")]
362    UnexpectedOptionFlowRow { id: u64, message: String },
363
364    /// The shared allowance is exhausted.
365    ///
366    /// Carries the allowance state at the point of rejection so the caller can decide how to pace.
367    /// Terminal — never retried internally.
368    ///
369    /// # Raised by the export path only
370    /// The bulk-export endpoints report allowance exhaustion as a `429` carrying no `Retry-After`
371    /// and no rate-limit headers, which this integration distinguishes from a per-minute rate
372    /// limit and reports here, populating the position from
373    /// [`usage`](super::vault::LseVaultClient::usage). A *candle* fetch that runs into a limit
374    /// still surfaces as [`RateLimited`](Self::RateLimited) — the provider gives no way to tell
375    /// the two apart on that path.
376    /// `status` is `None` when the follow-up [`usage`](super::vault::LseVaultClient::usage) call
377    /// itself failed. The allowance is still known to be exhausted — that is what the `429` said —
378    /// but this integration will not fabricate a [`QuotaStatus`] to fill the field. One variant
379    /// meaning "allowance gone, position unknown" is what lets a caller pace itself by matching a
380    /// single variant; reporting the second case as a generic
381    /// [`Api`](Self::Api)`{ status: 429 }` instead would make it silently miss half the cases.
382    #[error("quota exceeded{}", match .status {
383        Some(status) => format!(": {status:?}"),
384        None => " (allowance position could not be retrieved)".to_owned(),
385    })]
386    QuotaExceeded { status: Option<QuotaStatus> },
387
388    /// An export job reached a terminal state without producing an artifact.
389    ///
390    /// `expired` means the artifact was built and has since been reaped — roughly 48 hours after
391    /// it was created. Both are terminal; re-exporting costs another export.
392    #[error("export job {job_id} {status}{}", match .message.as_str() {
393        "" => String::new(),
394        message => format!(": {message}"),
395    })]
396    ExportFailed {
397        job_id: String,
398        status: String,
399        message: String,
400    },
401
402    /// An export job did not become ready within the caller's timeout.
403    ///
404    /// **The job is not cancelled and the identifier stays valid** — it keeps building, so polling
405    /// [`export_status`](super::vault::LseVaultClient::export_status) later picks it up without
406    /// spending another export. This is a timeout on waiting, not on the job.
407    #[error(
408        "export job {job_id} still {status} when the caller's timeout elapsed; it keeps building, so poll it again rather than re-exporting"
409    )]
410    ExportTimeout { job_id: String, status: String },
411
412    /// A downloaded artifact does not match the integrity metadata the job reported.
413    ///
414    /// The destination is left untouched rather than holding a corrupt artifact.
415    ///
416    /// `discarded` reports what happened to the partial file at `path`:
417    /// - `false` — it is **retained**, because it is *incomplete* rather than wrong: shorter than the
418    ///   artifact, so the bytes are a real prefix the next call resumes from with a `Range` request.
419    ///   For a multi-gigabyte artifact that is the difference between finishing and starting over.
420    /// - `true` — it was **removed**, because it is *corrupt*: longer than the artifact, or the right
421    ///   length at the wrong digest with nothing left to fetch. Retaining it would fail identically
422    ///   forever, so a re-call restarts instead.
423    #[error("integrity check failed for {}: expected {expected}, got {actual} ({})", .path.display(), match .discarded {
424        true => "unusable partial file discarded; re-call to restart",
425        false => "partial file kept for resume",
426    })]
427    IntegrityMismatch {
428        path: std::path::PathBuf,
429        expected: String,
430        actual: String,
431        discarded: bool,
432    },
433
434    /// A `ready` export job does not describe the request it is being downloaded for.
435    ///
436    /// The provider **silently substitutes defaults for parameters it does not recognise**, so a
437    /// request it partly ignored still yields a `ready` job — covering something other than what was
438    /// asked for. The job record echoes `dataset`, `symbol`, `timeframe`, `start` and `end`, and that
439    /// echo is the only client-side evidence of what the artifact actually contains. Downloading it
440    /// anyway would attribute one instrument's or one range's data to another, silently.
441    ///
442    /// Not recoverable by re-downloading: the artifact is what it is. The request has to be corrected
443    /// and re-exported, which costs one of the five hourly exports.
444    #[error(
445        "export job {job_id} does not describe this request: {field} is {reported:?}, requested {requested:?}"
446    )]
447    ExportJobMismatch {
448        job_id: String,
449        field: String,
450        requested: String,
451        reported: String,
452    },
453
454    /// A filesystem operation failed.
455    ///
456    /// `message` names the operation and the path; the underlying [`std::io::Error`] is retained as
457    /// the error [source](std::error::Error::source), so a caller can match on
458    /// [`std::io::ErrorKind`] rather than parse a string.
459    #[error("io error: {message}: {source}")]
460    Io {
461        message: String,
462        #[source]
463        source: std::io::Error,
464    },
465
466    /// An export artifact's columns match none of the layouts this integration decodes.
467    ///
468    /// The provider's tick schema **varies by dataset** — `fx` publishes `bid`/`ask`, `stocks`
469    /// publishes `price`/`volume`, and the synthetic classes publish `price`/`volume`/`ask` — so
470    /// the layout is resolved from the columns present. Reported rather than guessed at: a wrong
471    /// column guess yields plausible numbers in the wrong field.
472    ///
473    /// Also raised for a schema that is not **flat**. Columns are read by leaf index, which equals a
474    /// top-level field's position only when every field is a primitive: one nested group contributes
475    /// several leaves and shifts every index after it. Every measured artifact is flat, so rather
476    /// than support a nesting the provider has never emitted, it is rejected — the alternative is a
477    /// decoder whose column mapping is silently wrong on a file it accepted, which for a `bid`/`ask`
478    /// pair means reading one as the other.
479    #[error("unsupported export schema; columns were: {columns}")]
480    UnsupportedSchema { columns: String },
481
482    /// A recognised export column does not have the type this integration decodes it as.
483    ///
484    /// Distinct from [`UnsupportedSchema`](Self::UnsupportedSchema), which reports columns whose
485    /// *names* match no known layout. Here the layout is recognised and one of its columns is the
486    /// wrong type, so naming the column and both types is the whole diagnostic — reported up front
487    /// rather than as an opaque decode failure on the first row.
488    ///
489    /// # Why a `ts` column that is not UTC-adjusted lands here
490    /// `Timestamp { unit: MICROS, is_adjusted_to_utc: false }` is *physically identical* to the
491    /// UTC-adjusted form — same `INT64`, same legacy `TIMESTAMP_MICROS` converted type — and differs
492    /// only in the origin its values are measured against. Read as epoch microseconds, a local-time
493    /// column shifts every event by the venue's UTC offset with nothing downstream able to notice:
494    /// the timeline is still monotonic and the prices are still right, so a backtest simply trades
495    /// on data it could not have had. This check is the only place that difference is visible.
496    #[error("export column {column:?} is {found}, but this integration requires {required}")]
497    UnsupportedColumnType {
498        column: String,
499        required: &'static str,
500        found: String,
501    },
502
503    /// A column the resolved layout has no substitute for is null on some row.
504    ///
505    /// `ts`, `symbol` and the layout's price columns are `REQUIRED` on every measured artifact, but a
506    /// writer change would make them nullable without altering anything else the decoder keys on, so
507    /// the schema check accepts `OPTIONAL` and this reports a value that is actually missing. None of
508    /// them is substitutable: a null timestamp has no place on a timeline, a null symbol cannot be
509    /// checked against the descriptor — the check that catches a mis-described file — and a null
510    /// price would have to be invented.
511    #[error("export column {column:?} is null on a row that has no substitute for it")]
512    NullValue { column: &'static str },
513
514    /// A row's `symbol` column does not match the symbol the export descriptor names.
515    ///
516    /// Every export is single-symbol, so this means the file and its descriptor disagree — the
517    /// artifact is not the one the caller thinks it is. Caught because attributing it to the
518    /// descriptor's instrument would be silent misattribution, and because `BP` and `BP.L` are
519    /// different instruments quoted in different currencies.
520    #[error("export symbol mismatch: descriptor says {expected:?} but a row carries {found:?}")]
521    SymbolMismatch { expected: String, found: String },
522
523    /// An artifact's timestamps do not advance as its layout requires.
524    ///
525    /// A backtest fed an unsorted stream produces a non-monotonic clock and wrong results in
526    /// release, with no failure point, so this is rejected at decode.
527    ///
528    /// # What counts as a violation depends on the layout
529    /// - **Tick and quote** tapes are *non-decreasing*: several prints routinely share a
530    ///   microsecond, so ties are permitted and only a step backwards is rejected.
531    /// - **Candle** artifacts at a fixed resolution are *strictly ascending*: two bars of one
532    ///   series cannot share an open, so they cannot share the close derived from it. A tie there
533    ///   means the file holds more than one series, or repeats a row. It does **not** mean the
534    ///   declared resolution is wrong — a fixed interval is a constant shift from open to close, so
535    ///   it cancels out of this comparison entirely and a mis-declared one produces distinct,
536    ///   strictly ascending closes. That is a separate check on bar *spacing*, which reports
537    ///   [`UnexpectedCandleResolution`](Self::UnexpectedCandleResolution). Calendar intervals are
538    ///   exempt from strictness: month arithmetic clamps day-of-month, so two distinct opens can
539    ///   legitimately share a close.
540    #[error("export timestamps are not ascending: {found} follows {previous}")]
541    NonMonotonicTimestamps {
542        previous: DateTime<Utc>,
543        found: DateTime<Utc>,
544    },
545
546    /// A row's timestamp is outside the representable [`DateTime<Utc>`] range.
547    #[error("export timestamp {micros}µs is not representable")]
548    TimestampNotRepresentable { micros: i64 },
549
550    /// A provider `f64` has no [`rust_decimal::Decimal`] representation.
551    ///
552    /// Surfaced rather than substituted: a zero or a clamp here would put a real-looking price
553    /// into fees, PnL and risk notional.
554    #[error("price {value} is not representable as a decimal: {message}")]
555    PriceNotRepresentable { value: f64, message: String },
556
557    /// No registered instrument is priced on this exchange under the requested display symbol:
558    /// none has it as its data-side name there (its `DataVenue`'s, or its own when it declares
559    /// none).
560    ///
561    /// Raised when deriving an [`InstrumentIndex`] from the caller's registry rather than
562    /// accepting one. That derivation is what makes a fabricated index unrepresentable — the index
563    /// is a public, unbounded `usize` and engine state indexes positionally — and it is the only
564    /// check that catches a symbol typo, which would otherwise leave one instrument silently
565    /// receiving no data at all.
566    ///
567    /// [`InstrumentIndex`]: rustrade_instrument::instrument::InstrumentIndex
568    #[error(
569        "no registered instrument is priced on {exchange} under {symbol:?}; symbols priced there: [{registered}]"
570    )]
571    UnknownInstrument {
572        symbol: String,
573        exchange: rustrade_instrument::exchange::ExchangeId,
574        registered: String,
575    },
576
577    /// More than one registered instrument is priced on this exchange under the requested display
578    /// symbol, such as one listing executed on two venues with both priced here.
579    ///
580    /// A candle source delivers its symbol's prices to a single instrument, so picking one would
581    /// leave the other silently unpriced. `instruments` lists the `name_internal` of each.
582    #[error(
583        "{symbol:?} on {exchange} prices more than one registered instrument: [{instruments}]; a candle source feeds one"
584    )]
585    AmbiguousInstrument {
586        symbol: String,
587        exchange: rustrade_instrument::exchange::ExchangeId,
588        instruments: String,
589    },
590
591    /// The registered instrument prices this symbol in a different asset than the provider quotes
592    /// it in.
593    ///
594    /// The provider publishes no unit alongside a price, so the asset the caller registered *is*
595    /// the unit. Registering a `.L` listing against `gbp` rather than `gbx` is therefore not a
596    /// naming preference: `BP.L` prints ~548 where BP trades around £5.48, so every notional, fee,
597    /// unrealised PnL and balance derived from it is 100× wrong, and nothing downstream can tell.
598    ///
599    /// Raised at the same boundary as [`UnknownInstrument`](Self::UnknownInstrument), and for the
600    /// same reason: it is the one place the provider's view of a symbol and the caller's registry
601    /// meet, so it is the only place the disagreement is visible.
602    ///
603    /// Compared on [`AssetNameInternal`], because that is the identity the engine keys assets by —
604    /// `GBX` and `gbx` are one asset, and `GBP` is a different one.
605    ///
606    /// # `expected` is derived, not published
607    /// It comes from [`quote_asset`](super::market::quote_asset), which maps a venue suffix to an
608    /// asset and **defaults to USD** for any suffix outside its table. So a correctly-registered
609    /// listing on a venue that table does not cover is rejected here, with an `expected` the
610    /// provider never stated. The message says "this integration derives" rather than "the provider
611    /// quotes" for that reason. A caller in that position wants
612    /// [`LseCandleSource::new`](super::backtest::LseCandleSource::new), which carries no check.
613    ///
614    /// [`AssetNameInternal`]: rustrade_instrument::asset::name::AssetNameInternal
615    #[error(
616        "instrument {symbol:?} on {exchange} is registered with quote asset {registered:?}, but \
617         this integration derives {expected:?} for that symbol from its venue suffix (defaulting \
618         to USD); prices carry no unit, so a mismatch misprices every notional, fee and balance"
619    )]
620    QuoteAssetMismatch {
621        symbol: String,
622        exchange: rustrade_instrument::exchange::ExchangeId,
623        expected: String,
624        registered: String,
625    },
626
627    /// The Parquet artifact could not be read.
628    #[cfg(feature = "lse-parquet")]
629    #[error("parquet error: {0}")]
630    Parquet(#[from] parquet::errors::ParquetError),
631}
632
633/// A coarse classification of [`LseError`], for deciding what to do next.
634///
635/// [`LseError`] is `#[non_exhaustive]` and has a variant per measured provider behaviour, which is
636/// the right shape for diagnosing one failure and the wrong shape for *reacting* to one: a consumer
637/// writing retry logic wants five or six categories, not thirty, and should not have to revisit its
638/// match every time an endpoint gains a variant. This is that projection, and it is what
639/// [`DataError::Lse`](crate::error::DataError::Lse) carries so the classification survives being
640/// flattened into a `String`.
641///
642/// Mirrors `DatabentoErrorKind` — not linked, since that lives behind the `databento` feature and
643/// this type does not — plus the categories this integration's file and export surface adds.
644#[derive(
645    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
646)]
647#[non_exhaustive]
648pub enum LseErrorKind {
649    /// API key missing, malformed, or rejected. Retrying changes nothing until it is fixed.
650    Authentication,
651    /// Throttled, or a metered allowance is spent. Resumable once it replenishes — see
652    /// [`LseError::RateLimited`]'s `retry_after` and [`LseError::QuotaExceeded`]'s status.
653    RateLimit,
654    /// Transport-level failure. Resumable; a retry may well succeed.
655    Network,
656    /// An export job did not reach a terminal state within the caller's timeout. Resumable: the
657    /// job keeps building and its identifier stays valid, so polling again spends no allowance.
658    Timeout,
659    /// The provider rejected the request or the job failed. Not resumable by retrying alone.
660    Api,
661    /// The bytes could not be read as what they claim to be — a malformed body, an unsupported or
662    /// mistyped Parquet schema, a value with no representation, an ordering violation, an
663    /// integrity mismatch. Terminal: the data is what it is.
664    Decode,
665    /// The caller's request or instrument registry is wrong. Terminal until the caller changes it.
666    InvalidInput,
667    /// A local filesystem failure.
668    Io,
669}
670
671impl LseErrorKind {
672    /// Whether retrying the same operation could succeed without the caller changing anything.
673    ///
674    /// [`RateLimit`](Self::RateLimit) and [`Timeout`](Self::Timeout) require *waiting* first; this
675    /// says the attempt is worth making again, not that it is worth making immediately.
676    #[must_use]
677    pub fn is_resumable(&self) -> bool {
678        matches!(self, Self::RateLimit | Self::Network | Self::Timeout)
679    }
680}
681
682impl std::fmt::Display for LseErrorKind {
683    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
684        let name = match self {
685            Self::Authentication => "authentication",
686            Self::RateLimit => "rate limit",
687            Self::Network => "network",
688            Self::Timeout => "timeout",
689            Self::Api => "API",
690            Self::Decode => "decode",
691            Self::InvalidInput => "invalid input",
692            Self::Io => "IO",
693        };
694        f.write_str(name)
695    }
696}
697
698impl LseError {
699    /// Classify this error for a consumer deciding whether to retry, wait, or stop.
700    ///
701    /// Exhaustive on purpose: a new variant must produce a compile error here rather than fall
702    /// through to a default. Getting this wrong is not a visible failure — it silently retries
703    /// something terminal, or abandons something that would have succeeded.
704    #[must_use]
705    pub fn kind(&self) -> LseErrorKind {
706        match self {
707            Self::EnvVar(_) | Self::InvalidCredential(_) => LseErrorKind::Authentication,
708            Self::RateLimited { .. } | Self::QuotaExceeded { .. } => LseErrorKind::RateLimit,
709            Self::Http(_) => LseErrorKind::Network,
710            Self::ExportTimeout { .. } => LseErrorKind::Timeout,
711            // The request was valid and the provider cannot serve it whole; retrying changes nothing.
712            Self::Api { .. }
713            | Self::ExportFailed { .. }
714            | Self::OptionFlowWindowSaturated { .. } => LseErrorKind::Api,
715            Self::Io { .. } => LseErrorKind::Io,
716
717            // The caller's request or registry, not the provider's answer.
718            Self::InvalidInput { .. }
719            | Self::UnsupportedInterval { .. }
720            | Self::UnknownDataset(_)
721            | Self::AmbiguousSlug { .. }
722            | Self::UnknownInstrument { .. }
723            | Self::AmbiguousInstrument { .. }
724            | Self::QuoteAssetMismatch { .. }
725            | Self::UnknownBondYieldCountry { .. }
726            | Self::UnknownBondYieldMaturity { .. }
727            | Self::BondYieldRangeOutsideCoverage { .. }
728            | Self::UnknownCalendarCountry { .. }
729            | Self::UnknownCalendarImpact { .. }
730            | Self::CalendarRangeOutsideCoverage { .. } => LseErrorKind::InvalidInput,
731
732            // Everything the provider sent that could not be read as what it claims to be. An
733            // integrity or job mismatch belongs here rather than under `Api`: the request was
734            // accepted and answered, and it is the payload that does not hold up.
735            Self::Deserialize { .. }
736            | Self::TimestampOverflow { .. }
737            | Self::CursorOverflow { .. }
738            | Self::UnexpectedCandleRange { .. }
739            | Self::NonMonotonicCandlePage { .. }
740            | Self::UnexpectedCandleResolution { .. }
741            | Self::UnexpectedOptionFlowRow { .. }
742            | Self::IntegrityMismatch { .. }
743            | Self::ExportJobMismatch { .. }
744            | Self::UnsupportedSchema { .. }
745            | Self::UnsupportedColumnType { .. }
746            | Self::NullValue { .. }
747            | Self::SymbolMismatch { .. }
748            | Self::NonMonotonicTimestamps { .. }
749            | Self::TimestampNotRepresentable { .. }
750            | Self::PriceNotRepresentable { .. } => LseErrorKind::Decode,
751
752            #[cfg(feature = "lse-parquet")]
753            Self::Parquet(_) => LseErrorKind::Decode,
754        }
755    }
756}
757
758/// Maximum retained length of a provider diagnostic, in bytes.
759///
760/// Well above any real message, and far below a pathological proxy error page.
761const MAX_DETAIL_BYTES: usize = 2 * 1024;
762
763/// Extract the provider's human-readable diagnostic from an error response body.
764///
765/// # Why this is not `body["detail"].as_str()`
766///
767/// The provider encodes errors inconsistently, and both forms are measured:
768///
769/// ```text
770/// 400/404: {"detail":"{\"detail\":\"invalid timeframe '7q'; valid: 1s, 5s, ...\"}"}
771/// 401:     {"detail":"invalid api key"}
772/// ```
773///
774/// On `400`/`404` the value of `detail` is *itself* a JSON document encoded as a string, so
775/// reading one level surfaces raw JSON in a user-facing error message. This unwraps repeatedly
776/// until `detail` stops being re-encoded, and falls back to the whole body when the response is
777/// not in either shape (a proxy or CDN page, say). The result is always truncated.
778pub(crate) fn extract_detail(body: &str) -> String {
779    let mut current = body.trim().to_owned();
780
781    // Bounded rather than `loop`: two levels are measured, and a handful of iterations is ample for
782    // any further nesting the provider might add without risking a pathological input spinning here.
783    for _ in 0..4 {
784        let Ok(serde_json::Value::Object(object)) =
785            serde_json::from_str::<serde_json::Value>(&current)
786        else {
787            break;
788        };
789        let Some(serde_json::Value::String(detail)) = object.get("detail") else {
790            break;
791        };
792        current = detail.trim().to_owned();
793    }
794
795    crate::exchange::http::truncate_str(&current, MAX_DETAIL_BYTES)
796}
797
798#[cfg(test)]
799#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
800mod tests {
801    use super::*;
802
803    #[test]
804    fn single_encoded_detail_is_read_directly() {
805        // The measured 401 shape.
806        assert_eq!(
807            extract_detail(r#"{"detail":"invalid api key"}"#),
808            "invalid api key"
809        );
810    }
811
812    #[test]
813    fn double_encoded_detail_is_unwrapped_to_the_message() {
814        // The measured 400 shape: `detail` is a JSON document encoded as a string. Reading one
815        // level would put raw JSON in front of the user.
816        let body = r#"{"detail":"{\"detail\":\"invalid timeframe '7q'; valid: 1s, 5s, 15s\"}"}"#;
817
818        assert_eq!(
819            extract_detail(body),
820            "invalid timeframe '7q'; valid: 1s, 5s, 15s"
821        );
822    }
823
824    #[test]
825    fn double_encoded_not_found_is_unwrapped() {
826        // The measured 404 shape.
827        let body =
828            r#"{"detail":"{\"detail\":\"'NOPE_XYZ' has no candle data; browse /catalog\"}"}"#;
829
830        assert_eq!(
831            extract_detail(body),
832            "'NOPE_XYZ' has no candle data; browse /catalog"
833        );
834    }
835
836    #[test]
837    fn a_non_json_body_is_returned_as_is() {
838        // A proxy or CDN error page is not in either shape, and is still the best diagnostic there is.
839        assert_eq!(
840            extract_detail("<html>502 Bad Gateway</html>"),
841            "<html>502 Bad Gateway</html>"
842        );
843    }
844
845    #[test]
846    fn json_without_a_detail_field_is_returned_whole() {
847        assert_eq!(extract_detail(r#"{"error":"nope"}"#), r#"{"error":"nope"}"#);
848    }
849
850    #[test]
851    fn a_non_string_detail_is_not_unwrapped() {
852        // Only a re-encoded *string* is a nesting level; an object is already the message.
853        assert_eq!(
854            extract_detail(r#"{"detail":{"code":7}}"#),
855            r#"{"detail":{"code":7}}"#
856        );
857    }
858
859    #[test]
860    fn an_oversized_diagnostic_is_truncated() {
861        let body = format!(r#"{{"detail":"{}"}}"#, "a".repeat(MAX_DETAIL_BYTES * 3));
862
863        assert_eq!(extract_detail(&body).len(), MAX_DETAIL_BYTES);
864    }
865
866    #[test]
867    fn nesting_deeper_than_the_budget_stops_exactly_at_the_budget() {
868        // Six wrappers against a budget of four: it must stop mid-way rather than spin, and the
869        // assertion pins *where* it stopped. Asserting only that the result is non-empty would
870        // hold for any budget at all, including an off-by-one one.
871        let wrap =
872            |inner: &str| serde_json::to_string(&serde_json::json!({ "detail": inner })).unwrap();
873
874        let mut body = r#"{"detail":"bottom"}"#.to_owned();
875        for _ in 0..6 {
876            body = wrap(&body);
877        }
878
879        // Four unwraps consume four of the six wrappers, leaving the two-deep remainder — still
880        // encoded JSON, because the budget ran out before the message did.
881        let mut expected = r#"{"detail":"bottom"}"#.to_owned();
882        for _ in 0..2 {
883            expected = wrap(&expected);
884        }
885
886        assert_eq!(extract_detail(&body), expected);
887    }
888
889    #[test]
890    fn an_empty_body_yields_an_empty_diagnostic() {
891        assert_eq!(extract_detail(""), "");
892    }
893
894    /// The classification a consumer actually branches on.
895    ///
896    /// `kind()` is exhaustive, so a *new* variant is a compile error — but nothing in the compiler
897    /// checks that an existing variant is classified *correctly*, and getting that wrong is
898    /// invisible: it silently retries something terminal, or abandons something that would have
899    /// succeeded on the next attempt. These pin the pairs where that costs something.
900    #[test]
901    fn the_resumable_kinds_are_the_ones_a_retry_could_actually_fix() {
902        // The whole point of carrying `kind` through `DataError::Lse`: a consumer must be able to
903        // tell "wait and try again" from "stop" without substring-matching a `Display`.
904        assert!(LseErrorKind::RateLimit.is_resumable());
905        assert!(LseErrorKind::Network.is_resumable());
906        assert!(LseErrorKind::Timeout.is_resumable());
907
908        assert!(!LseErrorKind::Authentication.is_resumable());
909        assert!(!LseErrorKind::Api.is_resumable());
910        assert!(!LseErrorKind::Decode.is_resumable());
911        assert!(!LseErrorKind::InvalidInput.is_resumable());
912        assert!(!LseErrorKind::Io.is_resumable());
913    }
914
915    #[test]
916    fn a_rate_limit_classifies_as_resumable_and_a_decode_failure_does_not() {
917        // The pair the whole projection exists for, checked end to end from the variant rather
918        // than from the kind: `RateLimited` is documented resumable, `Deserialize` is terminal,
919        // and a consumer that confused them would either spin forever on a malformed body or
920        // abandon a fetch that a 60-second wait would have completed.
921        let rate_limited = LseError::RateLimited {
922            retry_after: Some(Duration::from_secs(60)),
923        };
924        assert_eq!(rate_limited.kind(), LseErrorKind::RateLimit);
925        assert!(rate_limited.kind().is_resumable());
926
927        let decode = LseError::Deserialize {
928            message: "unexpected end of input".to_owned(),
929        };
930        assert_eq!(decode.kind(), LseErrorKind::Decode);
931        assert!(!decode.kind().is_resumable());
932    }
933
934    #[test]
935    fn an_exhausted_allowance_is_a_rate_limit_and_an_export_timeout_is_a_timeout() {
936        // Both are export-path variants that a naive reading would file under `Api` -- they arrive
937        // as a 429 and as a job that never finished. Classifying either as terminal would stop a
938        // consumer that only had to wait.
939        assert_eq!(
940            LseError::QuotaExceeded { status: None }.kind(),
941            LseErrorKind::RateLimit
942        );
943        assert_eq!(
944            LseError::ExportTimeout {
945                job_id: "job-1".to_owned(),
946                status: "running".to_owned(),
947            }
948            .kind(),
949            LseErrorKind::Timeout
950        );
951    }
952
953    #[test]
954    fn a_payload_that_was_accepted_but_does_not_hold_up_is_a_decode_failure_not_an_api_error() {
955        // The boundary that is easiest to get wrong: the request succeeded and the provider
956        // answered, so `status` is a success code -- but the bytes are not what they claim to be.
957        // Filing these under `Api` would tell a consumer to fix its request, which cannot help.
958        let decode_variants = [
959            LseError::IntegrityMismatch {
960                path: std::path::PathBuf::from("/tmp/export.parquet.part"),
961                expected: "abc".to_owned(),
962                actual: "def".to_owned(),
963                discarded: true,
964            },
965            LseError::ExportJobMismatch {
966                job_id: "job-1".to_owned(),
967                field: "symbol".to_owned(),
968                requested: "BP.L".to_owned(),
969                reported: "BP".to_owned(),
970            },
971            LseError::NonMonotonicTimestamps {
972                previous: DateTime::UNIX_EPOCH,
973                found: DateTime::UNIX_EPOCH,
974            },
975            LseError::SymbolMismatch {
976                expected: "BP.L".to_owned(),
977                found: "BP".to_owned(),
978            },
979        ];
980
981        for error in decode_variants {
982            assert_eq!(error.kind(), LseErrorKind::Decode, "{error}");
983        }
984
985        // The contrast: an error the provider itself reported about the request.
986        assert_eq!(
987            LseError::Api {
988                status: 404,
989                message: "no candle data".to_owned(),
990            }
991            .kind(),
992            LseErrorKind::Api
993        );
994    }
995
996    #[test]
997    fn a_caller_side_mistake_is_invalid_input_rather_than_an_api_error() {
998        // These are all detected before or independently of the request, so reporting them as the
999        // provider's fault would send the caller looking in the wrong place.
1000        let caller_variants = [
1001            LseError::UnknownDataset("nope".to_owned()),
1002            LseError::UnsupportedInterval {
1003                interval: CandleInterval::Sec1,
1004            },
1005            LseError::QuoteAssetMismatch {
1006                symbol: "BP.L".to_owned(),
1007                exchange: rustrade_instrument::exchange::ExchangeId::Other,
1008                expected: "gbx".to_owned(),
1009                registered: "gbp".to_owned(),
1010            },
1011        ];
1012
1013        for error in caller_variants {
1014            assert_eq!(error.kind(), LseErrorKind::InvalidInput, "{error}");
1015        }
1016
1017        // A credential problem is its own category: also terminal, but the fix is a key rather
1018        // than a request.
1019        assert_eq!(
1020            LseError::EnvVar("LSE_API_KEY is not set".to_owned()).kind(),
1021            LseErrorKind::Authentication
1022        );
1023    }
1024
1025    #[test]
1026    fn the_kind_survives_flattening_into_the_crate_error() {
1027        // The reason `DataError::Lse` is a struct variant at all. Once an LSE failure is flattened
1028        // for the generic stream helpers, `message` is a `String` and the classification would be
1029        // unrecoverable from it -- so `kind` has to be carried alongside, not derived later.
1030        let error = crate::error::DataError::from(LseError::RateLimited { retry_after: None });
1031
1032        let crate::error::DataError::Lse { kind, message } = &error else {
1033            panic!("unexpected error variant: {error:?}")
1034        };
1035
1036        assert_eq!(*kind, LseErrorKind::RateLimit);
1037        assert!(kind.is_resumable());
1038        // The message is still the full diagnostic; `kind` is an addition to it, not a
1039        // replacement.
1040        assert!(message.contains("rate limited"), "{message}");
1041    }
1042}