rustrade_data/exchange/lse/live.rs
1//! Live WebSocket subscription: authentication, the pre-subscribe guards, and the subscribe flow.
2
3use super::{
4 connection::{AttachRequest, LseAttachment, LseConnection},
5 mapper::LseSubMapper,
6 market::LseDataset,
7 resume::{LseResumeState, epoch_seconds},
8};
9use crate::exchange::lse::transport::api_key_from_env;
10use crate::exchange::osi;
11use crate::{
12 Identifier,
13 exchange::Connector,
14 instrument::InstrumentData,
15 subscriber::{Subscribed, Subscriber, mapper::SubscriptionMapper},
16 subscription::{Subscription, SubscriptionKind, SubscriptionMeta},
17};
18use chrono::{DateTime, Utc};
19use fnv::{FnvHashMap, FnvHashSet};
20use futures::{SinkExt, StreamExt};
21use rustrade_instrument::exchange::ExchangeId;
22use rustrade_integration::{
23 error::SocketError,
24 protocol::websocket::{WebSocket, WsMessage},
25};
26use serde::{Deserialize, Serialize};
27use serde_json::json;
28use smol_str::SmolStr;
29use std::{fmt, sync::Arc, time::Duration};
30use tracing::{debug, warn};
31
32/// How long to wait for the `authenticated` frame.
33///
34/// Generous because that frame is not small — it enumerates every symbol the key may subscribe to,
35/// which was over eight thousand entries when measured.
36const AUTH_TIMEOUT: Duration = Duration::from_secs(15);
37
38/// The API key the WebSocket authenticates with.
39///
40/// `Debug` is implemented manually to redact the key: subscribers are routinely logged with `?`,
41/// and a credential that reaches a log line has effectively been disclosed.
42#[derive(Clone)]
43pub struct LseCredentials {
44 api_key: String,
45}
46
47impl fmt::Debug for LseCredentials {
48 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49 f.debug_struct("LseCredentials")
50 .field("api_key", &"[REDACTED]")
51 .finish()
52 }
53}
54
55impl LseCredentials {
56 /// Construct credentials from an explicit key.
57 pub fn new(api_key: impl Into<String>) -> Self {
58 Self {
59 api_key: api_key.into(),
60 }
61 }
62
63 /// Construct credentials from the `LSE_API_KEY` environment variable.
64 ///
65 /// # Errors
66 /// Returns [`SocketError::Subscribe`] if the variable is unset or does not hold valid UTF-8.
67 /// The message names the variable and never its value; the REST clients read the same variable
68 /// through the same helper, so the redaction cannot drift between the two surfaces.
69 pub fn from_env() -> Result<Self, SocketError> {
70 let api_key = api_key_from_env().map_err(SocketError::Subscribe)?;
71
72 Ok(Self::new(api_key))
73 }
74}
75
76/// The London Strategic Edge WebSocket subscriber.
77///
78/// # Why this provider needs a subscriber of its own
79/// Authentication is a **message**, not a header, and subscriptions are only accepted after the
80/// server answers it — so [`Connector::requests`] alone cannot express the flow.
81///
82/// The handshake also produces something the guards below need: the `authenticated` frame
83/// enumerates every symbol the key may subscribe to. That list is the only defence against this
84/// surface's quietest failure — see [`Self::subscribe`].
85///
86/// # ⚠️ One connection per key: clone the subscriber, do not build a second one
87/// The provider allows a key **one** WebSocket, so every stream this subscriber and its clones open
88/// shares one socket — whatever the dataset, the kind or the option underlying, and across every
89/// `subscribe` call. [`StreamBuilder::subscribe`](crate::streams::builder::StreamBuilder::subscribe)
90/// clones the subscriber it is given, so passing one subscriber, or clones of it, to every call is
91/// all sharing takes. See [`connection`](super::connection) for how the socket is shared, how a
92/// reconnect is coordinated across the streams on it, and what the shared subscription cap means
93/// for a batch.
94///
95/// A subscriber built separately — a second [`LseSubscriber::new`] or [`LseSubscriber::from_env`]
96/// for the same key — opens a socket of its own, and the provider refuses it with
97/// `TOO_MANY_CONNECTIONS`.
98///
99/// # Example
100/// ```ignore
101/// use rustrade_data::exchange::lse::{LseCrypto, live::LseSubscriber};
102/// use rustrade_data::streams::Streams;
103/// use rustrade_data::subscription::trade::PublicTrades;
104/// use rustrade_instrument::instrument::market_data::kind::MarketDataInstrumentKind;
105///
106/// let subscriber = LseSubscriber::from_env()?;
107///
108/// let streams = Streams::<PublicTrades>::builder()
109/// .subscribe(subscriber, [(
110/// LseCrypto::default(), "btc", "usd", MarketDataInstrumentKind::Spot, PublicTrades,
111/// )])
112/// .init()
113/// .await?;
114/// ```
115#[derive(Clone, Debug)]
116pub struct LseSubscriber {
117 connection: Arc<LseConnection>,
118 resume: Option<Arc<LseResumeState>>,
119}
120
121impl LseSubscriber {
122 /// Construct a subscriber with the provided credentials, without resumption.
123 ///
124 /// Each call opens a connection of its own when first used; clone the result to share it.
125 pub fn new(credentials: LseCredentials) -> Self {
126 Self {
127 connection: Arc::new(LseConnection::new(credentials)),
128 resume: None,
129 }
130 }
131
132 /// Construct a subscriber from the `LSE_API_KEY` environment variable.
133 ///
134 /// # Errors
135 /// See [`LseCredentials::from_env`].
136 pub fn from_env() -> Result<Self, SocketError> {
137 Ok(Self::new(LseCredentials::from_env()?))
138 }
139
140 /// Resume from the last delivered event when a reconnect re-subscribes.
141 ///
142 /// Every stream opened by this subscriber shares `state`, and the same subscriber instance is
143 /// cloned into each reconnect attempt, so the watermark it accumulates survives the
144 /// reconnect that consults it.
145 ///
146 /// # ⚠️ Replay is not free, which is why this is opt-in
147 /// A resumed subscription is served its historical window before a single live tick. That
148 /// window is large: one hour of a single busy crypto symbol replayed **107,395 ticks** before
149 /// going live, and a 24-hour window had not drained after 30 seconds. A consumer that must
150 /// stay current will prefer the gap; a consumer recording a continuous tape will prefer the
151 /// replay. Choosing between those is the caller's business, so the default is off.
152 ///
153 /// # ⚠️ The provider retains only 24 hours, and clamps silently
154 /// A resume point older than that is **moved forward with no error**. The stream compares what
155 /// the provider says it will replay from against what was asked for and warns on a mismatch;
156 /// see [`LseMessage::ReplayStarted`](super::tick::LseMessage::ReplayStarted).
157 ///
158 /// # ⚠️ One state per duplicated subscription
159 /// Watermarks are keyed by dataset, subscription **and** subscription kind, so one state serves
160 /// any set of streams that differ in any of those three. Two streams that duplicate all three
161 /// share one watermark and advance it independently, which resumes the trailing one past events
162 /// it never delivered — see [`LseResumeState`] for the obligation in full.
163 ///
164 /// # ⚠️ The replayed prefix is skipped by position, not by the `replay` flag
165 /// The provider re-serves every tick sharing the resume instant, and the ones already delivered
166 /// are dropped by counting rather than by trusting each tick's `replay` stamp — a provider that
167 /// stopped stamping them would otherwise turn every reconnect into a duplicate flood. The cost
168 /// is a narrow one: if the replay under-delivers at that instant, a genuinely *live* tick
169 /// arriving there while the count is still open is dropped as though it were one of the missing
170 /// duplicates. It requires the whole reconnect to land inside one timestamp quantum of the
171 /// resume instant, so it is unlikely rather than impossible, and it is reported by a `warn!`
172 /// naming the subscription and the instant rather than passing silently.
173 ///
174 /// # Streams sharing a symbol share its replay window
175 /// Streams on one connection that hold one symbol — two kinds, or two datasets spelling it
176 /// alike — hold one provider subscription to it, and the provider opens one replay window per
177 /// subscription. A reconnect therefore opens each symbol's window at the **earliest** watermark
178 /// among the streams holding it, and every stream drops, silently, the replayed ticks before its
179 /// own watermark: it delivered them before the connection was lost. Replayed ticks reach only
180 /// the streams that resume that symbol. See [`connection`](super::connection).
181 ///
182 /// A clone made *before* this call shares the connection but not the state. That is supported —
183 /// each stream resumes from its own subscriber's state — but it is rarely what was meant.
184 ///
185 /// Covers reconnects, not process restarts; see [`LseResumeState`].
186 #[must_use]
187 pub fn with_resume(mut self, state: Arc<LseResumeState>) -> Self {
188 self.resume = Some(state);
189 self
190 }
191
192 /// How many handles share this subscriber's connection: this subscriber and each clone of it.
193 #[cfg(test)]
194 pub(crate) fn connection_handles(&self) -> usize {
195 Arc::strong_count(&self.connection)
196 }
197}
198
199impl Subscriber for LseSubscriber {
200 type SubMapper = LseSubMapper;
201 type Transport = LseAttachment;
202
203 /// Attach the batch to this subscriber's shared connection — connecting and authenticating
204 /// first if nothing is attached yet — check it, then subscribe whatever the connection does not
205 /// already hold.
206 ///
207 /// # ⚠️ Every guard runs before the first subscribe leaves the client, deliberately
208 /// This surface confirms a subscription to a symbol it has never heard of: it answers
209 /// `subscribed`, never errors, never ticks, **and holds one of the connection's slots** until
210 /// it is unsubscribed — which nothing would prompt, since a confirmation is all the client ever
211 /// sees. So the requested symbols are checked against the list the `authenticated` frame
212 /// supplies *first*, and a batch that fails costs nothing.
213 ///
214 /// Two checks run over that list:
215 /// - **Membership is enforced.** A symbol the provider does not offer fails the batch.
216 /// - **The category is only cross-checked when the provider supplies a label this integration
217 /// recognises.** Roughly half the published entries carry no `category` key at all — and some
218 /// carry neither `category` nor `name` — so absence is normal and can never be treated as a
219 /// mismatch. Neither can a label we do not know: only one we recognise as belonging to a
220 /// *different* dataset is evidence of anything.
221 ///
222 /// The subscription cap is enforced in the same pass, because an over-cap batch is rejected
223 /// *anonymously*: the provider's rejection does not name the symbol it refused, leaving no
224 /// partial recovery. Failing the whole batch before sending is the only outcome that does not
225 /// leave the caller guessing which subscriptions survived.
226 ///
227 /// # ⚠️ The cap is the connection's, not the batch's
228 /// Every stream sharing the connection draws on one subscription cap, so a batch is checked
229 /// against what the connection would hold with it: the symbols and underlyings already held by
230 /// other streams count, and a symbol one of them already holds costs nothing more. A batch that
231 /// fits on its own can therefore be refused here.
232 ///
233 /// Should the provider reject a subscribe anyway, the batch fails and whatever it sent is
234 /// unsubscribed again, so a failed batch still holds no slot.
235 ///
236 /// # Option contracts subscribe per underlying
237 /// On [`LseOptions`](super::LseOptions) one subscribe covers an underlying's whole chain, so the
238 /// batch is reduced to its distinct underlyings: that is what is sent, what the cap counts, and
239 /// what the provider confirms. Every contract must spell an OSI symbol, checked before
240 /// connecting. The offered-symbol check does not apply: the handshake's list does not hold every
241 /// underlying that has options (index roots are absent from it), and an underlying with no
242 /// options is rejected by the provider **by name** rather than confirmed.
243 ///
244 /// # Errors
245 /// Returns [`SocketError::Subscribe`] if the batch is empty, the key is rejected, the handshake
246 /// times out, the batch would take the connection over its subscription cap, any requested
247 /// symbol is not offered, or — on the options dataset — any contract has no OSI symbol or names
248 /// an underlying with no options.
249 async fn subscribe<Exchange, Instrument, Kind>(
250 &self,
251 subscriptions: &[Subscription<Exchange, Instrument, Kind>],
252 ) -> Result<Subscribed<Instrument::Key, Self::Transport>, SocketError>
253 where
254 Exchange: Connector + Send + Sync,
255 Kind: SubscriptionKind + Send + Sync,
256 Instrument: InstrumentData,
257 Subscription<Exchange, Instrument, Kind>:
258 Identifier<Exchange::Channel> + Identifier<Exchange::Market>,
259 {
260 let exchange = Exchange::ID;
261 let url = Exchange::url()?;
262 debug!(%exchange, %url, ?subscriptions, "subscribing to London Strategic Edge WebSocket");
263
264 // Every subscription in a batch shares one `Kind`, and a watermark is filed under the
265 // dataset and the kind it was delivered for -- see `LseResumeKey`.
266 let Some(kind) = subscriptions
267 .first()
268 .map(|subscription| subscription.kind.as_str())
269 else {
270 return Err(SocketError::Subscribe(format!(
271 "no subscriptions were given to subscribe to on {exchange}"
272 )));
273 };
274
275 let markets = requested_markets::<Exchange, Instrument, Kind>(subscriptions);
276
277 // Checked before attaching: a contract with no OSI symbol needs no socket to reject.
278 let underlyings = subscribes_per_underlying(exchange)
279 .then(|| option_underlyings(exchange, &markets))
280 .transpose()?;
281
282 if underlyings.is_some() && self.resume.is_some() {
283 warn!(
284 %exchange,
285 "London Strategic Edge option contracts do not resume; this stream will not \
286 replay what a reconnect missed",
287 );
288 }
289
290 // Only the instrument map is taken from the mapper. The subscribe payloads are
291 // built by the connection instead, because it alone knows what the socket already holds
292 // and which replay window each symbol needs -- `Connector::requests` is a static function
293 // with access to neither. Both routes build their payloads with `subscribe_message`, so
294 // they cannot drift apart.
295 let SubscriptionMeta {
296 instrument_map,
297 ws_subscriptions: _,
298 } = Self::SubMapper::map::<Exchange, Instrument, Kind>(subscriptions);
299
300 let transport = self
301 .connection
302 .attach(AttachRequest {
303 exchange,
304 url,
305 kind,
306 markets,
307 underlyings,
308 timeout: Exchange::subscription_timeout(),
309 resume: self.resume.clone(),
310 })
311 .await?;
312
313 debug!(%exchange, "attached to the London Strategic Edge connection");
314 Ok(Subscribed {
315 transport,
316 map: instrument_map,
317 // The connection routes every frame for the batch into `transport` from the moment it
318 // is registered, so nothing is read ahead of it.
319 buffered_websocket_events: Vec::new(),
320 })
321 }
322}
323
324/// Build the subscribe payload for one symbol, optionally resuming from `start`.
325///
326/// Shared with [`Connector::requests`] so the two routes into this protocol cannot disagree about
327/// its shape. That route never resumes — it has no access to per-symbol state — and passes `None`.
328///
329/// `start` is sent as epoch seconds, the only spelling that can express a sub-second resume point;
330/// see [`epoch_seconds`] for why.
331pub(super) fn subscribe_message(symbol: &str, start: Option<DateTime<Utc>>) -> WsMessage {
332 let payload = match start {
333 Some(start) => json!({
334 "action": "subscribe",
335 "symbol": symbol,
336 "start": epoch_seconds(start),
337 }),
338 None => json!({ "action": "subscribe", "symbol": symbol }),
339 };
340
341 WsMessage::text(payload.to_string())
342}
343
344/// Build the subscribe payload for every option contract on `underlying`.
345///
346/// Carries no replay window: whether the provider honours one on this channel is unestablished.
347pub(super) fn subscribe_options_message(underlying: &str) -> WsMessage {
348 WsMessage::text(json!({ "action": "subscribe_options", "underlying": underlying }).to_string())
349}
350
351/// Build the payload releasing one symbol's subscription and its slot.
352///
353/// Answered by an `unsubscribed` frame, and ticks stop at once.
354pub(super) fn unsubscribe_message(symbol: &str) -> WsMessage {
355 WsMessage::text(json!({ "action": "unsubscribe", "symbol": symbol }).to_string())
356}
357
358/// Build the payload releasing an underlying's option chain and its slot.
359///
360/// Answered by an `options_unsubscribed` frame.
361pub(super) fn unsubscribe_options_message(underlying: &str) -> WsMessage {
362 WsMessage::text(
363 json!({ "action": "unsubscribe_options", "underlying": underlying }).to_string(),
364 )
365}
366
367/// Whether `exchange` subscribes per option underlying rather than per symbol.
368///
369/// The one place that decision is made. The subscriber, the confirmation count, the transformer and
370/// the stream all ask it, so they cannot disagree about which dataset fans out.
371pub(super) fn subscribes_per_underlying(exchange: ExchangeId) -> bool {
372 exchange == ExchangeId::LseOptions
373}
374
375/// The distinct underlyings a batch of option contracts subscribes to, in the order requested.
376///
377/// # Errors
378/// Returns [`SocketError::Subscribe`] naming every requested symbol that is not an OSI contract
379/// symbol — an instrument that is not an option, or whose strike OSI cannot carry.
380pub(super) fn option_underlyings(
381 exchange: ExchangeId,
382 markets: &[SmolStr],
383) -> Result<Vec<SmolStr>, SocketError> {
384 let mut seen = FnvHashSet::default();
385 let mut underlyings = Vec::<SmolStr>::new();
386 let mut unspellable = Vec::new();
387
388 for market in markets {
389 match osi::root(market) {
390 Some(root) => {
391 if seen.insert(root) {
392 underlyings.push(SmolStr::new(root));
393 }
394 }
395 None => unspellable.push(market.as_str()),
396 }
397 }
398
399 if !unspellable.is_empty() {
400 return Err(SocketError::Subscribe(format!(
401 "{exchange} subscribes option contracts by OSI symbol (root, YYMMDD, C or P, strike in \
402 thousandths as eight digits), and {unspellable:?} have none - each must be an option \
403 instrument whose strike is positive, has at most three decimal places and is below \
404 100,000",
405 )));
406 }
407
408 Ok(underlyings)
409}
410
411/// The distinct symbols a batch will subscribe to, in the order they were requested.
412///
413/// Two subscriptions naming one symbol are one slot and one confirmation, and the instrument map
414/// is keyed the same way — so sending a payload per *subscription* would over-count against the
415/// cap and leave the handshake waiting for a confirmation that never comes.
416///
417/// The batch is not bounded by the subscription cap: on the options dataset each entry is a
418/// *contract*, and one underlying can carry thousands of them into a single slot. Hence the
419/// seen-set rather than a linear scan, which would make a large option batch quadratic.
420fn requested_markets<Exchange, Instrument, Kind>(
421 subscriptions: &[Subscription<Exchange, Instrument, Kind>],
422) -> Vec<SmolStr>
423where
424 Exchange: Connector,
425 Subscription<Exchange, Instrument, Kind>: Identifier<Exchange::Market>,
426{
427 let mut seen =
428 FnvHashSet::<SmolStr>::with_capacity_and_hasher(subscriptions.len(), Default::default());
429 let mut markets = Vec::<SmolStr>::with_capacity(subscriptions.len());
430
431 for subscription in subscriptions {
432 let market = Identifier::<Exchange::Market>::id(subscription);
433 let symbol = SmolStr::new(market.as_ref());
434
435 if seen.insert(symbol.clone()) {
436 markets.push(symbol);
437 }
438 }
439
440 markets
441}
442
443/// Reject a batch that would take the connection past what it may hold.
444///
445/// `added` counts the slots the batch needs that the connection does not hold yet — distinct
446/// symbols, or on the options dataset distinct underlyings — and `held` the slots other streams on
447/// the connection already hold. The cap is the connection's, so both count against it.
448///
449/// # Errors
450/// Returns [`SocketError::Subscribe`] if `held + added` exceeds `max_subscriptions`.
451pub(super) fn check_subscription_cap(
452 exchange: ExchangeId,
453 added: usize,
454 held: usize,
455 max_subscriptions: Option<u32>,
456) -> Result<(), SocketError> {
457 let Some(max) = max_subscriptions else {
458 // Refusing here would break any tier whose cap this integration cannot read, and the
459 // provider still rejects an over-subscription observably. Proceed, but say so.
460 warn!(
461 %exchange,
462 "London Strategic Edge reported no subscription cap; the batch cannot be checked \
463 before it is sent",
464 );
465 return Ok(());
466 };
467
468 let cap = usize::try_from(max).unwrap_or(usize::MAX);
469 if held.saturating_add(added) > cap {
470 let unit = if subscribes_per_underlying(exchange) {
471 "option underlyings"
472 } else {
473 "symbols"
474 };
475
476 return Err(SocketError::Subscribe(format!(
477 "{added} {unit} requested on {exchange} beyond the {held} subscriptions this \
478 connection already holds, but it holds at most {cap} - the cap is shared by every \
479 stream opened by this subscriber and its clones, and the provider's rejection does \
480 not name the subscriptions it refuses, so there is no partial subscription to recover \
481 and the batch is rejected before it is sent",
482 )));
483 }
484
485 Ok(())
486}
487
488/// The handshake's symbol list, indexed once per connection for the per-batch guard.
489///
490/// The list ran to over eight thousand entries when measured, and every batch attached to a
491/// connection is checked against it.
492#[derive(Debug, Default)]
493pub(super) struct OfferedSymbols(FnvHashMap<SmolStr, Option<SmolStr>>);
494
495impl OfferedSymbols {
496 /// Index the `authenticated` frame's symbols by spelling, keeping each one's category.
497 pub(super) fn new(symbols: Vec<LseSymbol>) -> Self {
498 Self(
499 symbols
500 .into_iter()
501 .map(|entry| (entry.symbol, entry.category))
502 .collect(),
503 )
504 }
505}
506
507/// Check every requested symbol against the list the handshake published.
508///
509/// # Errors
510/// Returns [`SocketError::Subscribe`] if a symbol is not offered, or if one is offered under a
511/// category that contradicts the dataset being subscribed on.
512pub(super) fn check_symbols_are_offered(
513 exchange: ExchangeId,
514 markets: &[SmolStr],
515 offered: &OfferedSymbols,
516) -> Result<(), SocketError> {
517 if offered.0.is_empty() {
518 warn!(
519 %exchange,
520 "London Strategic Edge published no symbol list; a typo'd symbol will be confirmed and \
521 then never tick",
522 );
523 return Ok(());
524 }
525
526 let expected = expected_categories(exchange);
527
528 let mut unknown = Vec::new();
529 let mut miscategorised = Vec::new();
530
531 for market in markets {
532 match offered.0.get(market.as_str()) {
533 None => unknown.push(market.as_str()),
534 Some(category) => {
535 if let Some(category) = category.as_deref()
536 && !expected.is_empty()
537 && !expected.contains(&category)
538 && is_published_category(category)
539 {
540 miscategorised.push(format!("{market} is {category}"));
541 }
542 }
543 }
544 }
545
546 if !unknown.is_empty() {
547 return Err(SocketError::Subscribe(format!(
548 "London Strategic Edge does not offer {unknown:?} on its WebSocket - a symbol may be \
549 reachable through the catalog or the candle store and still be absent here, and \
550 subscribing to one the provider does not offer is CONFIRMED rather than rejected, \
551 then never ticks, while holding a subscription slot for the life of the connection",
552 )));
553 }
554
555 if !miscategorised.is_empty() {
556 return Err(SocketError::Subscribe(format!(
557 "London Strategic Edge categorises {miscategorised:?}, which does not match {exchange} \
558 (expected {expected:?}) - the symbol exists, but on a different dataset than the one \
559 being subscribed",
560 )));
561 }
562
563 Ok(())
564}
565
566/// The categories the handshake may report for symbols belonging to `exchange`.
567///
568/// Empty means this integration has no expectation to check against — the provider publishes no
569/// category for the datasets behind that identifier, so any category it did report would be
570/// unrecognised rather than wrong.
571fn expected_categories(exchange: ExchangeId) -> Vec<&'static str> {
572 LseDataset::ALL
573 .into_iter()
574 .filter(|dataset| dataset.exchange_id() == exchange)
575 .filter_map(|dataset| dataset.ws_category())
576 .collect()
577}
578
579/// Whether `category` is a label this integration knows some dataset by.
580///
581/// # Why an unrecognised label can never be a contradiction
582/// [`expected_categories`] is only as complete as what the provider publishes *today*, and it is
583/// complete for no better reason than that the provider has not labelled more. Five datasets stand
584/// behind [`ExchangeId::LseCfd`] and only two of them are labelled, so the day the provider begins
585/// publishing a category for one of the other three — its published list moved by hundreds of
586/// entries in a week when measured — a correctly-requested symbol would read as belonging to a
587/// different dataset and fail the whole batch, every other symbol in it included, with no change on
588/// our side.
589///
590/// A label we do not recognise says nothing about which dataset a symbol belongs to. Only one we
591/// recognise as belonging somewhere *else* is evidence, and that is the only case this admits.
592fn is_published_category(category: &str) -> bool {
593 LseDataset::ALL
594 .into_iter()
595 .any(|dataset| dataset.ws_category() == Some(category))
596}
597
598/// Authenticate, and return the frame the provider answers with.
599pub(super) async fn authenticate(
600 websocket: &mut WebSocket,
601 credentials: &LseCredentials,
602) -> Result<LseAuthenticated, SocketError> {
603 let auth = json!({ "action": "auth", "api_key": credentials.api_key }).to_string();
604
605 websocket
606 .send(WsMessage::text(auth))
607 .await
608 .map_err(|error| SocketError::WebSocket(Box::new(error)))?;
609
610 tokio::time::timeout(AUTH_TIMEOUT, async {
611 loop {
612 match websocket.next().await {
613 Some(Ok(WsMessage::Text(text))) => {
614 if let Some(result) = read_auth_frame(text.as_str()) {
615 return result;
616 }
617 }
618 Some(Ok(WsMessage::Binary(bytes))) => {
619 if let Ok(text) = std::str::from_utf8(&bytes)
620 && let Some(result) = read_auth_frame(text)
621 {
622 return result;
623 }
624 }
625 Some(Ok(WsMessage::Close(frame))) => {
626 return Err(SocketError::Subscribe(format!(
627 "WebSocket closed during London Strategic Edge authentication: {frame:?}"
628 )));
629 }
630 Some(Ok(_)) => {}
631 Some(Err(error)) => return Err(SocketError::WebSocket(Box::new(error))),
632 None => {
633 return Err(SocketError::Subscribe(
634 "WebSocket closed before London Strategic Edge authentication completed"
635 .to_owned(),
636 ));
637 }
638 }
639 }
640 })
641 .await
642 .map_err(|_| {
643 SocketError::Subscribe(format!(
644 "London Strategic Edge authentication timed out after {AUTH_TIMEOUT:?}"
645 ))
646 })?
647}
648
649/// Classify one frame received while awaiting authentication.
650///
651/// `None` means "not an answer to the auth request" — the server opens with a `welcome` frame
652/// before the key is ever sent, so waiting for a specific frame rather than any frame is what
653/// separates being connected from being authenticated.
654fn read_auth_frame(text: &str) -> Option<Result<LseAuthenticated, SocketError>> {
655 match serde_json::from_str::<LseAuthFrame>(text).ok()? {
656 LseAuthFrame::Authenticated(authenticated) => Some(Ok(authenticated)),
657 LseAuthFrame::Error { code, message } => Some(Err(SocketError::Subscribe(format!(
658 "London Strategic Edge rejected authentication ({}): {}",
659 code.as_deref().unwrap_or("unknown"),
660 message.as_deref().unwrap_or("no message"),
661 )))),
662 LseAuthFrame::Other => None,
663 }
664}
665
666/// A frame that may arrive while awaiting authentication.
667#[derive(Debug, Deserialize)]
668#[serde(tag = "type", rename_all = "snake_case")]
669enum LseAuthFrame {
670 Authenticated(LseAuthenticated),
671 Error {
672 #[serde(default)]
673 code: Option<SmolStr>,
674 #[serde(default)]
675 message: Option<SmolStr>,
676 },
677 /// The `welcome` frame, and anything else this integration does not model.
678 #[serde(other)]
679 Other,
680}
681
682/// The provider's answer to a successful `auth`.
683///
684/// Its symbol list is what the pre-subscribe guards check against — see
685/// [`LseSubscriber::subscribe`].
686#[derive(Clone, PartialEq, Eq, Deserialize, Serialize)]
687pub struct LseAuthenticated {
688 /// The key's plan, as the provider names it (`registered` on a free key).
689 #[serde(default)]
690 pub tier: Option<SmolStr>,
691
692 /// Subscriptions this connection may hold, shared between symbols and option underlyings.
693 ///
694 /// `None` if the provider did not report one, in which case the batch cannot be checked before
695 /// it is sent.
696 #[serde(default)]
697 pub max_subscriptions: Option<u32>,
698
699 /// Every symbol the key may subscribe to.
700 ///
701 /// # ⚠️ This is not the same population as the catalog or the candle store
702 /// A symbol reachable on one of the provider's surfaces need not be reachable on all of them —
703 /// at least one series with tens of millions of historical ticks is absent from this list
704 /// entirely. Its length also drifts week to week, so nothing should assert a count.
705 #[serde(default)]
706 pub symbols: Vec<LseSymbol>,
707}
708
709impl fmt::Debug for LseAuthenticated {
710 /// Summarises the symbol list rather than printing it.
711 ///
712 /// The list ran to over eight thousand entries when measured, and this type is reachable from
713 /// tracing fields — deriving `Debug` would put megabytes into a single log line.
714 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
715 f.debug_struct("LseAuthenticated")
716 .field("tier", &self.tier)
717 .field("max_subscriptions", &self.max_subscriptions)
718 .field("symbols", &format_args!("<{} offered>", self.symbols.len()))
719 .finish()
720 }
721}
722
723/// One entry of the handshake's symbol list.
724///
725/// # ⚠️ Every field but `symbol` may be absent
726/// Entries are not uniform: roughly half carry no `category`, and at least one arrives as
727/// `{"symbol": "ES.F"}` — missing keys rather than null values. This is why the category check can
728/// only reject a *contradiction* and never an absence.
729#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
730pub struct LseSymbol {
731 /// The display symbol, exactly as a subscribe must spell it.
732 pub symbol: SmolStr,
733
734 /// The instrument's descriptive name, where the provider publishes one.
735 #[serde(default)]
736 pub name: Option<SmolStr>,
737
738 /// The dataset the provider files this symbol under, where it publishes one.
739 #[serde(default)]
740 pub category: Option<SmolStr>,
741}
742
743#[cfg(test)]
744#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
745mod tests {
746 use super::*;
747 use crate::exchange::lse::LseFx;
748 use crate::subscription::trade::PublicTrades;
749 use rustrade_instrument::instrument::market_data::MarketDataInstrument;
750 use rustrade_instrument::instrument::market_data::kind::MarketDataInstrumentKind;
751
752 fn authenticated(symbols: &[(&str, Option<&str>)], max: Option<u32>) -> LseAuthenticated {
753 LseAuthenticated {
754 tier: Some("registered".into()),
755 max_subscriptions: max,
756 symbols: symbols
757 .iter()
758 .map(|(symbol, category)| LseSymbol {
759 symbol: SmolStr::new(symbol),
760 name: None,
761 category: category.map(SmolStr::new),
762 })
763 .collect(),
764 }
765 }
766
767 fn markets(symbols: &[&str]) -> Vec<SmolStr> {
768 symbols.iter().map(SmolStr::new).collect()
769 }
770
771 fn at(spelling: &str) -> DateTime<Utc> {
772 spelling.parse::<DateTime<Utc>>().unwrap()
773 }
774
775 /// The microsecond count as a float, so the epoch assertions compare in the float domain and
776 /// need no truncating cast of their own.
777 fn micros_as_f64(time: DateTime<Utc>) -> f64 {
778 time.timestamp_micros() as f64
779 }
780
781 #[test]
782 fn credentials_do_not_print_the_key() {
783 let printed = format!("{:?}", LseCredentials::new("super-secret-key"));
784
785 assert!(!printed.contains("super-secret-key"), "{printed}");
786 assert!(printed.contains("REDACTED"), "{printed}");
787 }
788
789 #[test]
790 fn a_subscribe_payload_names_the_symbol_and_nothing_else() {
791 let WsMessage::Text(payload) = subscribe_message("EUR/USD", None) else {
792 panic!("expected a text payload");
793 };
794 let payload: serde_json::Value = serde_json::from_str(payload.as_str()).unwrap();
795
796 assert_eq!(payload, json!({"action": "subscribe", "symbol": "EUR/USD"}));
797 }
798
799 /// The resume point has to survive the trip out as a number the provider can filter on at its
800 /// own resolution: it parses `start` with a float conversion first, and its datetime fallback
801 /// accepts neither a fractional second nor an offset.
802 #[test]
803 fn a_subscribe_payload_carrying_a_resume_point_sends_it_as_epoch_seconds() {
804 let start = at("2026-08-14T10:16:55.161234Z");
805
806 let WsMessage::Text(payload) = subscribe_message("BTC/USD", Some(start)) else {
807 panic!("expected a text payload");
808 };
809 let payload: serde_json::Value = serde_json::from_str(payload.as_str()).unwrap();
810 let payload = payload.as_object().unwrap();
811
812 assert_eq!(payload.len(), 3, "{payload:?}");
813 assert_eq!(payload["action"], json!("subscribe"));
814 assert_eq!(payload["symbol"], json!("BTC/USD"));
815
816 let seconds = payload["start"]
817 .as_f64()
818 .unwrap_or_else(|| panic!("start must be a JSON number, not {:?}", payload["start"]));
819 let drift = seconds * 1_000_000.0 - micros_as_f64(start);
820 assert!(drift.abs() < 0.5, "start drifted {drift} microseconds");
821 }
822
823 /// Two subscriptions naming one symbol are one slot and one confirmation. Sending two payloads
824 /// would leave the validator waiting for a confirmation that never arrives.
825 #[test]
826 fn repeated_symbols_produce_one_subscribe_each() {
827 let subscription = |base: &str| -> Subscription<LseFx, MarketDataInstrument, PublicTrades> {
828 Subscription::from((
829 LseFx::default(),
830 base,
831 "usd",
832 MarketDataInstrumentKind::Spot,
833 PublicTrades,
834 ))
835 };
836 let subscriptions = [
837 subscription("eur"),
838 subscription("gbp"),
839 subscription("eur"),
840 ];
841
842 assert_eq!(
843 requested_markets(&subscriptions),
844 markets(&["EUR/USD", "GBP/USD"])
845 );
846 }
847
848 #[test]
849 fn an_options_subscribe_payload_names_the_underlying_and_nothing_else() {
850 let WsMessage::Text(payload) = subscribe_options_message("SPY") else {
851 panic!("expected a text payload");
852 };
853 let payload: serde_json::Value = serde_json::from_str(payload.as_str()).unwrap();
854
855 assert_eq!(
856 payload,
857 json!({"action": "subscribe_options", "underlying": "SPY"})
858 );
859 }
860
861 #[test]
862 fn only_the_options_dataset_subscribes_per_underlying() {
863 assert!(subscribes_per_underlying(ExchangeId::LseOptions));
864
865 for exchange in [
866 ExchangeId::LseFx,
867 ExchangeId::LseCrypto,
868 ExchangeId::LseEquities,
869 ExchangeId::LseFutures,
870 ExchangeId::LseCfd,
871 ] {
872 assert!(!subscribes_per_underlying(exchange), "{exchange}");
873 }
874 }
875
876 /// One subscribe covers a whole chain and is confirmed once, however often it is sent, so the
877 /// contracts of one underlying must collapse into a single subscription.
878 #[test]
879 fn contracts_sharing_an_underlying_collapse_into_one_subscription() {
880 let requested = markets(&[
881 "SPY260930C00700000",
882 "QQQ260930P00500000",
883 "SPY260930P00650000",
884 "SPY261016C00700000",
885 ]);
886
887 assert_eq!(
888 option_underlyings(ExchangeId::LseOptions, &requested).unwrap(),
889 markets(&["SPY", "QQQ"])
890 );
891 }
892
893 /// Checked before connecting, and every offender is named — not only the first.
894 #[test]
895 fn a_contract_with_no_osi_symbol_fails_the_batch_naming_it() {
896 let requested = markets(&["SPY260930C00700000", "SPY", "SPY (no OSI symbol for spot)"]);
897
898 let error = option_underlyings(ExchangeId::LseOptions, &requested)
899 .unwrap_err()
900 .to_string();
901 assert!(error.contains(r#""SPY""#), "{error}");
902 assert!(error.contains("no OSI symbol for spot"), "{error}");
903 assert!(!error.contains("SPY260930C00700000"), "{error}");
904 }
905
906 /// On the options dataset a slot holds an underlying, so that is what an over-cap rejection
907 /// must count.
908 #[test]
909 fn an_over_cap_options_batch_is_reported_in_underlyings() {
910 let requested = markets(&["SPY", "QQQ", "AAPL"]);
911 let error = check_subscription_cap(ExchangeId::LseOptions, requested.len(), 0, Some(2))
912 .unwrap_err()
913 .to_string();
914
915 assert!(error.contains("3 option underlyings"), "{error}");
916 assert!(error.contains("at most 2"), "{error}");
917 }
918
919 /// The cap is the connection's: slots other streams on it already hold count, so a batch that
920 /// fits on its own is refused when the connection it joins is nearly full.
921 #[test]
922 fn slots_held_by_other_streams_count_against_the_cap() {
923 let error = check_subscription_cap(ExchangeId::LseFx, 2, 15, Some(16))
924 .unwrap_err()
925 .to_string();
926
927 assert!(error.contains("2 symbols"), "{error}");
928 assert!(error.contains("already holds"), "{error}");
929 assert!(error.contains("shared by every stream"), "{error}");
930 assert!(check_subscription_cap(ExchangeId::LseFx, 1, 15, Some(16)).is_ok());
931 }
932
933 #[test]
934 fn a_batch_within_the_cap_is_accepted() {
935 let requested = markets(&["EUR/USD", "GBP/USD"]);
936 assert!(check_subscription_cap(ExchangeId::LseFx, requested.len(), 0, Some(2)).is_ok());
937 }
938
939 #[test]
940 fn a_batch_over_the_cap_is_rejected_before_it_is_sent() {
941 let requested = markets(&["EUR/USD", "GBP/USD", "XAU/USD"]);
942 let error = check_subscription_cap(ExchangeId::LseFx, requested.len(), 0, Some(2))
943 .unwrap_err()
944 .to_string();
945
946 assert!(error.contains("at most 2"), "{error}");
947 }
948
949 /// Refusing a batch because the cap could not be read would break any tier whose cap this
950 /// integration does not recognise, and the provider still rejects an over-subscription itself.
951 #[test]
952 fn an_unreported_cap_does_not_reject_the_batch() {
953 let requested = markets(&["EUR/USD"]);
954 assert!(check_subscription_cap(ExchangeId::LseFx, requested.len(), 0, None).is_ok());
955 }
956
957 #[test]
958 fn an_offered_symbol_passes_the_guard() {
959 let frame = authenticated(&[("EUR/USD", Some("Forex"))], Some(16));
960 let requested = markets(&["EUR/USD"]);
961
962 assert!(
963 check_symbols_are_offered(
964 ExchangeId::LseFx,
965 &requested,
966 &OfferedSymbols::new(frame.symbols)
967 )
968 .is_ok()
969 );
970 }
971
972 /// The failure this guard exists for: the provider confirms a symbol it does not offer, never
973 /// ticks it, and holds the slot for the life of the connection.
974 #[test]
975 fn a_symbol_the_provider_does_not_offer_fails_the_batch() {
976 let frame = authenticated(&[("EUR/USD", Some("Forex"))], Some(16));
977 let requested = markets(&["EUR/USD", "NOPE_XYZ"]);
978
979 let error = check_symbols_are_offered(
980 ExchangeId::LseFx,
981 &requested,
982 &OfferedSymbols::new(frame.symbols),
983 )
984 .unwrap_err()
985 .to_string();
986 assert!(error.contains("NOPE_XYZ"), "{error}");
987 }
988
989 /// The same symbol the test above rejects, and the list it was checked against removed. There
990 /// is then nothing to check, so the guard degrades to a warning rather than failing a batch it
991 /// cannot judge — refusing would break every caller the moment the provider stopped publishing
992 /// its catalog in the handshake.
993 ///
994 /// This branch is why the live canary asserts a rejection instead of trusting the guard: pinned
995 /// only by this test, the degradation is silent, and a provider that quietly dropped the list
996 /// would leave the guard passing everything forever.
997 #[test]
998 fn an_absent_symbol_list_degrades_to_a_warning_rather_than_failing_the_batch() {
999 let frame = authenticated(&[], Some(16));
1000 let requested = markets(&["NOPE_XYZ"]);
1001
1002 assert!(
1003 check_symbols_are_offered(
1004 ExchangeId::LseFx,
1005 &requested,
1006 &OfferedSymbols::new(frame.symbols)
1007 )
1008 .is_ok()
1009 );
1010 }
1011
1012 #[test]
1013 fn a_contradicting_category_fails_the_batch() {
1014 let frame = authenticated(&[("AAPL", Some("Stocks"))], Some(16));
1015 let requested = markets(&["AAPL"]);
1016
1017 let error = check_symbols_are_offered(
1018 ExchangeId::LseFx,
1019 &requested,
1020 &OfferedSymbols::new(frame.symbols),
1021 )
1022 .unwrap_err()
1023 .to_string();
1024 assert!(error.contains("AAPL is Stocks"), "{error}");
1025 }
1026
1027 /// The same rule where the labels are *complete*, which is the case a symbol-shaped reading of
1028 /// "only cross-check what we recognise" would leave out.
1029 ///
1030 /// A label is evidence only if some dataset is known by it, whether or not this exchange's own
1031 /// expectation has gaps. Restricting the leniency to the partially-labelled exchanges would
1032 /// leave the same defect standing here in a slower form: the provider renaming `Forex` makes
1033 /// this expectation complete but *stale*, and every correctly-requested `LseFx` batch then
1034 /// fails on a label that is simply newer than the table.
1035 #[test]
1036 fn an_unrecognised_label_is_not_a_contradiction_even_where_the_labels_are_complete() {
1037 let expected = expected_categories(ExchangeId::LseFx);
1038 assert_eq!(
1039 expected,
1040 ["Forex"],
1041 "this exchange's expectation is complete, which is the point: {expected:?}"
1042 );
1043
1044 let frame = authenticated(&[("EUR/USD", Some("FX Majors"))], Some(16));
1045 let requested = markets(&["EUR/USD"]);
1046
1047 assert!(
1048 check_symbols_are_offered(
1049 ExchangeId::LseFx,
1050 &requested,
1051 &OfferedSymbols::new(frame.symbols)
1052 )
1053 .is_ok()
1054 );
1055 }
1056
1057 /// Roughly half the published entries carry no category, so absence must never be a mismatch.
1058 #[test]
1059 fn a_missing_category_is_not_a_contradiction() {
1060 let frame = authenticated(&[("EUR/USD", None)], Some(16));
1061 let requested = markets(&["EUR/USD"]);
1062
1063 assert!(
1064 check_symbols_are_offered(
1065 ExchangeId::LseFx,
1066 &requested,
1067 &OfferedSymbols::new(frame.symbols)
1068 )
1069 .is_ok()
1070 );
1071 }
1072
1073 /// Datasets the provider publishes no category for must not reject the categories it does
1074 /// publish — there is nothing to compare against.
1075 #[test]
1076 fn a_dataset_with_no_published_category_accepts_whatever_is_reported() {
1077 assert!(expected_categories(ExchangeId::LseFutures).is_empty());
1078
1079 let frame = authenticated(&[("ES.F", Some("Anything"))], Some(16));
1080 let requested = markets(&["ES.F"]);
1081
1082 assert!(
1083 check_symbols_are_offered(
1084 ExchangeId::LseFutures,
1085 &requested,
1086 &OfferedSymbols::new(frame.symbols)
1087 )
1088 .is_ok()
1089 );
1090 }
1091
1092 /// Five datasets stand behind `LseCfd` and the provider labels two of them, so its expectation
1093 /// is partial. A label appearing on one of the other three must not read as "this symbol
1094 /// belongs to a different dataset" and fail the batch — the published list moved by hundreds of
1095 /// entries in a week when measured, and this is what a correct subscription would meet.
1096 #[test]
1097 fn a_label_no_dataset_is_known_by_is_not_a_contradiction() {
1098 let expected = expected_categories(ExchangeId::LseCfd);
1099 assert!(expected.contains(&"Indices"), "{expected:?}");
1100 assert!(
1101 !expected.contains(&"Volatility"),
1102 "the expectation is partial by construction: {expected:?}"
1103 );
1104
1105 let frame = authenticated(&[("VIX/USD", Some("Volatility"))], Some(16));
1106 let requested = markets(&["VIX/USD"]);
1107
1108 assert!(
1109 check_symbols_are_offered(
1110 ExchangeId::LseCfd,
1111 &requested,
1112 &OfferedSymbols::new(frame.symbols)
1113 )
1114 .is_ok()
1115 );
1116 }
1117
1118 /// The other half of the same rule: a label we recognise as belonging elsewhere still fails,
1119 /// because that is evidence the symbol is on a different dataset.
1120 #[test]
1121 fn a_label_belonging_to_another_dataset_still_fails_the_batch() {
1122 let frame = authenticated(&[("AAPL", Some("Stocks"))], Some(16));
1123 let requested = markets(&["AAPL"]);
1124
1125 let error = check_symbols_are_offered(
1126 ExchangeId::LseCfd,
1127 &requested,
1128 &OfferedSymbols::new(frame.symbols),
1129 )
1130 .unwrap_err()
1131 .to_string();
1132 assert!(error.contains("AAPL is Stocks"), "{error}");
1133 }
1134
1135 /// Equities and ETFs share one identifier, so both categories must satisfy it.
1136 #[test]
1137 fn stocks_and_etfs_both_satisfy_the_equities_identifier() {
1138 let expected = expected_categories(ExchangeId::LseEquities);
1139 assert!(expected.contains(&"Stocks"), "{expected:?}");
1140 assert!(expected.contains(&"ETFs"), "{expected:?}");
1141
1142 let frame = authenticated(&[("AAPL", Some("Stocks")), ("SPY", Some("ETFs"))], Some(16));
1143 let requested = markets(&["AAPL", "SPY"]);
1144
1145 assert!(
1146 check_symbols_are_offered(
1147 ExchangeId::LseEquities,
1148 &requested,
1149 &OfferedSymbols::new(frame.symbols)
1150 )
1151 .is_ok()
1152 );
1153 }
1154
1155 /// The server opens with a `welcome` frame before the key is ever sent, so treating any frame
1156 /// as the answer would report success without authenticating.
1157 #[test]
1158 fn the_welcome_frame_is_not_mistaken_for_an_auth_response() {
1159 let welcome = r#"{"type":"welcome","message":"hello","symbols_available":8516}"#;
1160 assert!(read_auth_frame(welcome).is_none());
1161 }
1162
1163 #[test]
1164 fn an_auth_response_yields_the_symbol_list_and_the_cap() {
1165 let input = r#"{"type":"authenticated","tier":"registered","key_type":"main",
1166 "max_subscriptions":16,"symbols":[
1167 {"symbol":"BTC/USD","name":"Bitcoin","category":"Crypto"},
1168 {"symbol":"ES.F"}
1169 ]}"#;
1170 let frame = read_auth_frame(input).unwrap().unwrap();
1171
1172 assert_eq!(frame.tier.as_deref(), Some("registered"));
1173 assert_eq!(frame.max_subscriptions, Some(16));
1174 assert_eq!(frame.symbols.len(), 2);
1175 assert_eq!(frame.symbols[1].symbol, "ES.F");
1176 assert!(frame.symbols[1].name.is_none());
1177 assert!(frame.symbols[1].category.is_none());
1178 }
1179
1180 #[test]
1181 fn a_rejected_key_is_reported_rather_than_awaited() {
1182 let input = r#"{"type":"error","code":"INVALID_KEY","message":"invalid api key"}"#;
1183 let error = read_auth_frame(input).unwrap().unwrap_err().to_string();
1184
1185 assert!(error.contains("INVALID_KEY"), "{error}");
1186 }
1187
1188 /// The handshake frame is reachable from tracing fields, and the real list runs to thousands of
1189 /// entries.
1190 #[test]
1191 fn the_auth_frame_summarises_its_symbol_list_when_printed() {
1192 let frame = authenticated(&[("BTC/USD", Some("Crypto")), ("ETH/USD", None)], Some(16));
1193 let printed = format!("{frame:?}");
1194
1195 assert!(printed.contains("<2 offered>"), "{printed}");
1196 assert!(!printed.contains("BTC/USD"), "{printed}");
1197 }
1198
1199 #[test]
1200 fn every_dataset_identifier_expects_only_categories_the_provider_publishes() {
1201 // A category expectation that no dataset publishes would reject every symbol on that
1202 // identifier, so the two must be derived from one another rather than listed twice.
1203 for exchange in [
1204 ExchangeId::LseFx,
1205 ExchangeId::LseCrypto,
1206 ExchangeId::LseEquities,
1207 ExchangeId::LseFutures,
1208 ExchangeId::LseCfd,
1209 ] {
1210 for category in expected_categories(exchange) {
1211 assert!(
1212 LseDataset::ALL
1213 .into_iter()
1214 .any(|dataset| dataset.ws_category() == Some(category)),
1215 "{exchange} expects an unpublished category {category:?}",
1216 );
1217 }
1218 }
1219 }
1220
1221 #[test]
1222 fn unrelated_datasets_do_not_share_category_expectations() {
1223 assert_eq!(expected_categories(ExchangeId::LseFx), vec!["Forex"]);
1224 assert_eq!(expected_categories(ExchangeId::LseCrypto), vec!["Crypto"]);
1225 assert!(!expected_categories(ExchangeId::LseCfd).contains(&"Forex"));
1226 }
1227}