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}