Skip to main content

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}