Skip to main content

rustrade_data/exchange/lse/
tick.rs

1//! The London Strategic Edge WebSocket tick frame, and the three timestamp spellings it arrives in.
2
3use super::mapper::subscription_id;
4use crate::Identifier;
5use chrono::{DateTime, Utc};
6use rust_decimal::Decimal;
7use rustrade_integration::subscription::SubscriptionId;
8use serde::{
9    Deserialize, Deserializer,
10    de::{self, Unexpected, Visitor},
11};
12use smol_str::SmolStr;
13use std::fmt;
14
15/// A tick published on the London Strategic Edge WebSocket.
16///
17/// One frame shape serves every dataset — FX, crypto, equities, ETFs, indices, commodities,
18/// futures, volatility, currency indices and interest rates all publish the same seven keys. This
19/// is the opposite of the provider's bulk-export path, where the column set varies per dataset.
20///
21/// ```json
22/// {"type":"tick","symbol":"BTC/USD","ts":"2026-01-02T09:37:24.760146+00:00",
23///  "price":42000.5,"bid":42000.5,"ask":42001.0,"volume":0.00155}
24/// ```
25///
26/// # ⚠️ The tick is a QUOTE, not a print — except on the options channel
27/// `price` equals `bid` exactly — measured on 3,966 of 3,966 sampled ticks spanning every dataset
28/// family, plus every sample taken on the provider's other price endpoint. Anything decoded from
29/// this frame as a trade is therefore a bid-side quote wearing a trade's shape, and is not evidence
30/// that a transaction occurred at that price or at all. See the trade transformer for what ships
31/// anyway and why.
32///
33/// # Option contract ticks differ in four ways
34/// Option contracts tick on this same frame, under their OSI symbol (`SPY260930C00700000`):
35///
36/// - **`bid` and `ask` are `null` on every one**, while an equity tick on the same connection in
37///   the same second carries both. It is a property of the channel, not of market hours, so an
38///   option contract has no quote here at all — [`bid`](Self::bid) and [`ask`](Self::ask) are
39///   optional for this reason alone.
40/// - **The tick is a real print.** Over one identical window every option tick matched a row of the
41///   provider's REST option-print tape exactly on `(price, volume)`, and vice versa. The quote
42///   caveat above does not apply to it.
43/// - **`ts` is whole-second.** Dozens of prints share one value, so an option tick's instant is
44///   neither a unique key nor an ordering: arrival order is the only sequencing, and it was never
45///   observed out of order.
46/// - **An extra `name` key** carries a human-readable contract label. It is present on every live
47///   option tick, so it marks the channel and says nothing about replay — only [`replay`] does
48///   that. It is not decoded.
49///
50/// [`replay`]: Self::replay
51///
52/// # ⚠️ `volume` is real on some datasets and fabricated on others
53/// Crypto and equity ticks carry a genuine per-tick size: summing it over three whole minutes of
54/// one crypto symbol reproduced the provider's own one-minute candle volume to the last decimal,
55/// ratio `1.000`. FX and commodity ticks carry a **hard-coded `1.0`** on every tick of every
56/// symbol sampled — a fabricated size, not a measured one, and it will aggregate into a
57/// legitimate-looking total at any resolution. Note this differs from the provider's REST vault,
58/// which *omits* FX volume entirely; the WebSocket invents a value instead.
59///
60/// # ⚠️ Identical consecutive ticks are REAL and must not be filtered
61/// Ticks routinely repeat a prior `(ts, price, bid, ask, volume)` signature — barely a third of a
62/// sampled run was unique, and one signature recurred 49 times. They are nonetheless distinct
63/// prints: de-duplicating them destroyed 3–10% of the volume that reconciles exactly against the
64/// provider's candles. This is an aggregated cross-venue tape on which identical-size fills at the
65/// same millisecond are ordinary. Do not add a de-duplication filter.
66///
67/// # The four [`Decimal`] fields carry ~15 significant digits, not arbitrary precision
68/// This feed sends prices as JSON **numbers** rather than as strings — it is the only connector in
69/// this crate that does — so they are parsed as `f64` before reaching [`Decimal`]. The ceiling is
70/// therefore the `f64`'s: 15 significant digits survive exactly, and a 17-digit literal like
71/// `10.123456789012345` lands as `10.123456789012344`. The provider's own tick shapes
72/// (`42000.5`, `0.00155`, `16.9`) are nowhere near that, so no observed value loses a digit.
73///
74/// The same ceiling applies on the bulk-export path for the same reason, where it is pinned by
75/// `an_equity_row_deserializes_float_prices_and_an_integer_volume` in
76/// [`historical`](super::historical).
77///
78/// Reading the provider's digits instead of the `f64` is **not available here**, and that is
79/// structural rather than an omission: [`LseMessage`] is internally tagged, so serde buffers the
80/// frame body into its `Content` type before this struct is deserialised, and `Content` stores a
81/// JSON number as an already-parsed `f64`. A raw-text deserialiser — `serde_json::value::RawValue`
82/// — cannot be driven from that buffer at all, for the same reason a borrowed `&str` symbol cannot
83/// (see `de_tick_subscription_id`). Recovering the digits would mean giving up the derived
84/// tagging for a hand-written dispatch on the crate's central wire enum.
85#[derive(Clone, PartialEq, Eq, Debug, Deserialize)]
86pub struct LseTick {
87    /// Subscription identifier built from `symbol` during deserialisation.
88    ///
89    /// Constructing it here keeps the per-tick `format!` out of the transformer's hot path, and
90    /// mirrors how every other WebSocket integration in this crate resolves a frame to its
91    /// instrument.
92    #[serde(rename = "symbol", deserialize_with = "de_tick_subscription_id")]
93    pub subscription_id: SubscriptionId,
94
95    /// The instant the provider stamped the tick with.
96    ///
97    /// # The three spellings, all measured on one connection
98    /// 1. **RFC 3339** — `2026-01-02T09:37:24.760146+00:00`.
99    /// 2. **The same with a space** separating date and time —
100    ///    `2026-01-02 09:37:21.690159+00:00`, which is what five of thirteen sampled dataset
101    ///    families send. [`DateTime::parse_from_rfc3339`] **accepts** it: RFC 3339 §5.6 permits a
102    ///    space in place of the `T` by prior agreement, and `chrono` implements that lenience —
103    ///    verified against both 0.4.39, the oldest this workspace admits, and the pinned 0.4.45.
104    ///    It is a lenience rather than a guarantee, so
105    ///    `a_space_separated_timestamp_decodes_to_the_same_instant_as_the_t_form` pins it: a future
106    ///    `chrono` that tightened the grammar would fail the build rather than silently drop five
107    ///    dataset families.
108    /// 3. **Epoch seconds as a JSON number** — `1786091921.387`. Replayed ticks use this while
109    ///    live ticks on the *same connection* use a string, so the field changes JSON type
110    ///    mid-stream. A decoder typed as a string fails the moment replay is enabled.
111    ///
112    /// Sub-second precision also varies per symbol within one spelling: microseconds,
113    /// milliseconds, and — on at least one symbol — no fractional component at all.
114    ///
115    /// # Every spelling is quantised to microseconds
116    /// An `f64` cannot carry nanosecond resolution at epoch scale: near the present its spacing is
117    /// roughly a quarter of a microsecond, so nanoseconds recovered from spelling 3 are noise.
118    /// Rounding to the nearest microsecond discards that noise and recovers the provider's own
119    /// precision exactly, which matters for more than tidiness — **it is what makes the three
120    /// spellings of one instant decode to the same value.** The replay path re-sends ticks the live
121    /// path already delivered, in a different spelling, so anything comparing the two for equality
122    /// (resume arithmetic above all) depends on them agreeing.
123    ///
124    /// The string spellings are quantised to that same resolution rather than passed through at
125    /// whatever they carry, so the agreement is *enforced* rather than observed. Every string
126    /// sampled carried at most six fractional digits, but a seventh would defeat the resume
127    /// comparison silently: the watermark would hold a sub-microsecond instant, `start` would go
128    /// out truncated to the microsecond below it, and every replayed tick at that instant would
129    /// then sort *before* the watermark and be re-emitted as a duplicate.
130    ///
131    /// A string that is neither spelling, including one carrying no UTC offset, is rejected rather
132    /// than guessed at — a mis-parsed timestamp is a silently misdated market event.
133    #[serde(rename = "ts", deserialize_with = "de_timestamp")]
134    pub time_exchange: DateTime<Utc>,
135
136    /// The tick price. Equal to [`bid`](Self::bid) on every non-option sample taken, and the traded
137    /// premium on an option contract; see the type-level note,
138    /// which also covers the ~15-significant-digit ceiling this and the three fields below decode
139    /// under.
140    pub price: Decimal,
141
142    /// The bid. `None` on every option contract tick, which the provider publishes as `null`;
143    /// present on every other dataset sampled.
144    #[serde(default)]
145    pub bid: Option<Decimal>,
146
147    /// The ask. `None` on every option contract tick, which the provider publishes as `null`;
148    /// present on every other dataset sampled.
149    #[serde(default)]
150    pub ask: Option<Decimal>,
151
152    /// The size traded at this tick — genuine on crypto and equities, fabricated on FX and
153    /// commodities. See the type-level note.
154    pub volume: Decimal,
155
156    /// Whether the tick was served from the historical replay buffer rather than live.
157    ///
158    /// The provider sets this only on replayed ticks; live ticks carry no `replay` key at all, so
159    /// absence means live and the default is load-bearing rather than cosmetic.
160    #[serde(default)]
161    pub replay: bool,
162}
163
164/// A frame received on the London Strategic Edge WebSocket data stream.
165///
166/// The stream carries control frames (subscription confirmations, replay boundaries, errors)
167/// interleaved with ticks, on the same connection and after the handshake has completed. Anything
168/// this integration does not act on decodes to [`Other`](Self::Other) so a control frame cannot
169/// fail the parse and take the stream down with it; the confirmations that carry meaning to the
170/// *handshake* are decoded by the subscription types instead, where they can be acted on.
171///
172/// Two control frames are modelled rather than collapsed into [`Other`](Self::Other), because both
173/// carry something no other frame does: [`ReplayStarted`](Self::ReplayStarted) is the only evidence
174/// a replay window was clamped, and [`Error`](Self::Error) is the only evidence the provider
175/// rejected something after the handshake stopped reading.
176#[derive(Clone, PartialEq, Eq, Debug, Deserialize)]
177#[serde(tag = "type", rename_all = "snake_case")]
178pub enum LseMessage {
179    /// A tick.
180    Tick(LseTick),
181
182    /// The boundary frame announcing the replay window the provider actually opened.
183    ///
184    /// # ⚠️ `from` is the only signal that a `start` was clamped
185    /// The provider retains 24 hours. A `start` further back is **silently moved forward**, with no
186    /// error: a window requested 48 hours back was answered with one beginning exactly 24 hours
187    /// back, and streamed from there. Comparing `from` against what was asked for is the only way
188    /// to detect it.
189    ///
190    /// # Why it is modelled on the stream rather than caught during the handshake
191    /// The provider answers `subscribe` *before* it announces the window — measured on both a
192    /// crypto and an FX symbol, the order is `subscribed`, then `replay_started`, then the replayed
193    /// ticks. A subscribe handshake is complete the moment its last confirmation arrives, so a
194    /// single-symbol resumed subscription never has a `replay_started` to inspect at handshake
195    /// time, and a batch never has one for its last symbol. Reading it here instead makes the check
196    /// independent of frame ordering and of batch size.
197    ///
198    /// The shared connection routes it only to the streams a replay window was opened for — see
199    /// [`connection`](super::connection).
200    ReplayStarted {
201        /// The subscription the window belongs to, built from `symbol` exactly as a tick's is.
202        #[serde(rename = "symbol", deserialize_with = "de_tick_subscription_id")]
203        subscription_id: SubscriptionId,
204
205        /// The instant the provider will actually replay from.
206        #[serde(deserialize_with = "de_timestamp")]
207        from: DateTime<Utc>,
208    },
209
210    /// A rejection raised outside a subscribe handshake.
211    ///
212    /// # Why no stream normally sees one
213    /// The provider does **not** name the symbol it rejected, so a rejection arriving outside a
214    /// handshake — a later `LIMIT_REACHED`, a credential that expired mid-connection — cannot be
215    /// routed to the stream it concerns. The shared [`connection`](super::connection) logs it once
216    /// instead, and one arriving *during* a handshake fails that handshake.
217    ///
218    /// It is still decoded rather than collapsed into [`Other`](Self::Other), so a rejection that
219    /// reaches a stream by any other route is reported rather than discarded in silence — the one
220    /// thing a rejection must never be, since the only outward sign of one is a subscription that
221    /// stops ticking.
222    ///
223    /// Both fields are optional for the same reason they are on the handshake's own rejection: a
224    /// frame missing one must still decode, because failing the parse on an error frame would take
225    /// the whole stream down over the message that was trying to report a problem.
226    Error {
227        /// The provider's error code — `LIMIT_REACHED` and `INVALID_START` are the two observed.
228        #[serde(default)]
229        code: Option<SmolStr>,
230
231        /// The provider's own diagnostic.
232        #[serde(default)]
233        message: Option<SmolStr>,
234    },
235
236    /// Any other frame — a control frame, or one this integration does not model.
237    #[serde(other)]
238    Other,
239}
240
241impl Identifier<Option<SubscriptionId>> for LseMessage {
242    /// A tick resolves to the subscription its symbol was registered under; anything else resolves
243    /// to nothing.
244    ///
245    /// The `None` is what keeps control frames out of the market stream: the transformer drops an
246    /// unidentifiable frame rather than attempting to convert it, so the two decoders never have to
247    /// invent an event for a frame that describes none.
248    fn id(&self) -> Option<SubscriptionId> {
249        match self {
250            Self::Tick(tick) => Some(tick.subscription_id.clone()),
251            // A replay boundary names a subscription but describes the stream rather than a market
252            // event, and a rejection names no subscription at all. The transformer consumes both
253            // before this point; resolving either to an instrument would only offer it to a decoder
254            // that has nothing to build from it.
255            Self::ReplayStarted { .. } | Self::Error { .. } | Self::Other => None,
256        }
257    }
258}
259
260/// Deserialise the tick's `symbol` into the [`SubscriptionId`] it was registered under.
261///
262/// # Why this is not typed as `&str`
263/// Every symbol this feed publishes contains a `/`, and a JSON producer is free to spell that as
264/// the escape `\/` — RFC 8259 lists it among the permitted escapes, and escaping it is common
265/// enough that several encoders do it by default. A borrowed `&str` cannot be produced from an
266/// escaped string, because unescaping needs somewhere to write the result, so a decoder typed that
267/// way fails at runtime on a frame that is entirely legal. `SmolStr` accepts either spelling and
268/// holds up to 23 bytes inline, so the symbol itself is not allocated.
269///
270/// The identifier is the symbol, so it is not allocated either — every symbol this feed publishes
271/// fits inline. See [`subscription_id`].
272fn de_tick_subscription_id<'de, D>(deserializer: D) -> Result<SubscriptionId, D::Error>
273where
274    D: Deserializer<'de>,
275{
276    SmolStr::deserialize(deserializer).map(|symbol| subscription_id(&symbol))
277}
278
279/// Deserialise a WebSocket timestamp from any of the three spellings the provider uses.
280///
281/// Every timestamp the WebSocket sends shares this problem, not just a tick's `ts` — a replay
282/// window's `from` is decoded through it too. The spellings, and why the epoch form is quantised
283/// to microseconds, are documented on [`LseTick::time_exchange`], which is where a reader of the
284/// public API meets them.
285fn de_timestamp<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
286where
287    D: Deserializer<'de>,
288{
289    deserializer.deserialize_any(TimestampVisitor)
290}
291
292struct TimestampVisitor;
293
294impl Visitor<'_> for TimestampVisitor {
295    type Value = DateTime<Utc>;
296
297    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
298        formatter.write_str(
299            "an RFC 3339 timestamp, the same with a space separating date and time, or epoch \
300             seconds as a number",
301        )
302    }
303
304    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
305    where
306        E: de::Error,
307    {
308        parse_timestamp(value).ok_or_else(|| E::invalid_value(Unexpected::Str(value), &self))
309    }
310
311    fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
312    where
313        E: de::Error,
314    {
315        time_from_epoch_seconds(value)
316            .ok_or_else(|| E::invalid_value(Unexpected::Float(value), &self))
317    }
318
319    fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
320    where
321        E: de::Error,
322    {
323        // An epoch that lands on a whole second has no fractional part to print, so JSON carries
324        // it as an integer and never reaches `visit_f64`.
325        i64::try_from(value)
326            .ok()
327            .and_then(|seconds| DateTime::from_timestamp(seconds, 0))
328            .ok_or_else(|| E::invalid_value(Unexpected::Unsigned(value), &self))
329    }
330
331    fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
332    where
333        E: de::Error,
334    {
335        DateTime::from_timestamp(value, 0)
336            .ok_or_else(|| E::invalid_value(Unexpected::Signed(value), &self))
337    }
338}
339
340/// Parse spellings 1 and 2 — see [`de_timestamp`].
341///
342/// Both spellings take one code path because `chrono` accepts the space separator itself, as the
343/// RFC 3339 §5.6 lenience it is. There is deliberately no second, laxer grammar behind this: an
344/// offset RFC 3339 rejects is rejected here too, so the spellings cannot drift apart in what they
345/// admit.
346fn parse_timestamp(raw: &str) -> Option<DateTime<Utc>> {
347    DateTime::parse_from_rfc3339(raw)
348        .ok()
349        .map(|parsed| parsed.with_timezone(&Utc))
350        .and_then(round_to_micros)
351}
352
353/// Round an instant to the nearest microsecond.
354///
355/// The provider is measured never to spell more than six fractional digits, so this is a no-op on
356/// every string sampled. It is applied anyway because the resume arithmetic compares instants
357/// decoded from different spellings for *equality* — see [`LseTick::time_exchange`], which is where
358/// that dependency is documented.
359fn round_to_micros(time: DateTime<Utc>) -> Option<DateTime<Utc>> {
360    // `timestamp_subsec_nanos` measures from the second below, so this rounds half upward. The
361    // epoch spelling's `f64::round` rounds half away from zero, and the two agree on every instant
362    // at or after the epoch -- which is every instant this feed can carry.
363    let round_up = i64::from(time.timestamp_subsec_nanos() % 1_000 >= 500);
364
365    DateTime::from_timestamp_micros(time.timestamp_micros() + round_up)
366}
367
368/// Parse spelling 3 — see [`de_timestamp`] for why this rounds to microseconds.
369fn time_from_epoch_seconds(epoch: f64) -> Option<DateTime<Utc>> {
370    /// Microseconds in a second, as the multiplier for the epoch conversion.
371    const MICROS_PER_SECOND: f64 = 1_000_000.0;
372
373    if !epoch.is_finite() {
374        return None;
375    }
376
377    // The whole conversion stays inside `f64`'s exactly-representable integer range: epoch
378    // microseconds near the present are ~1.8e15, comfortably under 2^53. Scaling first and
379    // rounding once is therefore exact, where splitting into seconds and a fraction would round
380    // twice for no gain.
381    let micros = (epoch * MICROS_PER_SECOND).round();
382    if micros < i64::MIN as f64 || micros > i64::MAX as f64 {
383        return None;
384    }
385
386    // `micros` is integral (just rounded) and inside `i64`'s range (just checked), so this
387    // conversion is exact -- neither fact is visible to the lint, and `f64` carries no fallible
388    // conversion to `i64` that would express them.
389    #[allow(clippy::cast_possible_truncation)]
390    let micros = micros as i64;
391
392    DateTime::from_timestamp_micros(micros)
393}
394
395#[cfg(test)]
396#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
397mod tests {
398    use super::*;
399    use rust_decimal_macros::dec;
400
401    /// A live tick, in the RFC 3339 spelling with a `T` separator.
402    const LIVE_T_SEPARATED: &str = r#"{"type":"tick","symbol":"BTC/USD",
403        "ts":"2026-01-02T09:37:24.760146+00:00","price":42000.5,"bid":42000.5,
404        "ask":42001.0,"volume":0.00155}"#;
405
406    /// A live tick from one of the families that separates date and time with a space.
407    const LIVE_SPACE_SEPARATED: &str = r#"{"type":"tick","symbol":"EUR/USD",
408        "ts":"2026-01-02 09:37:21.690159+00:00","price":1.1,"bid":1.1,"ask":1.1001,
409        "volume":1.0}"#;
410
411    #[test]
412    fn a_t_separated_timestamp_decodes() {
413        let tick: LseTick = serde_json::from_str(LIVE_T_SEPARATED).unwrap();
414
415        assert_eq!(
416            tick.time_exchange,
417            "2026-01-02T09:37:24.760146Z"
418                .parse::<DateTime<Utc>>()
419                .unwrap()
420        );
421        assert_eq!(tick.price, dec!(42000.5));
422        assert_eq!(tick.volume, dec!(0.00155));
423    }
424
425    /// Five of thirteen sampled dataset families send this spelling. `chrono` accepts the space as
426    /// an RFC 3339 §5.6 lenience rather than a guarantee, so this test is the canary: if a future
427    /// version tightened the grammar, those five families would otherwise start failing to decode
428    /// silently, in production, on a dependency bump.
429    #[test]
430    fn a_space_separated_timestamp_decodes_to_the_same_instant_as_the_t_form() {
431        let tick: LseTick = serde_json::from_str(LIVE_SPACE_SEPARATED).unwrap();
432
433        assert_eq!(
434            tick.time_exchange,
435            "2026-01-02T09:37:21.690159Z"
436                .parse::<DateTime<Utc>>()
437                .unwrap()
438        );
439    }
440
441    /// At least one symbol publishes no fractional component at all.
442    #[test]
443    fn a_timestamp_without_a_subsecond_component_decodes() {
444        let input = r#"{"type":"tick","symbol":"VIX/USD","ts":"2026-01-02T19:59:46+00:00",
445            "price":16.9,"bid":16.9,"ask":16.93,"volume":0.0}"#;
446        let tick: LseTick = serde_json::from_str(input).unwrap();
447
448        assert_eq!(
449            tick.time_exchange,
450            "2026-01-02T19:59:46Z".parse::<DateTime<Utc>>().unwrap()
451        );
452    }
453
454    /// Replayed ticks send the timestamp as a JSON number while live ticks on the same connection
455    /// send a string, so the field changes JSON type mid-stream.
456    #[test]
457    fn a_float_epoch_timestamp_decodes() {
458        let input = r#"{"type":"tick","symbol":"BTC/USD","ts":1767346644.592,"name":"Bitcoin",
459            "replay":true,"price":42000.5,"bid":42000.5,"ask":42001.0,"volume":0.00155}"#;
460        let tick: LseTick = serde_json::from_str(input).unwrap();
461
462        assert_eq!(
463            tick.time_exchange,
464            DateTime::from_timestamp_micros(1_767_346_644_592_000).unwrap()
465        );
466        assert!(tick.replay);
467    }
468
469    /// The property the resume arithmetic depends on: a replayed tick and the live tick it repeats
470    /// describe the same instant in different spellings, and must decode equal. Nanosecond
471    /// conversion would fail this — `f64` spacing near the present is ~0.24µs.
472    #[test]
473    fn the_string_and_float_spellings_of_one_instant_decode_equal() {
474        let as_string = parse_timestamp("2026-01-02T09:37:24.760146+00:00").unwrap();
475        let as_float = time_from_epoch_seconds(1_767_346_644.760_146).unwrap();
476
477        assert_eq!(as_string, as_float);
478    }
479
480    /// The enforcement half of the same property: a string carrying more precision than the epoch
481    /// spelling can express is quantised to microseconds rather than passed through, so the two
482    /// still decode equal. Without it the watermark would hold an instant `start` cannot name, and
483    /// the replay would re-deliver it as a duplicate.
484    #[test]
485    fn a_sub_microsecond_string_is_quantised_so_it_still_matches_the_float_spelling() {
486        let seven_digits = parse_timestamp("2026-01-02T09:37:24.7601461+00:00").unwrap();
487        let as_float = time_from_epoch_seconds(1_767_346_644.760_146).unwrap();
488
489        assert_eq!(seven_digits, as_float);
490        assert_eq!(seven_digits.timestamp_subsec_nanos() % 1_000, 0);
491
492        // Rounds to nearest rather than truncating, matching what the epoch spelling recovers.
493        assert_eq!(
494            parse_timestamp("2026-01-02T09:37:24.7601465+00:00").unwrap(),
495            "2026-01-02T09:37:24.760147Z"
496                .parse::<DateTime<Utc>>()
497                .unwrap()
498        );
499    }
500
501    /// A whole-second epoch has no fractional part to print, so JSON carries it as an integer.
502    #[test]
503    fn an_integer_epoch_timestamp_decodes() {
504        let input = r#"{"type":"tick","symbol":"BTC/USD","ts":1767346644,"replay":true,
505            "price":42000.5,"bid":42000.5,"ask":42001.0,"volume":0.00155}"#;
506        let tick: LseTick = serde_json::from_str(input).unwrap();
507
508        assert_eq!(
509            tick.time_exchange,
510            DateTime::from_timestamp(1_767_346_644, 0).unwrap()
511        );
512    }
513
514    /// Live ticks carry no `replay` key at all, so the default decides whether every live tick is
515    /// mistaken for a replayed one.
516    #[test]
517    fn replay_defaults_to_false_when_the_key_is_absent() {
518        let tick: LseTick = serde_json::from_str(LIVE_T_SEPARATED).unwrap();
519        assert!(!tick.replay);
520    }
521
522    /// An option contract tick, synthetic but in the shape the options channel publishes: `bid`
523    /// and `ask` present as `null`, a whole-second `ts`, an integer `volume` and a `name` label.
524    const OPTION_TICK: &str = r#"{"type":"tick","symbol":"TEST261231C00010500",
525        "ts":"2026-01-02T15:00:00+00:00","price":1.25,"bid":null,"ask":null,"volume":3,
526        "name":"TEST $10.50 Call Dec 31"}"#;
527
528    /// Both sides are null on every option tick. Required fields would fail the whole frame, and
529    /// every print on the channel with it.
530    #[test]
531    fn an_option_tick_decodes_with_no_quote() {
532        let tick: LseTick = serde_json::from_str(OPTION_TICK).unwrap();
533
534        assert_eq!(tick.subscription_id.as_ref(), "TEST261231C00010500");
535        assert_eq!(tick.price, dec!(1.25));
536        assert_eq!(tick.volume, dec!(3));
537        assert_eq!(tick.bid, None);
538        assert_eq!(tick.ask, None);
539        assert_eq!(
540            tick.time_exchange,
541            "2026-01-02T15:00:00Z".parse::<DateTime<Utc>>().unwrap()
542        );
543    }
544
545    /// `name` rides on every live option tick. It marks the channel, so reading it as a replay
546    /// marker would classify every live option print as replayed.
547    #[test]
548    fn an_option_ticks_name_does_not_mark_it_as_replayed() {
549        let tick: LseTick = serde_json::from_str(OPTION_TICK).unwrap();
550        assert!(!tick.replay);
551    }
552
553    /// The decoder builds the identifier on every tick, so it must fit inline for the longest
554    /// symbol the feed publishes: an OSI contract on a six-character root.
555    #[test]
556    fn a_long_root_option_ticks_identifier_is_not_allocated() {
557        let frame = OPTION_TICK.replace("TEST261231C00010500", "GOOGLX261231C00010500");
558        let tick: LseTick = serde_json::from_str(&frame).unwrap();
559
560        assert_eq!(tick.subscription_id.as_ref(), "GOOGLX261231C00010500");
561        assert!(!tick.subscription_id.0.is_heap_allocated());
562    }
563
564    #[test]
565    fn the_subscription_id_is_built_from_the_symbol() {
566        let tick: LseTick = serde_json::from_str(LIVE_T_SEPARATED).unwrap();
567        assert_eq!(tick.subscription_id.as_ref(), "BTC/USD");
568    }
569
570    /// A timestamp carrying no offset is ambiguous, and guessing at it would silently misdate the
571    /// event rather than fail.
572    #[test]
573    fn a_timestamp_without_an_offset_is_rejected() {
574        assert!(parse_timestamp("2026-01-02 09:37:21.690159").is_none());
575        assert!(parse_timestamp("2026-01-02T09:37:21.690159").is_none());
576    }
577
578    #[test]
579    fn a_non_timestamp_string_is_rejected() {
580        assert!(parse_timestamp("").is_none());
581        assert!(parse_timestamp("not a timestamp").is_none());
582    }
583
584    #[test]
585    fn a_non_finite_epoch_is_rejected() {
586        assert!(time_from_epoch_seconds(f64::NAN).is_none());
587        assert!(time_from_epoch_seconds(f64::INFINITY).is_none());
588        assert!(time_from_epoch_seconds(f64::MAX).is_none());
589    }
590
591    #[test]
592    fn a_tick_frame_decodes_as_a_tick() {
593        let message: LseMessage = serde_json::from_str(LIVE_T_SEPARATED).unwrap();
594        assert!(matches!(message, LseMessage::Tick(_)));
595    }
596
597    #[test]
598    fn a_tick_identifies_itself_by_its_subscription() {
599        let message: LseMessage = serde_json::from_str(LIVE_T_SEPARATED).unwrap();
600        assert_eq!(
601            message.id(),
602            Some(SubscriptionId::from("BTC/USD")),
603            "a tick must resolve to the subscription it was registered under"
604        );
605    }
606
607    /// The transformer drops a frame that identifies no subscription, which is what keeps control
608    /// frames from reaching the decoders at all.
609    #[test]
610    fn a_control_frame_identifies_no_subscription() {
611        let message: LseMessage =
612            serde_json::from_str(r#"{"type":"replay_complete","symbol":"BTC/USD","rows":41}"#)
613                .unwrap();
614        assert_eq!(message.id(), None);
615    }
616
617    /// Control frames share the data stream with ticks. Failing the parse on one would take the
618    /// stream down over a message the transformer has no interest in.
619    #[test]
620    fn control_frames_decode_as_other_rather_than_failing_the_parse() {
621        for input in [
622            r#"{"type":"subscribed","symbol":"BTC/USD","count":1,"max":16}"#,
623            r#"{"type":"replay_complete","symbol":"BTC/USD","rows":41,"buffered_drained":9}"#,
624            r#"{"type":"welcome","message":"hello","symbols_available":1}"#,
625        ] {
626            let message: LseMessage = serde_json::from_str(input).unwrap();
627            assert_eq!(message, LseMessage::Other, "failed on {input}");
628        }
629    }
630
631    /// A rejection raised after the handshake stopped reading is the one control frame that must
632    /// not be discarded in silence — the provider names no symbol, so a swallowed rejection leaves
633    /// a subscription that simply stops ticking as the only sign anything went wrong.
634    #[test]
635    fn a_rejection_decodes_as_an_error_rather_than_being_collapsed_into_other() {
636        let message: LseMessage = serde_json::from_str(
637            r#"{"type":"error","code":"LIMIT_REACHED","message":"Max 16 symbols"}"#,
638        )
639        .unwrap();
640
641        assert_eq!(
642            message,
643            LseMessage::Error {
644                code: Some("LIMIT_REACHED".into()),
645                message: Some("Max 16 symbols".into()),
646            }
647        );
648    }
649
650    /// Failing the parse on an error frame would take the stream down over the message that was
651    /// trying to report a problem, so a rejection missing either field must still decode.
652    #[test]
653    fn a_rejection_missing_its_fields_still_decodes_as_an_error() {
654        let message: LseMessage = serde_json::from_str(r#"{"type":"error"}"#).unwrap();
655
656        assert_eq!(
657            message,
658            LseMessage::Error {
659                code: None,
660                message: None,
661            }
662        );
663    }
664
665    /// The rejection names no subscription, so it must resolve to no instrument — the transformer
666    /// consumes it, and no decoder has a market event to build from it.
667    #[test]
668    fn a_rejection_identifies_no_subscription() {
669        let message: LseMessage =
670            serde_json::from_str(r#"{"type":"error","code":"LIMIT_REACHED"}"#).unwrap();
671
672        assert_eq!(message.id(), None);
673    }
674
675    /// `/` is in every symbol this feed publishes and RFC 8259 permits spelling it `\/`, which
676    /// several JSON encoders do by default. A symbol decoded as a borrowed `&str` cannot survive
677    /// that — unescaping needs somewhere to write — so this pins the owned decode.
678    #[test]
679    fn a_symbol_carrying_an_escaped_solidus_decodes_to_the_same_subscription() {
680        let escaped: LseMessage = serde_json::from_str(
681            r#"{"type":"tick","symbol":"BTC\/USD","ts":"2026-01-02T09:37:24.760146+00:00",
682                "price":42000.5,"bid":42000.5,"ask":42001.0,"volume":0.00155}"#,
683        )
684        .unwrap();
685
686        assert_eq!(escaped.id(), Some(SubscriptionId::from("BTC/USD")));
687
688        // The replay boundary decodes its symbol through the same function, and a clamp that went
689        // undetected because the boundary frame failed to parse would be silent.
690        let boundary: LseMessage = serde_json::from_str(
691            r#"{"type":"replay_started","symbol":"BTC\/USD","from":"2026-01-02T09:37:24+00:00"}"#,
692        )
693        .unwrap();
694
695        assert_eq!(
696            boundary,
697            LseMessage::ReplayStarted {
698                subscription_id: SubscriptionId::from("BTC/USD"),
699                from: "2026-01-02T09:37:24Z".parse::<DateTime<Utc>>().unwrap(),
700            }
701        );
702    }
703
704    /// The replay boundary is the one control frame that is modelled, because its `from` is the
705    /// only evidence a requested window was clamped to the provider's retention.
706    #[test]
707    fn a_replay_boundary_decodes_with_its_window_in_every_timestamp_spelling() {
708        for raw in [
709            r#""2026-01-02 09:39:31.716622+00:00""#,
710            r#""2026-01-02T09:39:31.716622+00:00""#,
711            "1767346771.716622",
712        ] {
713            let input = format!(r#"{{"type":"replay_started","symbol":"BTC/USD","from":{raw}}}"#);
714            let message: LseMessage = serde_json::from_str(&input).unwrap();
715
716            assert_eq!(
717                message,
718                LseMessage::ReplayStarted {
719                    subscription_id: SubscriptionId::from("BTC/USD"),
720                    from: "2026-01-02T09:39:31.716622Z"
721                        .parse::<DateTime<Utc>>()
722                        .unwrap(),
723                },
724                "failed on {raw}"
725            );
726        }
727    }
728
729    /// It names a subscription but describes the stream, so it must not resolve to an instrument —
730    /// a decoder handed one has no market event to build from it.
731    #[test]
732    fn a_replay_boundary_identifies_no_subscription() {
733        let message: LseMessage = serde_json::from_str(
734            r#"{"type":"replay_started","symbol":"BTC/USD","from":"2026-01-02T09:37:24+00:00"}"#,
735        )
736        .unwrap();
737
738        assert_eq!(message.id(), None);
739    }
740}