Skip to main content

rustrade_data/exchange/lse/
mod.rs

1//! London Strategic Edge market data (behind the `lse` feature) — ⚠️ the retrieved data is **not
2//! redistributable**.
3//!
4//! A free, no-account market-data provider covering FX, equities, ETFs, crypto, commodities,
5//! indices, futures and options, plus reference and macroeconomic series.
6//!
7//! # ⚠️ Licensing — the data is NOT redistributable
8//! This integration's **code** is MIT-licensed like the rest of this repository. **The data it
9//! retrieves is not.** London Strategic Edge permits use for your own research, trading and model
10//! training — including commercially — but **prohibits redistributing, reselling, or otherwise
11//! making the data available to third parties**, in bulk or through any competing feed, download
12//! service or interface. Their own client library being MIT-licensed covers *that client only* and
13//! confers no rights in the data; the same split applies here.
14//!
15//! In practice: do not commit retrieved data to a public repository, do not publish it as fixtures
16//! or example datasets, and do not re-serve it. Terms: <https://londonstrategicedge.com/terms>
17//!
18//! # Data characteristics
19//! Properties that will silently mislead if assumed away:
20//!
21//! - **FX candles are BID candles — not mid, and not last.** Reconciling a day of `EUR/USD`
22//!   one-minute bars against the tick tape for the same day, open/high/low/close matched the
23//!   **bid** series on 1421 of 1421 minutes and matched the mid or the ask on none. So a backtest
24//!   that fills at the candle close is filling at the bid — systematically favourable by a full
25//!   spread on every buy, in the provider's deepest dataset. This cannot be corrected here without
26//!   inventing a spread. Equity candles, by contrast, track the trade tape; the asymmetry is real
27//!   and per-dataset.
28//! - **Candle volume is not a dependable figure.** For FX the vault omits the field entirely, which
29//!   is modelled as `None` rather than a zero — a zero would aggregate into a legitimate-looking
30//!   total at every derived resolution. (The provider's other host reports a volume for the same
31//!   bar; this integration uses the vault, which does not.) Where the field *is* published it is
32//!   still unreliable: a majority of sampled one-minute equity bars reported `0` in minutes the
33//!   tick tape shows real trades, and one daily series carried a contiguous band roughly 2,000×
34//!   too large. A literal `0` is passed through as `Some(0)`; rewriting it to `None` would be this
35//!   library inventing a fact, and `None` is reserved for a column the provider does not publish.
36//!   Validate before trading on it.
37//! - **⚠️ Candle volume has additionally been reported wrong by three to four orders of magnitude
38//!   since 2026-04-27, in opposite directions on ETFs and on equities.** Unlike the rest of this
39//!   list, which is measured here, this is a third-party report on the provider's public issue
40//!   tracker — repeated across symbols and dates, and unanswered. **ETFs over-report**: a `QQQ`
41//!   one-minute bar was published at 189,272,655,373 against a consolidated **daily** figure of
42//!   33,118,600 for the same session — roughly 5,700× the whole day inside one minute — on
43//!   `QQQ`, `SPY`, `IWM`, `SMH`, `XLE`, `XLF`, `TLT`, every trading day sampled over two months.
44//!   **Equities under-report**: `AAPL`, `MSFT` and `NVDA` daily totals ran at 24–45% of the
45//!   consolidated tape, against 65–80% before the same date, which is the ordinary
46//!   primary-venue-versus-consolidated gap. Splits and liquidity stress were both ruled out by
47//!   the reporter. This integration decodes those figures faithfully, and a shape check passes on
48//!   them: the bars are structurally valid and semantically wrong, so nothing in band distinguishes
49//!   them from correct ones. **Reconcile volume against a second source before sizing anything on
50//!   it**, and treat a volume-derived signal — VWAP, a liquidity filter, a participation-rate
51//!   model — as unusable on this feed until you have.
52//! - **⚠️ History depth varies per dataset and is far shallower than the headline suggests on
53//!   some of them.** `QQQ` in the `etf` dataset was reported carrying a first tick of 2026-04-27
54//!   and 0.2 years of coverage — roughly three months of spot — while options on the same ticker
55//!   reach back to 2014 and equities are stated to reach 2004. Depth is a per-symbol,
56//!   per-dataset property, discoverable from the `first_tick` and `years` fields the provider's
57//!   catalog publishes for each entry. **That catalog is on the discovery host and is not wrapped
58//!   by this integration**, so nothing here can check a requested range against it: a backtest
59//!   asking for years that a symbol does not have receives the bars that exist and no indication
60//!   that the rest were never published. Check depth per symbol before choosing a range.
61//! - **Non-trading days are emitted as FLAT bars, not omitted — daily series are not sparse.**
62//!   Every sampled Saturday and the US Independence Day observance returned a bar with
63//!   `open == high == low == close`; Sundays are absent. A backtest therefore sees a tradeable
64//!   price on a closed market, and the only signal is the flat OHLC. Intraday bars *are* sparse
65//!   (no-trade minutes are absent rather than zero-filled), so the two resolutions differ.
66//! - **These are CFD and aggregated-spot series, not exchange instruments.** `XAU/USD` is spot
67//!   gold rather than a COMEX contract, `SPX500/USD` is a CFD rather than an index or its future,
68//!   and `ES.F` is a continuous front-month proxy with **no contract chain, expiry or roll**.
69//!   There is no venue attribution anywhere in the feed.
70//! - **Crypto is an aggregated tape**: no funding rates, no liquidations, no venue. It is not a
71//!   substitute for a native exchange connector.
72//! - **The live tick is a QUOTE, not a print.** Its `price` equals its `bid` on every sample taken
73//!   — 3,966 of 3,966 ticks across every dataset family. Both [`PublicTrades`] and [`OrderBooksL1`]
74//!   are served from it, but a trade decoded this way is a bid-side quote wearing a trade's shape
75//!   and is not evidence that a transaction occurred. See [`trade`] for the mapping and its
76//!   reasoning.
77//! - **Live tick `volume` is real on two venues and FABRICATED on two others**, with no in-band
78//!   signal separating them. [`LseCrypto`] and [`LseEquities`] carry a genuine per-tick size, which
79//!   reconciles exactly against the provider's own one-minute candles (ratio `1.000`).
80//!   **[`LseFx`] and [`LseCfd`] carry a hard-coded `1.0`** on every tick of every symbol sampled —
81//!   a placeholder that will aggregate into a legitimate-looking total at any resolution, so
82//!   volume-weighted prices and size filters on those two venues are meaningless rather than merely
83//!   imprecise. Note this differs from the REST vault, which *omits* FX volume entirely; the
84//!   WebSocket invents a value instead.
85//! - **Identical consecutive live ticks are genuine and are never de-duplicated.** Barely a third
86//!   of a sampled run was unique on `(ts, price, bid, ask, volume)`, yet removing the repeats
87//!   destroyed 3–10% of volume that otherwise reconciles exactly. Do not add a de-duplication
88//!   filter; a test pins that both are emitted.
89//! - **London (`.L`) listings are quoted in PENCE**, and the catalog reports no unit. They are
90//!   quoted in GBX, an asset distinct from GBP; see
91//!   [`market::quote_asset`].
92//! - **Option greeks are print-triggered, not continuous**: they arrive only with a trade, so a
93//!   contract's greeks are as stale as its last print and an unheld, untraded contract is never
94//!   marked. See [`options`].
95//! - **Dataset slugs are not instrument identities** and do not uniquely identify a series; see
96//!   [`market::slug`].
97
98// A module carries an outer `///` here only when its own file has no `//!` documentation.
99// Supplying both makes rustdoc resolve the file's inner links in THIS module's scope rather than
100// the child's, so every `[`SomeType`]` written inside the child silently renders as dead text.
101
102/// Replay historical candles for N instruments as one time-ordered market stream.
103pub mod backtest;
104
105pub mod bond_yield;
106
107pub mod calendar;
108
109/// The WebSocket channel a subscription maps to.
110pub mod channel;
111
112pub mod connection;
113
114pub mod data_api;
115
116/// Errors produced by the London Strategic Edge integration.
117pub mod error;
118
119pub mod export;
120
121pub mod historical;
122
123pub mod live;
124
125pub mod mapper;
126
127pub mod options;
128
129/// London Strategic Edge symbology: datasets, underlying assets, quote currencies and slugs.
130pub mod market;
131
132#[cfg(feature = "lse-parquet")]
133pub mod parquet;
134
135/// The shared streaming + export allowance, as the provider reports it.
136pub mod quota;
137
138pub mod quote;
139
140pub mod reference;
141
142pub mod resume;
143
144pub mod stream;
145
146pub mod subscription;
147
148pub mod tick;
149
150pub mod trade;
151
152pub mod transformer;
153
154// HTTP plumbing shared by the provider's two hosts; documented in its own file.
155pub(crate) mod transport;
156
157pub mod vault;
158
159use self::{
160    channel::LseChannel,
161    live::{
162        LseSubscriber, subscribe_message, subscribe_options_message, subscribes_per_underlying,
163    },
164    market::{LseMarket, LseQuoteServer, LseServer, LseSymbolShape},
165    stream::LseStream,
166    subscription::LseSubResponse,
167};
168use crate::exchange::osi;
169use crate::{
170    NoInitialSnapshots,
171    exchange::{Connector, ExchangeServer, ExchangeSub, StreamSelector},
172    instrument::InstrumentData,
173    subscriber::validator::WebSocketSubValidator,
174    subscription::{Map, book::OrderBooksL1, trade::PublicTrades},
175};
176use rustrade_instrument::exchange::ExchangeId;
177use rustrade_integration::protocol::websocket::WsMessage;
178use serde::{Deserialize, Serialize};
179use smol_str::SmolStr;
180use std::{fmt::Debug, marker::PhantomData};
181use url::Url;
182
183/// The WebSocket endpoint.
184///
185/// One host serves every dataset. The per-dataset connector split below is about provenance in
186/// `MarketEvent.exchange` and per-dataset support declarations, not about distinct endpoints.
187///
188/// # ⚠️ One connection per key, shared by every stream a subscriber and its clones open
189/// A free key — the `registered` tier, the only one measured — holds exactly one concurrent
190/// connection: its handshake reports `max_connections: 1`, and a second is refused with
191/// `TOO_MANY_CONNECTIONS`. That one connection serves every dataset, both subscription kinds and
192/// option underlyings, so every stream opened by one [`LseSubscriber`] and its clones shares it —
193/// across any number of `subscribe` calls. A subscriber built separately for the same key opens a
194/// connection of its own and is refused. See [`connection`] for how the connection is shared.
195///
196/// That one connection holds 100 subscriptions when last measured (the handshake reports the live
197/// figure), shared by every stream on it: a symbol per slot, or on [`LseOptions`] an underlying per
198/// slot. A symbol held by several streams — [`OrderBooksL1`] alongside [`PublicTrades`], say —
199/// costs one.
200pub const WEBSOCKET_URL: &str = "wss://data-ws.londonstrategicedge.com";
201
202/// Format of every naive timestamp the provider serves over REST.
203///
204/// Used by the candle rows' `ts` and by the catalog's `first_tick` / `last_tick`, which share one
205/// spelling (`2024-01-02 09:09:00.000000`). `%.f` makes the fractional part optional, so a response
206/// that drops the microseconds still parses. The value carries no timezone and is UTC.
207pub(crate) const PROVIDER_TIMESTAMP_FORMAT: &str = "%Y-%m-%d %H:%M:%S%.f";
208
209/// The London Strategic Edge live market data connector.
210///
211/// Use the per-dataset aliases — [`LseFx`], [`LseCrypto`], [`LseEquities`], [`LseFutures`],
212/// [`LseCfd`], [`LseOptions`] — rather than naming the server type directly.
213#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
214pub struct Lse<Server>(PhantomData<Server>);
215
216/// Spot FX: `EUR/USD`, `GBP/USD`, …
217pub type LseFx = Lse<LseServerFx>;
218
219/// Aggregated spot crypto: `BTC/USD`, `ETH/USD`, …
220pub type LseCrypto = Lse<LseServerCrypto>;
221
222/// Equities and ETFs: `AAPL`, `SPY`, `BP.L`, …
223pub type LseEquities = Lse<LseServerEquities>;
224
225/// Continuous front-month futures proxies: `ES.F`, `FDAX`, …
226pub type LseFutures = Lse<LseServerFutures>;
227
228/// Indices, commodities, interest rates, currency indices and volatility, all as CFDs:
229/// `SPX500/USD`, `XAU/USD`, `USB10Y/USD`, `DXY/USD`, `VIX/USD`.
230pub type LseCfd = Lse<LseServerCfd>;
231
232/// US equity and ETF option contracts, one [`Subscription`](crate::subscription::Subscription)
233/// per contract: `SPY260930C00700000`.
234///
235/// Serves [`PublicTrades`] only — every option tick is a genuine print, and none carries a quote.
236///
237/// # How a batch is subscribed
238/// The provider streams options **per underlying**, not per contract: one subscribe covers an
239/// underlying's whole chain, thousands of contracts, on a single one of the connection's
240/// subscription slots. Contracts sharing an underlying therefore collapse into one subscribe, and
241/// the connection holds as many underlyings as it has slots — 100 when last measured — however
242/// many contracts are registered.
243///
244/// # ⚠️ Only registered contracts are delivered; the rest of the chain is counted and dropped
245/// Every contract on a subscribed underlying ticks, whether registered or not. Prints for contracts
246/// nobody registered are dropped without an error each, and reported instead as a periodic
247/// per-underlying `info` count. The set of contracts that trade does not level off over a session —
248/// it was still growing after five minutes with no plateau in sight — so a contract that starts
249/// trading mid-session is missed unless it was registered up front. The provider's REST print tape
250/// in [`options`] carries every contract, greeks included.
251///
252/// # ⚠️ A contract the provider does not list is silent, not rejected
253/// The provider confirms an underlying without listing its contracts, so a registered contract that
254/// does not exist — a wrong expiry, a strike off the chain — is accepted and never ticks. An
255/// underlying with no options at all *is* rejected, by name.
256///
257/// # One connection per key, shared
258/// Option chains stream over the same connection as every other London Strategic Edge dataset, and
259/// draw on its one subscription cap: each underlying takes a slot, as each plain symbol does. See
260/// [`WEBSOCKET_URL`].
261///
262/// # No resumption
263/// A reconnect does not replay the gap: the provider has no replay window on this channel. An
264/// options subscribe that names a `start` is confirmed as usual and draws no error, yet no replay
265/// opens and nothing older than the subscribe is served — measured in session, beside a plain
266/// subscribe on the same connection that did replay. None is therefore requested, and a subscriber configured with [`LseSubscriber::with_resume`] resumes its other
267/// datasets but not this one.
268///
269/// Expiry and strike conventions are those of [`LseSymbolShape::OptionContract`].
270pub type LseOptions = Lse<LseServerOptions>;
271
272/// Spot FX server.
273#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
274pub struct LseServerFx;
275
276/// Aggregated spot crypto server.
277#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
278pub struct LseServerCrypto;
279
280/// Equities and ETFs server.
281#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
282pub struct LseServerEquities;
283
284/// Continuous futures-proxy server.
285#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
286pub struct LseServerFutures;
287
288/// CFD server — indices, commodities, interest rates, currency indices and volatility.
289#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
290pub struct LseServerCfd;
291
292/// Option contract server.
293#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
294pub struct LseServerOptions;
295
296macro_rules! impl_lse_server {
297    ($server:ty, $id:expr, $shape:expr) => {
298        impl ExchangeServer for $server {
299            const ID: ExchangeId = $id;
300
301            fn websocket_url() -> &'static str {
302                WEBSOCKET_URL
303            }
304        }
305
306        impl LseServer for $server {
307            const SYMBOL_SHAPE: LseSymbolShape = $shape;
308        }
309    };
310}
311
312// The two traits are declared together per server because they answer one question each about the
313// same dataset -- which venue its events are stamped with, and how it spells a symbol -- and a
314// server that implements one without the other is not usable.
315impl_lse_server!(LseServerFx, ExchangeId::LseFx, LseSymbolShape::Pair);
316impl_lse_server!(LseServerCrypto, ExchangeId::LseCrypto, LseSymbolShape::Pair);
317impl_lse_server!(
318    LseServerEquities,
319    ExchangeId::LseEquities,
320    LseSymbolShape::Bare
321);
322impl_lse_server!(
323    LseServerFutures,
324    ExchangeId::LseFutures,
325    LseSymbolShape::Bare
326);
327impl_lse_server!(LseServerCfd, ExchangeId::LseCfd, LseSymbolShape::Pair);
328impl_lse_server!(
329    LseServerOptions,
330    ExchangeId::LseOptions,
331    LseSymbolShape::OptionContract
332);
333
334// Every server but options: the options channel publishes no quote. See `LseQuoteServer`.
335impl LseQuoteServer for LseServerFx {}
336impl LseQuoteServer for LseServerCrypto {}
337impl LseQuoteServer for LseServerEquities {}
338impl LseQuoteServer for LseServerFutures {}
339impl LseQuoteServer for LseServerCfd {}
340
341impl<Server> Connector for Lse<Server>
342where
343    Server: LseServer,
344{
345    const ID: ExchangeId = Server::ID;
346    type Channel = LseChannel;
347    type Market = LseMarket;
348    type Subscriber = LseSubscriber;
349    type SubValidator = WebSocketSubValidator;
350    type SubResponse = LseSubResponse;
351
352    fn url() -> Result<Url, url::ParseError> {
353        Url::parse(Server::websocket_url())
354    }
355
356    /// One payload per symbol — the provider accepts no batched subscribe.
357    ///
358    /// # ⚠️ Nothing sends what this builds
359    /// [`LseSubscriber`] does not call this: a subscription may carry a replay window and this
360    /// function cannot see one, so the subscriber builds its own payloads and the mapper's output
361    /// is discarded where it does so. This is required by the [`Connector`] contract and is
362    /// implemented faithfully; it is not the route this integration subscribes over.
363    ///
364    /// It shares `subscribe_message` with the live route, so the two cannot disagree about a
365    /// payload's *shape*. They do differ in what they are given: this receives one entry per
366    /// subscription, while the subscriber sends one per *distinct* symbol, because a repeated
367    /// symbol is one slot and one confirmation on this provider.
368    ///
369    /// On [`LseOptions`] it sends one payload per distinct underlying, as the subscriber does. A
370    /// contract with no OSI symbol has no underlying to name and is left out; the live route
371    /// rejects the batch over it instead, which this infallible signature cannot.
372    fn requests(exchange_subs: Vec<ExchangeSub<Self::Channel, Self::Market>>) -> Vec<WsMessage> {
373        if !subscribes_per_underlying(Self::ID) {
374            return exchange_subs
375                .iter()
376                .map(|exchange_sub| subscribe_message(exchange_sub.market.as_ref(), None))
377                .collect();
378        }
379
380        let mut underlyings = Vec::<&str>::new();
381        for exchange_sub in &exchange_subs {
382            if let Some(root) = osi::root(exchange_sub.market.as_ref())
383                && !underlyings.contains(&root)
384            {
385                underlyings.push(root);
386            }
387        }
388
389        underlyings
390            .into_iter()
391            .map(subscribe_options_message)
392            .collect()
393    }
394
395    /// One confirmation per distinct symbol — or, on [`LseOptions`], per distinct underlying.
396    ///
397    /// The instrument map holds one entry per registered contract, but the provider confirms an
398    /// underlying once however many of its contracts are registered, so counting entries would
399    /// leave the validator waiting out its timeout for confirmations that never come.
400    fn expected_responses<InstrumentKey>(map: &Map<InstrumentKey>) -> usize {
401        if !subscribes_per_underlying(Self::ID) {
402            return map.0.len();
403        }
404
405        let mut underlyings = Vec::<SmolStr>::new();
406        for subscription_id in map.0.keys() {
407            // A subscription identifier is the contract symbol -- see `mapper::subscription_id`.
408            if let Some(root) = osi::root(subscription_id.as_ref())
409                && !underlyings.iter().any(|underlying| underlying == root)
410            {
411                underlyings.push(SmolStr::new(root));
412            }
413        }
414
415        underlyings.len()
416    }
417}
418
419// Both subscription kinds decode the SAME frame -- the provider publishes one data frame and it
420// carries a price, a bid, an ask and a size -- so the two selectors differ only in which decoder
421// the transformer is instantiated with. Neither needs an initial snapshot: the feed is a tick
422// stream with no book to synchronise against.
423//
424// One blanket impl per kind covers every server, rather than an impl per server each. The servers
425// differ in venue and symbol shape, never in framing -- save that the options channel carries no
426// quote, which is why the L1 impl is bounded on `LseQuoteServer`.
427
428impl<Instrument, Server> StreamSelector<Instrument, PublicTrades> for Lse<Server>
429where
430    Instrument: InstrumentData,
431    Server: LseServer + Debug + Send + Sync,
432{
433    type SnapFetcher = NoInitialSnapshots;
434    type Stream = LseStream<Self, Instrument::Key, PublicTrades>;
435}
436
437impl<Instrument, Server> StreamSelector<Instrument, OrderBooksL1> for Lse<Server>
438where
439    Instrument: InstrumentData,
440    Server: LseQuoteServer + Debug + Send + Sync,
441{
442    type SnapFetcher = NoInitialSnapshots;
443    type Stream = LseStream<Self, Instrument::Key, OrderBooksL1>;
444}
445
446impl<'de, Server> Deserialize<'de> for Lse<Server>
447where
448    Server: ExchangeServer,
449{
450    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
451    where
452        D: serde::de::Deserializer<'de>,
453    {
454        let input = <String as Deserialize>::deserialize(deserializer)?;
455
456        if input.as_str() == Server::ID.as_str() {
457            Ok(Self::default())
458        } else {
459            Err(serde::de::Error::invalid_value(
460                serde::de::Unexpected::Str(input.as_str()),
461                &Server::ID.as_str(),
462            ))
463        }
464    }
465}
466
467impl<Server> Serialize for Lse<Server>
468where
469    Server: ExchangeServer,
470{
471    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
472    where
473        S: serde::ser::Serializer,
474    {
475        serializer.serialize_str(Server::ID.as_str())
476    }
477}
478
479#[cfg(test)]
480#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
481mod tests {
482    use super::*;
483
484    /// The connector is hand-serialised rather than derived, so nothing else pins that the two
485    /// halves agree — a `Subscription` carrying one would otherwise fail to round-trip through a
486    /// config file with no compile-time signal.
487    #[test]
488    fn a_connector_round_trips_through_its_exchange_id() {
489        let json = serde_json::to_string(&LseCrypto::default()).unwrap();
490        assert_eq!(json, r#""lse_crypto""#);
491
492        assert_eq!(
493            serde_json::from_str::<LseCrypto>(&json).unwrap(),
494            LseCrypto::default()
495        );
496    }
497
498    fn map(markets: &[&str]) -> Map<u8> {
499        markets
500            .iter()
501            .zip(0_u8..)
502            .map(|(market, key)| (mapper::subscription_id(market), key))
503            .collect()
504    }
505
506    /// The provider confirms an underlying once however many of its contracts are registered.
507    /// Counting contracts would leave the validator waiting out its timeout.
508    #[test]
509    fn options_expect_one_confirmation_per_underlying() {
510        let contracts = map(&[
511            "SPY260930C00700000",
512            "SPY260930P00650000",
513            "QQQ260930C00500000",
514        ]);
515
516        assert_eq!(LseOptions::expected_responses(&contracts), 2);
517    }
518
519    #[test]
520    fn other_datasets_expect_one_confirmation_per_symbol() {
521        let symbols = map(&["BTC/USD", "ETH/USD"]);
522        assert_eq!(LseCrypto::expected_responses(&symbols), 2);
523    }
524
525    #[test]
526    fn options_requests_name_each_underlying_once() {
527        let exchange_subs = [
528            "SPY260930C00700000",
529            "SPY260930P00650000",
530            "QQQ260930C00500000",
531        ]
532        .into_iter()
533        .map(|market| ExchangeSub::from((LseChannel::Tick, LseMarket(market.into()))))
534        .collect();
535
536        let payloads: Vec<serde_json::Value> = LseOptions::requests(exchange_subs)
537            .into_iter()
538            .map(|message| match message {
539                WsMessage::Text(text) => serde_json::from_str(text.as_str()).unwrap(),
540                other => panic!("expected a text payload, not {other:?}"),
541            })
542            .collect();
543
544        assert_eq!(
545            payloads,
546            [
547                serde_json::json!({"action": "subscribe_options", "underlying": "SPY"}),
548                serde_json::json!({"action": "subscribe_options", "underlying": "QQQ"}),
549            ]
550        );
551    }
552
553    #[test]
554    fn the_options_server_spells_option_contracts() {
555        assert_eq!(
556            LseServerOptions::SYMBOL_SHAPE,
557            LseSymbolShape::OptionContract
558        );
559        assert_eq!(LseServerOptions::ID, ExchangeId::LseOptions);
560    }
561
562    /// Every dataset connector is a distinct type over one endpoint, so deserialising a config that
563    /// names a *different* dataset must fail rather than silently produce this one and subscribe
564    /// the wrong venue's symbology.
565    #[test]
566    fn a_connector_rejects_another_datasets_identifier() {
567        let error = serde_json::from_str::<LseCrypto>(r#""lse_fx""#).unwrap_err();
568        assert!(error.to_string().contains("lse_crypto"), "{error}");
569    }
570}