Skip to main content

rustrade_data/exchange/lse/
historical.rs

1//! Paged historical candles from the London Strategic Edge vault.
2//!
3//! ```no_run
4//! # use chrono::{Duration, Utc};
5//! # use futures::StreamExt;
6//! # use rustrade_data::exchange::lse::error::LseError;
7//! # use rustrade_data::exchange::lse::vault::LseVaultClient;
8//! # use rustrade_data::subscription::candle::CandleInterval;
9//! # async fn example() -> Result<(), LseError> {
10//! let client = LseVaultClient::from_env()?;
11//! let end = Utc::now();
12//! let start = end - Duration::days(7);
13//!
14//! let stream = client.fetch_candles("EUR/USD", CandleInterval::Day1, start, end);
15//! // The returned stream is a generator holding borrows across await points, so it is `!Unpin`
16//! // and has to be pinned before `StreamExt::next` will accept it.
17//! futures::pin_mut!(stream);
18//!
19//! while let Some(candle) = stream.next().await {
20//!     println!("{:?}", candle?);
21//! }
22//! # Ok(())
23//! # }
24//! ```
25//!
26//! # ⚠️ Licensing
27//! Candles retrieved here are **not redistributable**. See the [module documentation](super) and
28//! <https://londonstrategicedge.com/terms>.
29
30use crate::exchange::lse::PROVIDER_TIMESTAMP_FORMAT;
31use crate::exchange::lse::error::LseError;
32use crate::exchange::lse::market::candle_interval_str;
33use crate::exchange::lse::vault::LseVaultClient;
34use crate::subscription::candle::{
35    Candle, CandleInterval, IntervalStep, close_time_from_open, open_time_from_close,
36};
37use async_stream::try_stream;
38use chrono::{DateTime, Duration as TimeDelta, NaiveDateTime, Utc};
39use futures::{Stream, StreamExt};
40use rust_decimal::Decimal;
41use serde::{Deserialize, de::DeserializeOwned};
42use tracing::debug;
43
44/// Format accepted by the `start` / `end` query parameters.
45///
46/// **Second precision, and no finer.** The provider rejects both a sub-second value and the ISO
47/// `2024-01-02T09:09:00Z` form with a `400` whose message (`use YYYY-MM-DD`) understates what is
48/// actually accepted — the time-of-day *is* honoured.
49const CURSOR_FORMAT: &str = "%Y-%m-%d %H:%M:%S";
50
51/// Amount the page cursor advances past the last bar received.
52///
53/// The cursor is inclusive, so resuming exactly at the last open time would re-yield that bar.
54/// One second is the smallest step the parameter accepts.
55///
56/// # ⚠️ This is lossless only while the finest resolution is one second
57/// Bars are at least one second apart, so stepping a full second past the last open can never skip
58/// one. Were the provider to publish a sub-second resolution, this step would silently drop bars —
59/// revisit it alongside any new [`CandleInterval`] variant below [`Sec1`](CandleInterval::Sec1).
60const CURSOR_STEP_SECS: i64 = 1;
61
62/// One candle as the vault serves it.
63///
64/// The `symbol` field is echoed by the provider and deliberately not captured: the caller already
65/// knows what it asked for, and unknown fields are ignored rather than rejected so an added field
66/// does not break decoding.
67#[derive(Debug, Deserialize)]
68struct LseCandleRow {
69    /// Bar **open** time, UTC. See [`PROVIDER_TIMESTAMP_FORMAT`].
70    ts: String,
71    open: Decimal,
72    high: Decimal,
73    low: Decimal,
74    close: Decimal,
75    /// Absent entirely for FX; an integer for equities.
76    #[serde(default)]
77    volume: Option<Decimal>,
78}
79
80/// A vault row describing one candle, identified by the instant its bar opens.
81///
82/// Implemented by every row type [`candle_rows`](LseVaultClient::candle_rows) pages over, so the
83/// cursor arithmetic and the structural page checks are written once for every vault candle
84/// endpoint rather than once per row shape.
85pub(super) trait VaultCandleRow: DeserializeOwned {
86    /// Parse the row's timestamp as the bar's open instant.
87    fn open_time(&self) -> Result<DateTime<Utc>, LseError>;
88}
89
90/// Parse a vault candle label, which carries the bar's **open** in UTC with no zone suffix.
91pub(super) fn parse_candle_open(label: &str) -> Result<DateTime<Utc>, LseError> {
92    NaiveDateTime::parse_from_str(label, PROVIDER_TIMESTAMP_FORMAT)
93        .map(|naive| naive.and_utc())
94        .map_err(|error| LseError::Deserialize {
95            message: format!("invalid candle timestamp {label:?}: {error}"),
96        })
97}
98
99impl VaultCandleRow for LseCandleRow {
100    fn open_time(&self) -> Result<DateTime<Utc>, LseError> {
101        parse_candle_open(&self.ts)
102    }
103}
104
105impl LseCandleRow {
106    /// Convert into the library [`Candle`] model, given the period-end boundary.
107    ///
108    /// `trade_count` is unconditionally `None`: the vault reports no trade count for any dataset.
109    /// `volume` carries the provider's absence through as `None` rather than as a zero — for FX
110    /// the field is omitted entirely, and a synthetic zero would aggregate into a
111    /// legitimate-looking total at every derived resolution.
112    fn into_candle(self, close_time: DateTime<Utc>) -> Candle {
113        Candle {
114            close_time,
115            open: self.open,
116            high: self.high,
117            low: self.low,
118            close: self.close,
119            volume: self.volume,
120            trade_count: None,
121        }
122    }
123}
124
125impl LseVaultClient {
126    /// Fetch historical candles for `symbol` at `interval`, paginating automatically.
127    ///
128    /// Returns a [`Stream`] that processes each page as it arrives rather than buffering the whole
129    /// range. For a convenience `Vec`, see [`collect_candles`](Self::collect_candles).
130    ///
131    /// # Symbol
132    /// The vault keys candles on the **display symbol** — `EUR/USD`, `AAPL`, `BP.L`, `ES.F` — not
133    /// on a dataset slug. Nothing here needs [`slug`](super::market::slug).
134    ///
135    /// # Range contract
136    /// Yields exactly the candles whose [`close_time`](Candle::close_time) falls in `[start, end]`
137    /// (**both inclusive**), matched on `close_time` — the field consumers receive — consistent
138    /// with the library's other historical fetches.
139    ///
140    /// The vault's own range is expressed on the bar's **open** time with an **exclusive** upper
141    /// bound, so this method maps both bounds (lower widens to capture the bar whose
142    /// `close_time == start`; upper is extended past the final bar's open) and then trims exactly
143    /// on `close_time`. The trim also absorbs the cursor's second-precision truncation of
144    /// sub-second `start`/`end` values.
145    ///
146    /// `close_time` is computed library-side as the exclusive period-end boundary
147    /// (`open_time + interval`) via the shared boundary helper; the provider reports only the open
148    /// instant.
149    ///
150    /// # Sparseness differs by resolution — and a closed market is not the same as no bar
151    /// **Intraday**: periods with no activity are omitted entirely, so consecutive candles are
152    /// **not** guaranteed to be one interval apart. This is the opposite of Binance's REST klines,
153    /// which server-side gap-fill. Consumers needing a dense grid must fill it themselves.
154    ///
155    /// **Daily**: the series is **not** sparse. Non-trading days are emitted as *flat* bars —
156    /// `open == high == low == close` — for every sampled Saturday and for the US Independence Day
157    /// observance; only Sundays are absent. So a backtest sees a tradeable price on a closed market,
158    /// and the flat OHLC is the only signal. Do not infer "no bar means the market was closed": see
159    /// the [module's data characteristics](super#data-characteristics).
160    ///
161    /// # Volume — ⚠️ not a figure to size on without reconciling it
162    /// FX candles carry **no volume**: the vault omits the field, which surfaces as
163    /// [`volume: None`](Candle::volume) rather than a zero. `trade_count` is `None` for every
164    /// dataset — the vault reports none.
165    ///
166    /// Where volume *is* published it is unreliable in both directions, and by margins large
167    /// enough to invert a result rather than blur it: a majority of sampled one-minute equity bars
168    /// report `0` in minutes the tick tape shows real trades, and ETF bars have been reported
169    /// carrying three to four orders of magnitude too much — one `QQQ` minute published at some
170    /// 5,700× that session's entire consolidated volume. Equity totals over the same period ran
171    /// well under the consolidated tape. Every one of those bars is structurally valid, so neither
172    /// this method nor any shape check on its output can tell them from correct ones. See the
173    /// [module's data characteristics](super#data-characteristics) for the measurements and their
174    /// provenance. Reconcile against a second source before trusting a volume-derived quantity.
175    ///
176    /// # Coverage — a range the symbol does not cover is not an error
177    /// The provider publishes a first tick and a coverage span per symbol per dataset, and the two
178    /// vary widely: an ETF has been reported carrying three months of spot history where equities
179    /// reach back two decades. Those fields live in the catalog on the discovery host, which this
180    /// integration does not wrap, so this method cannot check `start` against them. A range
181    /// beginning before a symbol's coverage yields the bars that exist and nothing to say the
182    /// remainder was never published — indistinguishable here from a genuinely quiet period.
183    /// Establish a symbol's depth from the catalog before choosing a range to backfill.
184    ///
185    /// # Arguments
186    /// * `symbol` - Display symbol, e.g. `"EUR/USD"` or `"AAPL"`.
187    /// * `interval` - Resolution. The provider serves 14 of [`CandleInterval`]'s variants;
188    ///   an unserved one is rejected up front with [`LseError::UnsupportedInterval`] rather than
189    ///   relayed as a `400`.
190    /// * `start` / `end` - Inclusive `close_time` bounds.
191    ///
192    /// # Errors
193    /// Each yielded item is a `Result`. On `429` the stream yields [`LseError::RateLimited`] and
194    /// **ends** — resume by re-calling with `start` set past the last `close_time` received. An
195    /// inverted range is [`LseError::InvalidInput`]. Other failures surface as
196    /// [`LseError::Api`] / [`Http`](LseError::Http) / [`Deserialize`](LseError::Deserialize).
197    ///
198    /// A page carrying a bar that closes past `end` ends the stream with
199    /// [`LseError::UnexpectedCandleRange`] rather than trimming it away: the upper bound is exact
200    /// by construction, so such a bar means the range parameters were not honoured. Every in-range
201    /// bar on that page is yielded first. Note the lower bound is **not** symmetric — it is widened
202    /// deliberately, so bars closing before `start` are trimmed without comment.
203    ///
204    /// A page whose opens step backwards ends the stream with
205    /// [`LseError::NonMonotonicCandlePage`], and one whose bars are spaced closer than `interval`
206    /// with [`LseError::UnexpectedCandleResolution`]. Both are checked before *any* bar of that
207    /// page is yielded — unlike the range violation above, nothing from such a page is emitted.
208    ///
209    /// # Request count
210    /// Because the row cap is applied silently, a short page cannot be distinguished from the end
211    /// of the data. Pagination therefore continues until a page comes back **empty** or the cursor
212    /// passes the requested range, which costs at most one extra request per fetch. Treating a
213    /// short page as terminal would be faster and would silently truncate the result if the
214    /// provider ever lowered its cap below the rows requested per page — the figure the provider
215    /// itself reports as [`max_rows_per_request`](super::quota::QuotaStatus::max_rows_per_request),
216    /// measured at 5,000 and overridable with
217    /// [`with_page_limit`](LseVaultClient::with_page_limit).
218    #[must_use = "fetch_candles returns a lazy Stream that does nothing unless polled"]
219    pub fn fetch_candles<'a>(
220        &'a self,
221        symbol: &'a str,
222        interval: CandleInterval,
223        start: DateTime<Utc>,
224        end: DateTime<Utc>,
225    ) -> impl Stream<Item = Result<Candle, LseError>> + 'a {
226        try_stream! {
227            // Rejected up front so the caller gets a typed answer instead of a relayed 400.
228            let timeframe = candle_interval_str(interval)
229                .ok_or(LseError::UnsupportedInterval { interval })?;
230
231            let rows = self.candle_rows::<LseCandleRow>(
232                "candles",
233                symbol,
234                vec![
235                    ("symbol", symbol.to_owned()),
236                    // ⚠️ `timeframe`, NOT `resolution`. An unknown parameter is ignored silently
237                    // and the vault defaults to 1-minute bars, returning a byte-identical shape.
238                    ("timeframe", timeframe.to_owned()),
239                ],
240                interval,
241                start,
242                end,
243            );
244            futures::pin_mut!(rows);
245
246            while let Some(row) = rows.next().await {
247                let (row, close_time) = row?;
248                yield row.into_candle(close_time);
249            }
250        }
251    }
252
253    /// The paging core shared by every vault candle endpoint.
254    ///
255    /// Yields each row whose close falls in `[start, end]`, paired with that close, under exactly
256    /// the range, ordering, resolution and request-count contract documented on
257    /// [`fetch_candles`](Self::fetch_candles) — every error described there originates here.
258    ///
259    /// `path` is the vault endpoint and `selector` the query parameters naming what to fetch; the
260    /// range and page-size parameters are appended per page. `symbol` labels the typed errors only.
261    ///
262    /// Relies on the endpoint sharing the vault's measured range semantics: `start` inclusive and
263    /// `end` exclusive, both on the bar's **open**, at second precision.
264    pub(super) fn candle_rows<'a, R>(
265        &'a self,
266        path: &'static str,
267        symbol: &'a str,
268        selector: Vec<(&'static str, String)>,
269        interval: CandleInterval,
270        start: DateTime<Utc>,
271        end: DateTime<Utc>,
272    ) -> impl Stream<Item = Result<(R, DateTime<Utc>), LseError>> + 'a
273    where
274        R: VaultCandleRow + 'a,
275    {
276        try_stream! {
277            // An inverted range is a caller error, not an empty result: the vault would answer 200
278            // with a confusing selection rather than complaining.
279            if start > end {
280                Err(LseError::InvalidInput {
281                    message: format!("start ({start}) must not be after end ({end})"),
282                })?;
283            }
284
285            let step = interval.to_step();
286
287            // Map the close-time contract onto the vault's open-time filter. `None` (underflow near
288            // `DateTime::MIN_UTC`) is not an error: the boundary bar would have an unrepresentable
289            // open and so cannot exist, making the un-widened bound already correct.
290            let request_start = open_time_from_close(start, step).unwrap_or(start);
291            let request_end_open = open_time_from_close(end, step).unwrap_or(end);
292
293            // The vault's `end` is EXCLUSIVE on open time, so extend past the final bar's open to
294            // readmit it. Saturating near `DateTime::MAX_UTC` is safe -- the close-time trim below
295            // is exact regardless, so an un-extended bound only risks omitting a bar that cannot
296            // exist at that boundary.
297            let range_end = request_end_open
298                .checked_add_signed(TimeDelta::seconds(CURSOR_STEP_SECS))
299                .unwrap_or(request_end_open);
300
301            let mut cursor = request_start;
302            let mut page = 0usize;
303            // Carried ACROSS pages, not reset per page: a vault that served each page ascending but
304            // the pages themselves out of order, or that repeated a page's worth of bars after the
305            // cursor advanced, would satisfy a per-page check and still corrupt the series.
306            let mut previous_open: Option<DateTime<Utc>> = None;
307
308            loop {
309                // No sleep here: pacing is applied inside `get_json`, against a gate shared by
310                // every clone of this client. Spacing pages from inside this loop would only ever
311                // pace *this* fetch, so N concurrent fetches would issue N requests per interval —
312                // see `LseVaultClient::with_pace`.
313                let mut query = selector.clone();
314                query.extend([
315                    ("start", cursor.format(CURSOR_FORMAT).to_string()),
316                    ("end", range_end.format(CURSOR_FORMAT).to_string()),
317                    ("limit", self.page_limit().to_string()),
318                ]);
319
320                let rows: Vec<R> = self.get_json(path, &query).await?;
321                page += 1;
322                debug!(path, symbol, %cursor, rows = rows.len(), page, "vault candle page received");
323
324                // The only reliable end-of-data signal; see the request-count note above.
325                if rows.is_empty() {
326                    break;
327                }
328
329                let mut max_open: Option<DateTime<Utc>> = None;
330                let mut reached_end = false;
331
332                // Two STRUCTURAL properties of the page, checked in full before a single bar of it
333                // is yielded. Both are properties of the response as a whole rather than of any one
334                // row, and a page violating either is not partially usable: the first invalidates
335                // the cursor arithmetic, the second means every `close_time` this module computes
336                // for the page is wrong. That is the difference from the range trim below, which is
337                // a per-row property of a well-formed page and is therefore applied per row.
338                //
339                // The cost is one extra pass over a `Vec` already in memory. The instants this pass
340                // parses are kept and reused by the conversion loop below rather than reparsed:
341                // `open_time` is a format-string parse, not a comparison, so reparsing them would
342                // double the timestamp work on every page of every backfill.
343                let mut opens = Vec::with_capacity(rows.len());
344                for row in &rows {
345                    let open = row.open_time()?;
346
347                    if let Some(previous) = previous_open {
348                        // ORDER. Ascending is how the vault answers today, not something it
349                        // guarantees, and the cursor advances past the newest open a page carried
350                        // -- so a page served newest-first AND truncated at the row cap would jump
351                        // the cursor to the end of the range, make the next page empty (the
352                        // documented end-of-data signal), and return `Ok` holding the tail of the
353                        // series with nothing to say the rest was skipped.
354                        if open < previous {
355                            Err(LseError::NonMonotonicCandlePage {
356                                symbol: symbol.to_owned(),
357                                page,
358                                previous_open: previous,
359                                open,
360                            })?;
361                        }
362
363                        // RESOLUTION. Consecutive bars of a compliant fixed-interval series are
364                        // exactly one interval apart, and a gap (a weekend, a holiday, a quiet
365                        // symbol) only ever makes that spacing WIDER -- so spacing narrower than
366                        // the interval means the response is finer-grained than the request. This
367                        // is the runtime detector for the vault's documented habit of answering
368                        // `200` to a misspelled resolution parameter with 1-minute bars in a
369                        // byte-identical shape (see the `vault` module docs). Nothing else here can
370                        // see that: the bars ascend, and they fall inside the requested range.
371                        //
372                        // Skipped for a calendar step -- `month` has no single width, and February
373                        // would false-positive against a 31-day one.
374                        if let IntervalStep::Fixed(width) = step {
375                            let actual = open - previous;
376                            if actual < width {
377                                Err(LseError::UnexpectedCandleResolution {
378                                    symbol: symbol.to_owned(),
379                                    interval,
380                                    previous_open: previous,
381                                    open,
382                                    actual,
383                                })?;
384                            }
385                        }
386                    }
387                    previous_open = Some(open);
388                    opens.push(open);
389                }
390
391                for (row, open) in rows.into_iter().zip(opens) {
392                    let close_time = close_time_from_open(open, step)
393                        .ok_or(LseError::TimestampOverflow { open, interval })?;
394
395                    // Track every row, including trimmed ones, so the cursor advances past bars
396                    // that fell outside the contract rather than re-requesting them forever.
397                    max_open = Some(max_open.map_or(open, |seen| seen.max(open)));
398
399                    // A bar past the upper bound ends the fetch — but only after the rest of this
400                    // page has been examined, so the error never arrives having thrown away data
401                    // the response actually carried. On a page that reaches here that is now belt
402                    // and braces: the order check above rejects any page on which an out-of-range
403                    // bar could precede an in-range one, since on an ascending page everything
404                    // after the first out-of-range bar is also out of range. Kept because it costs
405                    // one pass over a page already in memory, `max_open` below scans every row
406                    // regardless, and "yield everything valid before failing" is the property worth
407                    // holding structurally rather than as a consequence of another check.
408                    if close_time > end {
409                        reached_end = true;
410                        continue;
411                    }
412
413                    // Below the lower bound only for the widened first page.
414                    if close_time < start {
415                        continue;
416                    }
417
418                    yield (row, close_time);
419                }
420
421                if reached_end {
422                    // Terminal, not trimmed-and-logged. `range_end` is derived as `end - step + 1s`
423                    // against an exclusive upper bound, so the newest bar a compliant page can carry
424                    // is the one closing exactly on `end`. A later one means the vault answered
425                    // outside the range it was asked for -- the same silently-ignored-parameter
426                    // failure as the cursor non-advance below, and so the same kind of typed error.
427                    // Trimming and continuing would hand back a series that looks complete; a caller
428                    // cannot assert on a log line, so a warning here would be observable to an
429                    // operator reading stderr and to nobody else. Every in-range bar on this page
430                    // was already yielded above, so nothing this response did contain is lost.
431                    Err(LseError::UnexpectedCandleRange {
432                        symbol: symbol.to_owned(),
433                        cursor,
434                        page,
435                        end,
436                    })?;
437                }
438
439                let Some(last_open) = max_open else {
440                    break;
441                };
442
443                let next_cursor = last_open
444                    .checked_add_signed(TimeDelta::seconds(CURSOR_STEP_SECS))
445                    .ok_or(LseError::CursorOverflow { last_open })?;
446
447                // A page consisting solely of bars at or before the cursor would leave the cursor
448                // unmoved and loop forever. That requires the provider to ignore `start`, which is
449                // exactly the silent-parameter failure this integration guards against elsewhere,
450                // so it is surfaced rather than trusted.
451                if next_cursor <= cursor {
452                    Err(LseError::Api {
453                        status: 200,
454                        message: format!(
455                            "page {page} did not advance past {cursor}: the range parameters \
456                             appear to have been ignored"
457                        ),
458                    })?;
459                }
460
461                cursor = next_cursor;
462
463                // `>=` rather than `>`: the vault's upper bound is exclusive, so a cursor sitting
464                // on it selects an empty window. Stopping here saves a guaranteed-empty request
465                // whenever the last bar received was the final one in range.
466                if cursor >= range_end {
467                    break;
468                }
469            }
470        }
471    }
472
473    /// Fetch historical candles into a `Vec`.
474    ///
475    /// Convenience over [`fetch_candles`](Self::fetch_candles) with the same range contract, for
476    /// slices small enough to hold in memory. **Buffers the whole range** — prefer the stream for
477    /// long backfills at fine resolutions.
478    ///
479    /// # Errors
480    /// Fails on the first error, discarding any candles already received. See
481    /// [`fetch_candles`](Self::fetch_candles).
482    pub async fn collect_candles(
483        &self,
484        symbol: &str,
485        interval: CandleInterval,
486        start: DateTime<Utc>,
487        end: DateTime<Utc>,
488    ) -> Result<Vec<Candle>, LseError> {
489        let stream = self.fetch_candles(symbol, interval, start, end);
490        futures::pin_mut!(stream);
491
492        let mut candles = Vec::new();
493        while let Some(candle) = stream.next().await {
494            candles.push(candle?);
495        }
496
497        Ok(candles)
498    }
499}
500
501#[cfg(test)]
502#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
503mod tests {
504    use super::*;
505    use rust_decimal_macros::dec;
506
507    fn row(ts: &str, volume: Option<Decimal>) -> LseCandleRow {
508        LseCandleRow {
509            ts: ts.to_owned(),
510            open: dec!(1),
511            high: dec!(2),
512            low: dec!(0.5),
513            close: dec!(1.5),
514            volume,
515        }
516    }
517
518    #[test]
519    fn an_equity_row_deserializes_float_prices_and_an_integer_volume() {
520        // The measured equity *shape*: prices are JSON floats, volume a JSON integer. Both must
521        // land in `Decimal` without a custom helper.
522        //
523        // Every number here is invented — deliberately round, or obviously not a price — so no row
524        // in this repo can be mistaken for provider data, which we are not licensed to redistribute
525        // (<https://londonstrategicedge.com/terms>). Decoding is value-independent, so a synthetic
526        // row proves exactly the property a captured one would.
527        //
528        // `high` carries **15 significant digits** deliberately: that is the part under test, since
529        // a price this long must survive the `f64` the JSON number is parsed as. 15 is also the
530        // ceiling — the deserializer goes JSON float -> `f64` -> `Decimal`, so a 17-digit literal
531        // like `10.123456789012345` lands as `10.123456789012344` and would make this test assert
532        // the loss rather than the fidelity.
533        let json = r#"{"ts":"2024-01-02 00:00:00.000000","symbol":"TEST","open":10.0,
534                       "high":10.1234567890123,"low":10.0,"close":10.0,"volume":1000000}"#;
535
536        let row: LseCandleRow = serde_json::from_str(json).unwrap();
537
538        assert_eq!(row.open, dec!(10.0));
539        assert_eq!(row.high, dec!(10.1234567890123));
540        assert_eq!(row.volume, Some(dec!(1000000)));
541    }
542
543    #[test]
544    fn an_fx_row_omitting_volume_deserializes_as_none() {
545        // The measured FX shape: no `volume` key at all. This must be `None`, never `Some(0)`.
546        // Values are invented, for the reason given above.
547        let json = r#"{"ts":"2024-01-02 00:00:00.000000","symbol":"AAA/BBB","open":2.0,
548                       "high":2.0,"low":2.0,"close":2.0}"#;
549
550        let row: LseCandleRow = serde_json::from_str(json).unwrap();
551
552        assert_eq!(row.volume, None);
553    }
554
555    #[test]
556    fn timestamps_parse_as_utc() {
557        // The value carries no zone. Treating it as UTC is what makes the pre-market open land at
558        // 09:00 in winter and 08:00 in summer, i.e. 04:00 ET across the DST boundary.
559        let open = row("2024-01-02 09:09:00.000000", None).open_time().unwrap();
560
561        assert_eq!(
562            open,
563            "2024-01-02T09:09:00Z".parse::<DateTime<Utc>>().unwrap()
564        );
565    }
566
567    #[test]
568    fn a_timestamp_without_a_fractional_part_still_parses() {
569        assert!(row("2024-01-02 09:09:00", None).open_time().is_ok());
570    }
571
572    #[test]
573    fn a_malformed_timestamp_is_a_typed_error() {
574        let error = row("not-a-timestamp", None).open_time().unwrap_err();
575
576        assert!(matches!(error, LseError::Deserialize { .. }));
577        assert!(error.to_string().contains("not-a-timestamp"));
578    }
579
580    #[test]
581    fn trade_count_is_always_none() {
582        // The vault reports no trade count for any dataset, so it must be the explicit unknown.
583        let candle = row("2024-01-02 09:09:00.000000", Some(dec!(5))).into_candle(Utc::now());
584
585        assert_eq!(candle.trade_count, None);
586    }
587
588    #[test]
589    fn a_daily_bars_close_is_the_next_midnight_not_its_own_label() {
590        // `ts` is the bar's OPEN. Passing it through as `close_time` would shift every candle in
591        // the integration by one bar.
592        let open = row("2024-01-02 00:00:00.000000", None).open_time().unwrap();
593
594        let close = close_time_from_open(open, CandleInterval::Day1.to_step()).unwrap();
595
596        assert_eq!(
597            close,
598            "2024-01-03T00:00:00Z".parse::<DateTime<Utc>>().unwrap()
599        );
600    }
601
602    #[test]
603    fn a_monthly_bars_close_uses_calendar_arithmetic() {
604        // Measured: monthly bars are labelled on the 1st. February's length rules out a fixed step.
605        let open = row("2024-02-01 00:00:00.000000", None).open_time().unwrap();
606
607        let close = close_time_from_open(open, CandleInterval::Month1.to_step()).unwrap();
608
609        assert_eq!(
610            close,
611            "2024-03-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap()
612        );
613    }
614}