rustrade_data/exchange/lse/trade.rs
1//! Decode a London Strategic Edge tick as a [`PublicTrade`].
2
3use super::tick::LseMessage;
4use crate::{
5 event::{MarketEvent, MarketIter},
6 subscription::trade::PublicTrade,
7};
8use chrono::Utc;
9use rustrade_instrument::exchange::ExchangeId;
10use smol_str::SmolStr;
11
12/// Decode a tick frame as a [`PublicTrade`].
13///
14/// # ⚠️ The tick is a QUOTE — a `PublicTrade` from this feed may not be a print
15/// `price` equals `bid` exactly on every sample taken — 3,966 of 3,966 ticks spanning every dataset
16/// family, plus every sample from the provider's separate price endpoint. So the price here is a
17/// bid-side quote wearing a trade's shape, and its arrival is **not** evidence that a transaction
18/// occurred, at that price or at all. The mapping ships regardless, because saying so is more useful
19/// than withholding a feed whose sizes do reconcile; but a strategy that treats these as executions
20/// is reading one side of a quote as a fill.
21///
22/// # [`LseOptions`](super::LseOptions) is the exception: its trades ARE prints
23/// Option contract ticks reconcile one-for-one against the provider's REST option-print tape on
24/// `(price, volume)`, so there `price` is the traded premium per share and `amount` the number of
25/// contracts. They carry no quote at all — `bid` and `ask` are `null` on every one — which is why
26/// this is the only kind that venue serves.
27///
28/// # ⚠️ `amount` is genuine on some venues and FABRICATED on others
29/// This is per-dataset and there is no in-band signal separating the two:
30///
31/// - [`LseCrypto`](super::LseCrypto) and [`LseEquities`](super::LseEquities) carry a **real**
32/// per-tick size. Summing it across three whole minutes of one crypto symbol reproduced the
33/// provider's own one-minute candle volume to the last decimal, ratio `1.000`.
34/// - [`LseFx`](super::LseFx) and [`LseCfd`](super::LseCfd) carry a **hard-coded `1.0`** — measured
35/// on every tick of every FX and commodity symbol sampled, one distinct value across all of them.
36/// It is a placeholder, not a measurement. Because it is a plausible-looking positive number it
37/// will aggregate into a legitimate-looking total at any resolution, so volume-weighted prices,
38/// participation rates and size filters computed on these two venues are meaningless rather than
39/// merely imprecise. Note this also differs from the provider's REST vault, which *omits* FX
40/// volume entirely — the true absence; the WebSocket invents a value instead.
41///
42/// # ⚠️ Identical consecutive ticks are REAL and are both emitted
43/// This decoder performs no de-duplication, deliberately. See
44/// [`LseTick`](super::tick::LseTick) for the reconciliation that proves the repeats are distinct
45/// prints. The property is pinned by `identical_live_ticks_are_never_deduplicated` in
46/// [`transformer`](super::transformer), which is the only place a filter could be added: this
47/// impl is a stateless `From` and has nothing to remember a previous tick with.
48impl<InstrumentKey> From<(ExchangeId, InstrumentKey, LseMessage)>
49 for MarketIter<InstrumentKey, PublicTrade>
50{
51 fn from((exchange, instrument, message): (ExchangeId, InstrumentKey, LseMessage)) -> Self {
52 let LseMessage::Tick(tick) = message else {
53 // Control frames share the data stream with ticks. The transformer resolves a frame to
54 // its instrument before reaching here and drops anything unidentifiable, so this arm is
55 // unreachable through it -- but the conversion is total, and a control frame yielding
56 // nothing is the only answer that is correct if it ever is reached.
57 return Self(vec![]);
58 };
59
60 Self(vec![Ok(MarketEvent {
61 time_exchange: tick.time_exchange,
62 time_received: Utc::now(),
63 exchange,
64 instrument,
65 kind: PublicTrade {
66 // The feed publishes no trade identifier, and one cannot be synthesised: ticks are
67 // genuinely non-unique in `(ts, price, size)` -- one signature recurred 49 times in
68 // a sampled run -- so anything derived from those fields would collide across
69 // distinct prints. An empty id says "unidentified" rather than asserting a false
70 // one.
71 id: SmolStr::default(),
72 price: tick.price,
73 amount: tick.volume,
74 // No aggressor side is published, and it is not inferable: the price is the bid on
75 // every sample taken, which would make every tick look like a sell.
76 side: None,
77 },
78 })])
79 }
80}
81
82#[cfg(test)]
83#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
84mod tests {
85 use super::*;
86 use chrono::DateTime;
87 use rust_decimal_macros::dec;
88
89 fn tick_json(ts: &str, price: &str, volume: &str) -> String {
90 format!(
91 r#"{{"type":"tick","symbol":"BTC/USD","ts":"{ts}","price":{price},
92 "bid":{price},"ask":42001.0,"volume":{volume}}}"#
93 )
94 }
95
96 fn decode(json: &str) -> Vec<MarketEvent<u8, PublicTrade>> {
97 let message: LseMessage = serde_json::from_str(json).unwrap();
98 MarketIter::<u8, PublicTrade>::from((ExchangeId::LseCrypto, 1_u8, message))
99 .0
100 .into_iter()
101 .map(Result::unwrap)
102 .collect()
103 }
104
105 #[test]
106 fn a_tick_decodes_to_one_trade() {
107 let events = decode(&tick_json(
108 "2026-01-02T09:37:24.760146+00:00",
109 "42000.5",
110 "0.00155",
111 ));
112
113 assert_eq!(events.len(), 1);
114 assert_eq!(events[0].exchange, ExchangeId::LseCrypto);
115 assert_eq!(events[0].instrument, 1);
116 assert_eq!(
117 events[0].time_exchange,
118 "2026-01-02T09:37:24.760146Z"
119 .parse::<DateTime<Utc>>()
120 .unwrap()
121 );
122 assert_eq!(events[0].kind.price, dec!(42000.5));
123 assert_eq!(events[0].kind.amount, dec!(0.00155));
124 assert_eq!(events[0].kind.side, None);
125 }
126
127 /// Two identical frames decode to two identical events, which is what makes the repeats
128 /// indistinguishable downstream and therefore what a de-duplication filter would key on. The
129 /// filter itself could only live in the transformer, which is stateful — see
130 /// `identical_live_ticks_are_never_deduplicated` there for the property that pins its absence.
131 /// This decoder is a stateless `From`, so what it pins is narrower: the decode carries no
132 /// distinguishing mark that would let one of the two be dropped.
133 #[test]
134 fn identical_consecutive_ticks_decode_to_identical_events() {
135 let json = tick_json("2026-01-02T09:37:24.760146+00:00", "42000.5", "0.00155");
136
137 let first = decode(&json);
138 let second = decode(&json);
139
140 assert_eq!(first.len(), 1);
141 assert_eq!(second.len(), 1);
142 assert_eq!(first[0].time_exchange, second[0].time_exchange);
143 assert_eq!(first[0].kind, second[0].kind);
144 }
145
146 /// The empty id is forced rather than tidy — see the field comment.
147 #[test]
148 fn the_trade_id_is_empty_because_no_identifier_could_be_synthesised() {
149 let events = decode(&tick_json(
150 "2026-01-02T09:37:24.760146+00:00",
151 "42000.5",
152 "0.00155",
153 ));
154 assert!(events[0].kind.id.is_empty());
155 }
156
157 /// The fabricated FX size is passed through unaltered: rewriting it would replace one invented
158 /// number with another, and the warning belongs in the docs, not in the data.
159 #[test]
160 fn the_fabricated_fx_size_is_passed_through_rather_than_rewritten() {
161 let events = decode(&tick_json("2026-01-02 09:37:21.690159+00:00", "1.1", "1.0"));
162 assert_eq!(events[0].kind.amount, dec!(1.0));
163 }
164
165 #[test]
166 fn a_control_frame_yields_no_trades() {
167 let events = decode(r#"{"type":"replay_complete","symbol":"BTC/USD","rows":41}"#);
168 assert!(events.is_empty());
169 }
170}