rustrade_data/exchange/lse/subscription.rs
1//! What the provider answers a `subscribe` with.
2//!
3//! The replay window's own boundary frames are decoded on the stream instead, by
4//! [`LseMessage`](super::tick::LseMessage) — see
5//! [`ReplayStarted`](super::tick::LseMessage::ReplayStarted) for why the handshake is the wrong
6//! place to read them.
7
8use rustrade_integration::{Validator, error::SocketError};
9use serde::{Deserialize, Serialize};
10use smol_str::SmolStr;
11
12/// The provider's answer to a `subscribe` request.
13///
14/// One frame arrives per request — `{"action":"subscribe","symbol":"EUR/USD"}` is answered by
15/// `{"type":"subscribed","symbol":"EUR/USD","count":1,"max":16}`. The subscriber sends one request
16/// per distinct symbol, or on the options dataset per distinct underlying, and the connector's
17/// [`expected_responses`](crate::exchange::Connector::expected_responses) counts the same way.
18///
19/// # ⚠️ A confirmation is NOT evidence the symbol exists
20/// Subscribing to a symbol the provider has never heard of is **confirmed, not rejected**: it
21/// answers `subscribed`, never errors, never ticks, and permanently consumes one of the
22/// connection's subscription slots. Validation here therefore cannot catch a typo, and does not
23/// try to. That guard runs in the subscriber, against the symbol list the `authenticated` frame
24/// supplies, *before* any subscribe is sent — which is also what keeps a bad batch from spending
25/// slots it can never get back.
26///
27/// # Why replay frames are deliberately not modelled here
28/// A validator counts every frame that deserialises into this type and validates `Ok` as one more
29/// subscription confirmed — the connector's declared
30/// [`SubValidator`](crate::exchange::Connector::SubValidator) does. The shared
31/// [`connection`](super::connection) decodes a frame as this type only when its `type` names an
32/// answer, but the type must stay safe for any validator to count. A `replay_started` frame
33/// modelled as a success would inflate that count and end validation before the last real
34/// confirmation arrived. It is decoded on the stream instead, and reaches the transformer as stream
35/// data — the same route ticks take. For the same reason this enum has no catch-all
36/// variant: one would swallow ticks into the success count.
37#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
38#[serde(tag = "type", rename_all = "snake_case")]
39pub enum LseSubResponse {
40 /// A subscription was accepted, and the connection now holds `count` of its `max` slots.
41 ///
42 /// **No field is required to deserialise**, `symbol` included. All three are reported by the
43 /// provider and modelled for the reader, but nothing acts on any of them: a validator counts
44 /// confirmations rather than matching them to symbols, and the cap is enforced before a
45 /// subscribe is sent, against the `authenticated` frame. Requiring a field nothing reads would
46 /// let a rename or an omission upstream turn an arriving confirmation into a ten-second
47 /// validation timeout blaming latency — and `symbol` is not privileged here, because a
48 /// confirmation that cannot be matched to a request is worth exactly as much as one that can.
49 /// The same reasoning the [`Error`](Self::Error) variant is built on.
50 Subscribed {
51 /// The symbol, case-normalised by the provider (`eur/usd` is confirmed as `EUR/USD`).
52 #[serde(default)]
53 symbol: Option<SmolStr>,
54 /// Slots held on this connection after the subscription.
55 #[serde(default)]
56 count: Option<u32>,
57 /// Slots this connection may hold in total.
58 #[serde(default)]
59 max: Option<u32>,
60 },
61
62 /// An underlying's option chain was subscribed, and the connection now holds `count` of its
63 /// `max` slots.
64 ///
65 /// Answers `{"action":"subscribe_options","underlying":"SPY"}` with
66 /// `{"type":"options_subscribed","underlying":"SPY","contracts":10649,"active_underlyings":1,
67 /// "count":1,"max":100}`. One arrives per subscribe, including a repeated one, which is why the
68 /// subscriber sends each underlying once.
69 ///
70 /// No field is required, for the reason given on [`Subscribed`](Self::Subscribed).
71 OptionsSubscribed {
72 /// The underlying, case-normalised by the provider (`spy` is confirmed as `SPY`).
73 #[serde(default)]
74 underlying: Option<SmolStr>,
75 /// Contracts in the underlying's chain. Advisory: it drifts within a session, and says
76 /// nothing about whether any registered contract is among them.
77 #[serde(default)]
78 contracts: Option<u32>,
79 /// Slots held on this connection after the subscription.
80 #[serde(default)]
81 count: Option<u32>,
82 /// Slots this connection may hold in total.
83 #[serde(default)]
84 max: Option<u32>,
85 },
86
87 /// A subscription was rejected.
88 ///
89 /// Both fields are optional so that a rejection missing one cannot fail to deserialise and be
90 /// silently reclassified as stream data — an error frame must never be the thing that gets
91 /// swallowed.
92 Error {
93 /// The provider's error code. `LIMIT_REACHED`, `INVALID_START` and — for an option
94 /// underlying with no options — `INVALID_UNDERLYING` are those observed;
95 /// the field is kept as text rather than an enum so an unrecognised code reaches the
96 /// caller intact instead of collapsing into a catch-all.
97 #[serde(default)]
98 code: Option<SmolStr>,
99 /// The provider's own diagnostic.
100 #[serde(default)]
101 message: Option<SmolStr>,
102 },
103}
104
105impl Validator for LseSubResponse {
106 type Error = SocketError;
107
108 fn validate(self) -> Result<Self, SocketError> {
109 let Self::Error { code, message } = &self else {
110 return Ok(self);
111 };
112
113 let code = code.as_deref().unwrap_or("unknown");
114 let message = message.as_deref().unwrap_or("no message");
115
116 // A cap rejection does not name the symbol it rejected -- measured: 20 subscriptions
117 // requested against a cap of 16 produced 16 confirmations and 4 anonymous errors. There is
118 // therefore no partial recovery available, and continuing would leave the caller holding a
119 // subscription set the provider silently truncated. An `INVALID_UNDERLYING` does name its
120 // underlying, in the message quoted below, but failing the batch over it keeps one rule.
121 Err(SocketError::Subscribe(format!(
122 "London Strategic Edge rejected a subscription ({code}): {message} - a rejection \
123 need not name what it rejected, so the whole batch fails"
124 )))
125 }
126}
127
128#[cfg(test)]
129#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
130mod tests {
131 use super::*;
132
133 #[test]
134 fn a_subscription_confirmation_deserialises_and_validates() {
135 let input = r#"{"type":"subscribed","symbol":"EUR/USD","count":1,"max":16}"#;
136 let response: LseSubResponse = serde_json::from_str(input).unwrap();
137
138 assert_eq!(
139 response,
140 LseSubResponse::Subscribed {
141 symbol: Some("EUR/USD".into()),
142 count: Some(1),
143 max: Some(16),
144 }
145 );
146 assert!(response.validate().is_ok());
147 }
148
149 #[test]
150 fn a_subscription_rejection_fails_validation() {
151 let input = r#"{"type":"error","code":"LIMIT_REACHED",
152 "message":"Max 16 symbols on registered plan"}"#;
153 let response: LseSubResponse = serde_json::from_str(input).unwrap();
154
155 let error = response.validate().unwrap_err().to_string();
156 assert!(error.contains("LIMIT_REACHED"), "{error}");
157 assert!(error.contains("Max 16 symbols"), "{error}");
158 }
159
160 #[test]
161 fn an_option_chain_confirmation_deserialises_and_validates() {
162 let input = r#"{"type":"options_subscribed","underlying":"SPY","contracts":10649,
163 "active_underlyings":1,"count":1,"max":100}"#;
164 let response: LseSubResponse = serde_json::from_str(input).unwrap();
165
166 assert_eq!(
167 response,
168 LseSubResponse::OptionsSubscribed {
169 underlying: Some("SPY".into()),
170 contracts: Some(10649),
171 count: Some(1),
172 max: Some(100),
173 }
174 );
175 assert!(response.validate().is_ok());
176 }
177
178 /// The same leniency a plain confirmation gets: the tag alone confirms.
179 #[test]
180 fn an_option_chain_confirmation_missing_its_fields_still_confirms() {
181 let response: LseSubResponse =
182 serde_json::from_str(r#"{"type":"options_subscribed"}"#).unwrap();
183 assert!(response.validate().is_ok());
184 }
185
186 /// An underlying with no options is rejected by name, unlike an unknown symbol, which is
187 /// confirmed.
188 #[test]
189 fn an_underlying_with_no_options_fails_validation_naming_it() {
190 let input = r#"{"type":"error","code":"INVALID_UNDERLYING",
191 "message":"No options available for NOPE"}"#;
192 let response: LseSubResponse = serde_json::from_str(input).unwrap();
193
194 let error = response.validate().unwrap_err().to_string();
195 assert!(error.contains("INVALID_UNDERLYING"), "{error}");
196 assert!(error.contains("NOPE"), "{error}");
197 }
198
199 #[test]
200 fn a_rejected_replay_start_fails_validation() {
201 let input = r#"{"type":"error","code":"INVALID_START",
202 "message":"Invalid start: could not convert string to float"}"#;
203 let response: LseSubResponse = serde_json::from_str(input).unwrap();
204
205 assert!(response.validate().is_err());
206 }
207
208 /// An error frame that fails to deserialise would be reclassified as stream data and silently
209 /// dropped, turning a loud rejection into no signal at all.
210 #[test]
211 fn a_rejection_missing_its_fields_still_fails_validation() {
212 let response: LseSubResponse = serde_json::from_str(r#"{"type":"error"}"#).unwrap();
213 assert!(response.validate().is_err());
214 }
215
216 /// The subscription validator counts everything that deserialises into [`LseSubResponse`] as a
217 /// confirmation. Ticks and replay frames must therefore NOT deserialise into it — they take
218 /// the buffered-events path instead.
219 #[test]
220 fn stream_frames_do_not_deserialise_as_subscription_responses() {
221 for input in [
222 r#"{"type":"tick","symbol":"EUR/USD","ts":"2026-01-02T09:37:24.760146+00:00",
223 "price":1.1,"bid":1.1,"ask":1.1001,"volume":1.0}"#,
224 r#"{"type":"replay_started","symbol":"BTC/USD","from":"2026-01-02T09:39:31+00:00"}"#,
225 r#"{"type":"replay_complete","symbol":"BTC/USD","rows":41,"buffered_drained":9}"#,
226 r#"{"type":"tick","symbol":"TEST261231C00010500","ts":"2026-01-02T15:00:00+00:00",
227 "price":1.25,"bid":null,"ask":null,"volume":3,"name":"TEST $10.50 Call Dec 31"}"#,
228 ] {
229 assert!(
230 serde_json::from_str::<LseSubResponse>(input).is_err(),
231 "deserialised as a subscription response: {input}"
232 );
233 }
234 }
235
236 /// A confirmation that failed to deserialise would fall through to the buffered-events path and
237 /// leave the validator waiting out its ten-second timeout for a frame that had already arrived
238 /// — a failure that reads as latency. Nothing consumes any of these fields, `symbol` included,
239 /// so nothing is worth that.
240 #[test]
241 fn a_confirmation_missing_the_fields_nothing_reads_still_confirms() {
242 let response: LseSubResponse =
243 serde_json::from_str(r#"{"type":"subscribed","symbol":"EUR/USD"}"#).unwrap();
244
245 assert_eq!(
246 response,
247 LseSubResponse::Subscribed {
248 symbol: Some("EUR/USD".into()),
249 count: None,
250 max: None,
251 }
252 );
253 assert!(response.validate().is_ok());
254
255 // The tag alone is enough. The validator counts confirmations rather than matching them to
256 // requests, so a confirmation naming nothing still confirms one.
257 let bare: LseSubResponse = serde_json::from_str(r#"{"type":"subscribed"}"#).unwrap();
258
259 assert_eq!(
260 bare,
261 LseSubResponse::Subscribed {
262 symbol: None,
263 count: None,
264 max: None,
265 }
266 );
267 assert!(bare.validate().is_ok());
268 }
269}