rustrade_data/exchange/lse/quote.rs
1//! Decode a London Strategic Edge tick as an [`OrderBookL1`].
2
3use super::tick::LseMessage;
4use crate::{
5 books::Level,
6 event::{MarketEvent, MarketIter},
7 subscription::book::OrderBookL1,
8};
9use chrono::Utc;
10use rust_decimal::Decimal;
11use rustrade_instrument::exchange::ExchangeId;
12
13/// Decode a tick frame as a top-of-book quote.
14///
15/// This is the mapping the frame fits most honestly: the tick carries a bid and an ask, and its
16/// `price` equals the bid on every sample taken. The trade mapping in
17/// [`trade`](super::trade) exists alongside it and is documented as the approximation it is.
18///
19/// # Both levels carry a ZERO size
20/// The feed publishes no bid or ask size — only prices — so the sizes here are placeholders, not
21/// measurements. This is the same choice the provider's bulk-export decoder makes, for the same
22/// reason: a fabricated size would be indistinguishable downstream from a real one. It is safe
23/// because `DefaultInstrumentMarketData::price` in the `rustrade` crate falls back from the
24/// volume-weighted mid to the plain mid when the sizes are absent, so a zero-size book prices
25/// correctly rather than not at all. Consumers that genuinely need depth must not read these as
26/// available quantity.
27///
28/// The tick's `volume` is deliberately **not** used for either level: it is a traded size, not
29/// resting bid or ask liquidity, and on two of the five venues it is a fabricated constant. See
30/// [`trade`](super::trade) for that warning in full.
31///
32/// # ⚠️ A zero PRICE is absent, and it is not the same case as a zero size
33/// The provider publishes `0.0` for a side it has no quote for — a venue outside its session, a
34/// symbol quoted on one side only. Published as `Some(Level { price: 0, .. })` that becomes a real
35/// level asserting someone will trade at zero, and the damage is worse than the obvious one: it is
36/// precisely the volume-weighted-mid fallback that makes the zero *sizes* above harmless which
37/// makes a zero price dangerous. `mid_price` requires only that both sides be `Some`, and it
38/// averages whatever prices they carry — so a book quoting a real ask of `42001` against a zero bid
39/// prices at `21000.5`, a number that is neither wrong-looking nor recoverable downstream. The
40/// consumer cannot filter it either, because nothing distinguishes it from a genuine quote.
41///
42/// So a zero price maps to `None`, following the same rule as the Binance L1 decoder. `None` is the
43/// spelling every consumer of this type already handles as "no quote on this side"; a one-sided
44/// book prices as no book at all, which is the honest answer when there is no quote to average.
45impl<InstrumentKey> From<(ExchangeId, InstrumentKey, LseMessage)>
46 for MarketIter<InstrumentKey, OrderBookL1>
47{
48 fn from((exchange, instrument, message): (ExchangeId, InstrumentKey, LseMessage)) -> Self {
49 let LseMessage::Tick(tick) = message else {
50 // See the matching arm in the trade decoder: unreachable through the transformer, and
51 // an empty result is the only correct answer if it is ever reached anyway.
52 return Self(vec![]);
53 };
54
55 Self(vec![Ok(MarketEvent {
56 time_exchange: tick.time_exchange,
57 time_received: Utc::now(),
58 exchange,
59 instrument,
60 kind: OrderBookL1 {
61 // Must equal the event's `time_exchange` -- downstream state orders L1 updates on
62 // this field rather than on the event's, and a producer that lets the two diverge
63 // gets no error and no log.
64 last_update_time: tick.time_exchange,
65 best_bid: quoted(tick.bid),
66 best_ask: quoted(tick.ask),
67 },
68 })])
69 }
70}
71
72/// One side of the book, or `None` where the provider published no quote for it.
73///
74/// The size is a placeholder rather than a measurement — see the type-level note — so only the
75/// price can say whether the side exists at all. A side published as `null` is absent for the same
76/// reason a zero one is.
77fn quoted(price: Option<Decimal>) -> Option<Level> {
78 price
79 .filter(|price| !price.is_zero())
80 .map(|price| Level::new(price, Decimal::ZERO))
81}
82
83#[cfg(test)]
84#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
85mod tests {
86 use super::*;
87 use rust_decimal_macros::dec;
88
89 const TICK: &str = r#"{"type":"tick","symbol":"BTC/USD",
90 "ts":"2026-01-02T09:37:24.760146+00:00","price":42000.5,"bid":42000.5,
91 "ask":42001.0,"volume":0.00155}"#;
92
93 fn decode(json: &str) -> Vec<MarketEvent<u8, OrderBookL1>> {
94 let message: LseMessage = serde_json::from_str(json).unwrap();
95 MarketIter::<u8, OrderBookL1>::from((ExchangeId::LseCrypto, 1_u8, message))
96 .0
97 .into_iter()
98 .map(Result::unwrap)
99 .collect()
100 }
101
102 #[test]
103 fn a_tick_decodes_to_a_two_sided_book() {
104 let events = decode(TICK);
105
106 assert_eq!(events.len(), 1);
107 assert_eq!(events[0].kind.best_bid.unwrap().price, dec!(42000.5));
108 assert_eq!(events[0].kind.best_ask.unwrap().price, dec!(42001.0));
109 }
110
111 /// The producer obligation stated on the field: downstream state ranks L1 updates on
112 /// `last_update_time`, so a divergence from the event's own instant silently freezes the book.
113 #[test]
114 fn the_book_instant_equals_the_events_instant() {
115 let events = decode(TICK);
116 assert_eq!(events[0].kind.last_update_time, events[0].time_exchange);
117 }
118
119 /// The feed publishes no sizes. A fabricated one would be indistinguishable from a real one.
120 #[test]
121 fn both_levels_carry_a_zero_size_because_none_is_published() {
122 let events = decode(TICK);
123
124 assert_eq!(events[0].kind.best_bid.unwrap().amount, Decimal::ZERO);
125 assert_eq!(events[0].kind.best_ask.unwrap().amount, Decimal::ZERO);
126 }
127
128 /// The traded size is not resting liquidity, and on two venues it is a fabricated constant.
129 #[test]
130 fn the_tick_volume_is_not_used_as_a_level_size() {
131 let events = decode(TICK);
132 assert_ne!(events[0].kind.best_bid.unwrap().amount, dec!(0.00155));
133 }
134
135 fn one_sided(bid: &str, ask: &str) -> String {
136 format!(
137 r#"{{"type":"tick","symbol":"BTC/USD","ts":"2026-01-02T09:37:24.760146+00:00",
138 "price":{bid},"bid":{bid},"ask":{ask},"volume":0.0}}"#
139 )
140 }
141
142 /// The provider publishes `0.0` for a side it holds no quote for. Published as a level it
143 /// asserts someone will trade at zero, which no consumer can distinguish from a genuine quote.
144 #[test]
145 fn a_zero_priced_side_is_absent_rather_than_a_level_at_zero() {
146 let bid_only = decode(&one_sided("42000.5", "0.0"));
147 assert_eq!(bid_only[0].kind.best_bid.unwrap().price, dec!(42000.5));
148 assert_eq!(bid_only[0].kind.best_ask, None);
149
150 let ask_only = decode(&one_sided("0.0", "42001.0"));
151 assert_eq!(ask_only[0].kind.best_bid, None);
152 assert_eq!(ask_only[0].kind.best_ask.unwrap().price, dec!(42001.0));
153
154 let neither = decode(&one_sided("0.0", "0.0"));
155 assert_eq!(neither[0].kind.best_bid, None);
156 assert_eq!(neither[0].kind.best_ask, None);
157 }
158
159 /// Option contract ticks publish both sides as `null`. That is no quote, exactly as a zero is.
160 #[test]
161 fn a_null_side_is_absent() {
162 let events = decode(
163 r#"{"type":"tick","symbol":"TEST261231C00010500","ts":"2026-01-02T15:00:00+00:00",
164 "price":1.25,"bid":null,"ask":null,"volume":3,"name":"TEST $10.50 Call Dec 31"}"#,
165 );
166
167 assert_eq!(events[0].kind.best_bid, None);
168 assert_eq!(events[0].kind.best_ask, None);
169 }
170
171 /// Why the zero *price* is the dangerous one while the zero *sizes* are safe: the fallback that
172 /// rescues a size-less book is `mid_price`, which requires only that both sides be `Some` and
173 /// then averages whatever prices they carry. A zero bid published as a level would halve the
174 /// instrument's price, silently and unrecoverably.
175 #[test]
176 fn a_zero_priced_side_would_have_halved_the_mid_had_it_been_published() {
177 let events = decode(&one_sided("0.0", "42001.0"));
178 let book = &events[0].kind;
179
180 assert_eq!(
181 crate::books::mid_price(Decimal::ZERO, dec!(42001.0)),
182 dec!(21000.5),
183 "this is what a published zero bid would have priced the instrument at",
184 );
185
186 // Absent instead, so `mid_price` has nothing to average and no price reaches a consumer
187 // that was never quoted. A missing price is recoverable downstream; a halved one is not.
188 assert!(book.best_bid.is_none());
189 assert_eq!(book.mid_price(), None);
190 assert_eq!(book.volume_weighed_mid_price(), None);
191 }
192
193 #[test]
194 fn a_control_frame_yields_no_books() {
195 let events = decode(r#"{"type":"replay_complete","symbol":"BTC/USD","rows":41}"#);
196 assert!(events.is_empty());
197 }
198}