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}