Skip to main content

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}