Skip to main content

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}