Skip to main content

rustrade_data/exchange/lse/
market.rs

1use super::Lse;
2use crate::exchange::lse::error::LseError;
3use crate::exchange::osi;
4use crate::subscription::candle::CandleInterval;
5use crate::{Identifier, instrument::MarketInstrumentData, subscription::Subscription};
6use rustrade_instrument::asset::name::AssetNameInternal;
7use rustrade_instrument::instrument::name::InstrumentNameExchange;
8use rustrade_instrument::instrument::quote::InstrumentQuoteAsset;
9use rustrade_instrument::{
10    Keyed, Underlying, asset::name::AssetNameExchange, exchange::ExchangeId,
11    index::IndexedInstruments, instrument::InstrumentIndex,
12    instrument::market_data::MarketDataInstrument,
13    instrument::market_data::kind::MarketDataInstrumentKind,
14};
15use smol_str::{SmolStr, StrExt, format_smolstr};
16
17/// A London Strategic Edge price dataset.
18///
19/// The provider also publishes reference datasets (bond yields, credit indices, economics, …).
20/// Those are not instruments and have no variant here — they are distinguishable in the catalog
21/// mechanically, since every price dataset carries an empty `frequency` and `category` while every
22/// reference dataset populates both.
23#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
24pub enum LseDataset {
25    Stocks,
26    Etf,
27    Crypto,
28    Fx,
29    Index,
30    Commodity,
31    InterestRates,
32    CurrencyIndex,
33    Volatility,
34    Futures,
35}
36
37impl LseDataset {
38    /// Every price dataset that has a variant here, in catalog order.
39    ///
40    /// ⚠️ **Not every price dataset the catalog publishes.** The catalog carries eleven price
41    /// dataset names; `options` is the eleventh and has no variant, because the surfaces below
42    /// ([`exchange_id`](Self::exchange_id), [`market_data_kind`](Self::market_data_kind),
43    /// [`ws_category`](Self::ws_category)) have no honest answer for an option contract. It was
44    /// 3,186 of the 7,429 price-classified catalog entries when last measured, so a caller
45    /// enumerating this to cover "all price data" will silently miss them.
46    pub const ALL: [Self; 10] = [
47        Self::Stocks,
48        Self::Etf,
49        Self::Crypto,
50        Self::Fx,
51        Self::Index,
52        Self::Commodity,
53        Self::InterestRates,
54        Self::CurrencyIndex,
55        Self::Volatility,
56        Self::Futures,
57    ];
58
59    /// Returns the name this dataset carries in the provider's catalog.
60    pub fn as_catalog_str(&self) -> &'static str {
61        match self {
62            Self::Stocks => "stocks",
63            Self::Etf => "etf",
64            Self::Crypto => "crypto",
65            Self::Fx => "fx",
66            Self::Index => "index",
67            Self::Commodity => "commodity",
68            Self::InterestRates => "interest_rates",
69            Self::CurrencyIndex => "currency_index",
70            Self::Volatility => "volatility",
71            Self::Futures => "futures",
72        }
73    }
74
75    /// Parses a catalog dataset name.
76    ///
77    /// # Errors
78    /// Returns [`LseError::UnknownDataset`] for any name without a variant in [`ALL`](Self::ALL).
79    /// That is **every** reference dataset — they are not instruments, and this is the contract, not
80    /// a gap — and also `options`, which *is* a price dataset but has no variant. So the error does
81    /// not classify the name: it reports only that this type cannot model it.
82    pub fn from_catalog_str(dataset: &str) -> Result<Self, LseError> {
83        Self::ALL
84            .into_iter()
85            .find(|candidate| candidate.as_catalog_str() == dataset)
86            .ok_or_else(|| LseError::UnknownDataset(dataset.to_string()))
87    }
88
89    /// Returns the [`ExchangeId`] this dataset's market events are stamped with.
90    pub fn exchange_id(&self) -> ExchangeId {
91        match self {
92            // Stocks and ETFs share one identifier: they are the same venue, the same symbology
93            // and the same coverage, differing only in what the instrument is.
94            Self::Stocks | Self::Etf => ExchangeId::LseEquities,
95            Self::Crypto => ExchangeId::LseCrypto,
96            Self::Fx => ExchangeId::LseFx,
97            Self::Futures => ExchangeId::LseFutures,
98            Self::Index
99            | Self::Commodity
100            | Self::InterestRates
101            | Self::CurrencyIndex
102            | Self::Volatility => ExchangeId::LseCfd,
103        }
104    }
105
106    /// Whether this dataset can be exported at a candle resolution.
107    ///
108    /// The provider splits its price datasets into candle classes and *synthetic* classes. The
109    /// synthetic ones serve candles over REST but are **tick-only on the export path** — measured:
110    /// `{dataset: volatility, timeframe: 1d}` is rejected with `no candle data for 'volatility';
111    /// it is tick-only`, and identically for `interest_rates`.
112    ///
113    /// # ⚠️ The catalog's `access` map does not answer this
114    /// It lists `["candles", "export"]` for the synthetic classes too, so it says only that both
115    /// capabilities exist — not that they compose.
116    pub fn is_candle_class(&self) -> bool {
117        match self {
118            Self::Stocks
119            | Self::Etf
120            | Self::Crypto
121            | Self::Fx
122            | Self::Index
123            | Self::Commodity
124            | Self::Futures => true,
125            Self::InterestRates | Self::CurrencyIndex | Self::Volatility => false,
126        }
127    }
128
129    /// Returns the [`MarketDataInstrumentKind`] this dataset serves.
130    ///
131    /// # Note
132    /// The executable counterpart, `InstrumentKind::Cfd`, additionally requires a settlement asset
133    /// — the *account* currency, which is a property of the caller's account rather than of the
134    /// data — so it cannot be produced here.
135    pub fn market_data_kind(&self) -> MarketDataInstrumentKind {
136        match self {
137            // Spot: deliverable, and priced as a consolidated series rather than a broker's own.
138            // FX stays spot rather than CFD: the retail-OTC caveat that would motivate `Cfd` is
139            // already expressed un-ignorably by the vault omitting volume entirely.
140            Self::Stocks | Self::Etf | Self::Crypto | Self::Fx => MarketDataInstrumentKind::Spot,
141            // CFD: no venue, no contract size, no expiry. `futures` belongs here and NOT to
142            // `Future` — these series are continuous front-month proxies with no contract chain
143            // and no expiry, and `FutureContract` requires one. A fabricated expiry is not inert:
144            // it becomes a subscription-binding key and drives contract-expiry settlement.
145            Self::Index
146            | Self::Commodity
147            | Self::InterestRates
148            | Self::CurrencyIndex
149            | Self::Volatility
150            | Self::Futures => MarketDataInstrumentKind::Cfd,
151        }
152    }
153
154    /// The category label the WebSocket handshake reports for this dataset's symbols, where it
155    /// reports one at all.
156    ///
157    /// # ⚠️ `None` is the common case, not the exception
158    /// The handshake publishes no category for roughly half the symbols it offers, and some
159    /// entries carry neither a category nor a name. `None` here therefore means *the provider does
160    /// not label this dataset*, not *unknown* — so a symbol arriving without a category can never
161    /// be treated as contradicting the dataset it was requested on.
162    ///
163    /// These labels are the provider's own spelling and are deliberately not derived from
164    /// [`as_catalog_str`](Self::as_catalog_str): the two vocabularies differ (`etf` against
165    /// `ETFs`, `index` against `Indices`) and neither is a transformation of the other.
166    pub fn ws_category(&self) -> Option<&'static str> {
167        match self {
168            Self::Stocks => Some("Stocks"),
169            Self::Etf => Some("ETFs"),
170            Self::Crypto => Some("Crypto"),
171            Self::Fx => Some("Forex"),
172            Self::Index => Some("Indices"),
173            Self::Commodity => Some("Commodities"),
174            Self::InterestRates | Self::CurrencyIndex | Self::Volatility | Self::Futures => None,
175        }
176    }
177}
178
179/// A London Strategic Edge dataset slug — the path key of the provider's dataset-info endpoint.
180///
181/// A slug is a **discovery key, not an identity**. Candle endpoints are keyed on the display
182/// symbol, so nothing in this integration needs a slug to fetch data.
183#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
184pub struct LseSlug(SmolStr);
185
186impl LseSlug {
187    /// Returns the slug as a `str`.
188    pub fn as_str(&self) -> &str {
189        &self.0
190    }
191}
192
193impl AsRef<str> for LseSlug {
194    fn as_ref(&self) -> &str {
195        &self.0
196    }
197}
198
199impl std::fmt::Display for LseSlug {
200    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201        write!(f, "{}", self.0)
202    }
203}
204
205/// Symbol stems whose slug is shared by more than one distinct series.
206///
207/// The first eleven are Eurex futures that publish **both** a bare and a `.F` series holding
208/// different data — different tick counts over different date ranges — which collapse onto one
209/// slug, with the dataset-info endpoint answering for the `.F` series and the bare series having
210/// no reachable slug at all. The last two are futures whose stripped symbol collides with an
211/// unrelated equity ticker (`ES.F` with Eversource, `SI.F` with an unrelated listing), where the
212/// endpoint answers with the future.
213///
214/// Both spellings of each stem are ambiguous: whichever the caller asked for, the slug identifies
215/// the other series just as plausibly.
216const AMBIGUOUS_SLUG_STEMS: [&str; 13] = [
217    "fbtp", "fdax", "fdxm", "fesx", "fgbl", "fgbm", "fgbs", "fmeu", "fmwo", "foat", "fsmi", "es",
218    "si",
219];
220
221/// Suffixes that look like a venue suffix but are not, and therefore must not select a quote
222/// currency.
223///
224/// `A` and `B` are US **share classes** (`BRK.B`, `BF.A`, `HEI.A`); `F` marks a continuous futures
225/// proxy. Listing them is documentation — [`quote_asset`] defaults to USD for any unrecognised
226/// suffix — but the naive `.`-split venue parse they defeat is a real trap, so they are named.
227///
228/// Test-only because the default arm already handles them: nothing in the parse needs to consult
229/// this list, only the test that pins the behaviour it describes.
230#[cfg(test)]
231const NON_VENUE_SUFFIXES: [&str; 3] = ["A", "B", "F"];
232
233/// Returns the quote asset for a London Strategic Edge display symbol.
234///
235/// Pair-shaped symbols (`EUR/USD`, `XAU/USD`, `SPX500/USD`) state their quote directly. Bare
236/// tickers do not, so the quote is derived from the venue suffix, defaulting to USD.
237///
238/// # ⚠️ `.L` symbols are quoted in PENCE, not pounds
239/// London listings are quoted in pence and the provider's catalog reports no unit, so `.L` symbols
240/// map to **GBX** (penny sterling), a *distinct asset from GBP*. This is not cosmetic: `BP.L`
241/// prints ~548 where BP trades around £5.48, so quoting these in GBP would inflate notional, fees,
242/// unrealised PnL and every balance by 100×, silently. Prices are passed through exactly as the
243/// provider reports them — in pence — and it is the asset that carries the scale.
244///
245/// Note that GBX must be spelled distinctly rather than cased differently (`GBp`): asset names are
246/// lowercased internally, so `GBp` and `GBP` would be one and the same asset.
247///
248/// # Venue suffixes
249/// `.L` → GBX, `.T` → JPY, `.HK` → HKD, `.NS` → INR, `.AX` → AUD, `.KS` → KRW, `.TW` → TWD.
250/// Everything else — including the share-class suffixes `.A`/`.B` and the continuous-futures `.F`
251/// — is USD. Suffix matching is **case-insensitive**, so `bp.l` and `BP.L` agree; defaulting a
252/// mis-cased London ticker to USD would be the 100× error above. The suffix is *not* stripped from
253/// the base asset; see [`underlying`].
254///
255/// Currency is deliberately **not** derived from the catalog's `country` field, which records
256/// issuer domicile rather than listing venue: Bermuda- and Ireland-domiciled names are USD-quoted,
257/// and some entries carry no country at all.
258pub fn quote_asset(symbol: &str) -> AssetNameExchange {
259    if let Some((_, quote)) = symbol.split_once('/') {
260        return AssetNameExchange::new(quote.to_uppercase_smolstr());
261    }
262
263    // The suffix is case-normalised before matching. Matching the literals directly would send a
264    // lowercase `bp.l` to the USD default -- silently, and with exactly the 100x consequence the
265    // warning above describes. Venue suffixes are at most two ASCII characters, so the uppercased
266    // copy stays inline in the `SmolStr` and does not allocate.
267    let quote = match symbol.rsplit_once('.') {
268        Some((_, suffix)) => match suffix.to_uppercase_smolstr().as_str() {
269            "L" => "GBX",
270            "T" => "JPY",
271            "HK" => "HKD",
272            "NS" => "INR",
273            "AX" => "AUD",
274            "KS" => "KRW",
275            "TW" => "TWD",
276            // Unrecognised suffix or share class.
277            _ => "USD",
278        },
279        // No suffix at all.
280        None => "USD",
281    };
282
283    AssetNameExchange::new(quote)
284}
285
286/// Splits a London Strategic Edge display symbol into its base and quote assets.
287///
288/// Pair-shaped symbols split at the `/`. Bare tickers take the whole symbol as the base —
289/// **including any venue suffix** — and derive the quote via [`quote_asset`].
290///
291/// The suffix is retained deliberately: stripping it would merge a London listing and a
292/// same-ticker US listing into one asset on one exchange, despite different currencies and
293/// different economics.
294pub fn underlying(symbol: &str) -> Underlying<AssetNameExchange> {
295    let quote = quote_asset(symbol);
296
297    let base = match symbol.split_once('/') {
298        Some((base, _)) => base,
299        None => symbol,
300    };
301
302    Underlying::new(AssetNameExchange::new(base.to_uppercase_smolstr()), quote)
303}
304
305/// Resolve the [`InstrumentIndex`] a display symbol was registered under.
306///
307/// Matched on the data side — each instrument's
308/// [`data_exchange`](rustrade_instrument::instrument::Instrument::data_exchange) and
309/// [`data_name_exchange`](rustrade_instrument::instrument::Instrument::data_name_exchange) — since
310/// `exchange` names where the prices come from. An instrument executed on another venue and priced
311/// here through a `DataVenue` therefore resolves; one with no `DataVenue` is priced where it is
312/// executed, so it resolves by its own exchange and name.
313///
314/// # Why derive it rather than accept one
315/// [`InstrumentIndex`] is a public, unbounded `usize`, and engine state indexes positionally — a
316/// fabricated index attributes a symbol's prices to a different instrument, or panics. Deriving it
317/// from the registry the engine was actually built with makes both unrepresentable, and catches the
318/// typo (`BP` for `BP.L`) that would otherwise yield an instrument that silently never marks.
319///
320/// # The quote asset is checked here too
321/// This is where the provider's view of a symbol and the caller's registry meet, so it is where a
322/// disagreement between them is visible. The provider publishes no unit alongside a price, which
323/// makes the registered asset *the* unit: a `.L` listing registered against `gbp` rather than the
324/// `gbx` [`quote_asset`] derives for it books pence as pounds, and every notional, fee, PnL and
325/// balance downstream is 100× wrong with nothing to object. See [`quote_asset`] for why London
326/// listings are a distinct asset rather than a scaled one.
327///
328/// The instrument's *pricing* asset is what is compared — [`InstrumentQuoteAsset::UnderlyingBase`]
329/// selects the base — because that is the one the provider's number is denominated in.
330///
331/// # This is the ingest path's obligation, and the check reaches only its callers
332/// [`LseCandleSource::resolve`](super::backtest::LseCandleSource::resolve) performs it on the
333/// candle-replay path. On the export path `parquet::read_export` does **not**: it takes its
334/// `instrument` argument on trust, and callers should obtain that argument here. Constructing an
335/// [`InstrumentIndex`] by hand and passing it to either bypasses this
336/// — [`LseCandleSource::new`](super::backtest::LseCandleSource::new) exists for callers who have no
337/// registry to check against, and carries no protection.
338///
339/// It lives in this module rather than in `parquet` because it is registry symbology, not artifact
340/// decoding: the candle path needs it and is **not** gated behind `lse-parquet`, so a consumer
341/// building with `--features lse` alone could not otherwise reach it. Those two references are
342/// deliberately not intra-doc links for the same reason — the item they name is absent from that
343/// build.
344///
345/// # Errors
346/// - [`LseError::UnknownInstrument`] if no instrument is priced on `exchange` under `symbol`,
347///   listing the symbols that are.
348/// - [`LseError::AmbiguousInstrument`] if more than one is, such as one listing executed on two
349///   venues and priced here for both. A candle source feeds a single instrument, so nothing here
350///   can choose between them.
351/// - [`LseError::QuoteAssetMismatch`] if it does, but prices it in a different asset than the
352///   provider quotes that symbol in.
353pub fn instrument_index_for(
354    instruments: &IndexedInstruments,
355    exchange: ExchangeId,
356    symbol: &str,
357) -> Result<InstrumentIndex, LseError> {
358    let wanted = InstrumentNameExchange::new(symbol);
359
360    let priced_here = || {
361        instruments
362            .instruments()
363            .iter()
364            .filter(move |keyed| keyed.value.data_exchange().value == exchange)
365    };
366
367    let mut matches = priced_here().filter(|keyed| *keyed.value.data_name_exchange() == wanted);
368    let instrument = matches.next().ok_or_else(|| LseError::UnknownInstrument {
369        symbol: symbol.to_owned(),
370        exchange,
371        registered: priced_here()
372            .map(|keyed| keyed.value.data_name_exchange().to_string())
373            .collect::<Vec<_>>()
374            .join(", "),
375    })?;
376    if matches.next().is_some() {
377        return Err(LseError::AmbiguousInstrument {
378            symbol: symbol.to_owned(),
379            exchange,
380            instruments: priced_here()
381                .filter(|keyed| *keyed.value.data_name_exchange() == wanted)
382                .map(|keyed| keyed.value.name_internal.to_string())
383                .collect::<Vec<_>>()
384                .join(", "),
385        });
386    }
387
388    let pricing_asset = match instrument.value.quote {
389        InstrumentQuoteAsset::UnderlyingQuote => instrument.value.underlying.quote,
390        InstrumentQuoteAsset::UnderlyingBase => instrument.value.underlying.base,
391    };
392
393    // An `AssetIndex` held by an indexed instrument was issued by this same registry, so the
394    // lookup cannot miss; treating a miss as a mismatch would report the wrong problem, so it is
395    // mapped to the registry's own error rather than folded in.
396    let registered =
397        instruments
398            .find_asset(pricing_asset)
399            .map_err(|error| LseError::UnknownInstrument {
400                symbol: symbol.to_owned(),
401                exchange,
402                registered: error.to_string(),
403            })?;
404
405    // Compared on `name_internal`: that is the identity the engine keys assets by, so `GBX` and
406    // `gbx` are one asset and `GBP` is a different one — which is exactly the distinction that
407    // matters here.
408    let expected = AssetNameInternal::new(quote_asset(symbol).name().clone());
409    if registered.asset.name_internal != expected {
410        return Err(LseError::QuoteAssetMismatch {
411            symbol: symbol.to_owned(),
412            exchange,
413            expected: expected.to_string(),
414            registered: registered.asset.name_internal.to_string(),
415        });
416    }
417
418    Ok(instrument.key)
419}
420
421/// Derives the dataset slug for a London Strategic Edge display symbol.
422///
423/// The slug is the path key of the provider's dataset-info endpoint. It is a **discovery helper,
424/// not an instrument identity** — candle endpoints key on the display symbol, so no data fetch
425/// needs one.
426///
427/// The transformation is: lowercase, `/` → `_`, and drop a trailing `.F`.
428///
429/// # Errors
430/// Returns [`LseError::AmbiguousSlug`] when more than one distinct series resolves to the derived
431/// slug. This is the case the signature exists for: the dataset-info endpoint answers `200` for an
432/// ambiguous slug, silently serving whichever series it prefers, so a plain string transformation
433/// would return the wrong dataset with no error. Thirteen stems are ambiguous: eleven Eurex
434/// futures that publish **both** a bare and a `.F` series holding different data, which collapse
435/// onto one slug; plus `ES.F` and `SI.F`, whose stripped symbols collide with unrelated equity
436/// tickers. Both spellings of each are rejected — whichever the caller asked for, the slug
437/// identifies the other series just as plausibly.
438///
439/// # Caller obligation
440/// A derived slug is not verified to exist. The `.F` rule is measured; the behaviour of other
441/// dotted suffixes (`.L`, `.T`, `.A`, …) is not, and they are carried through verbatim. A slug
442/// that does not exist fails observably as a `404` — unlike the ambiguous case above, which is why
443/// only ambiguity is an error here.
444pub fn slug(symbol: &str) -> Result<LseSlug, LseError> {
445    // Case-normalise *before* stripping, in the order the doc states. Stripping first makes the
446    // strip an exact-literal match that a lowercase `.f` survives, leaving a stem of `fbtp.f`
447    // that matches nothing in `AMBIGUOUS_SLUG_STEMS` -- so the one input the ambiguity check
448    // exists for is the one that walks past it.
449    let normalised = symbol.replace('/', "_").to_lowercase_smolstr();
450    let slug = normalised.strip_suffix(".f").unwrap_or(&normalised);
451
452    if AMBIGUOUS_SLUG_STEMS.contains(&slug) {
453        return Err(LseError::AmbiguousSlug {
454            symbol: symbol.to_string(),
455            slug: slug.to_string(),
456        });
457    }
458
459    Ok(LseSlug(SmolStr::new(slug)))
460}
461
462/// Returns the provider's spelling of `interval`, or `None` if it does not serve that resolution.
463///
464/// [`CandleInterval`] is the venue-agnostic union of every resolution any connector in this crate
465/// serves. This provider serves **14 of the 19**: it publishes no `2h`, `6h`, `8h`, `12h` or `3d`.
466///
467/// # ⚠️ The month spelling differs from the shared enum
468/// The provider spells one month **`1mo`**, where [`CandleInterval::as_str`] — which follows
469/// Binance's kline convention — spells it `1M`. Sending `1M` is not a silent mismatch (it is
470/// rejected with a `400`), but the two must not be assumed interchangeable.
471///
472/// The match is exhaustive rather than defaulting, so adding a [`CandleInterval`] variant forces a
473/// decision here instead of silently reporting it unsupported.
474#[must_use]
475pub fn candle_interval_str(interval: CandleInterval) -> Option<&'static str> {
476    match interval {
477        CandleInterval::Sec1 => Some("1s"),
478        CandleInterval::Sec5 => Some("5s"),
479        CandleInterval::Sec15 => Some("15s"),
480        CandleInterval::Sec30 => Some("30s"),
481        CandleInterval::Min1 => Some("1m"),
482        CandleInterval::Min3 => Some("3m"),
483        CandleInterval::Min5 => Some("5m"),
484        CandleInterval::Min15 => Some("15m"),
485        CandleInterval::Min30 => Some("30m"),
486        CandleInterval::Hour1 => Some("1h"),
487        CandleInterval::Hour4 => Some("4h"),
488        CandleInterval::Day1 => Some("1d"),
489        CandleInterval::Week1 => Some("1w"),
490        // Note the spelling: `1mo`, not the shared enum's `1M`.
491        CandleInterval::Month1 => Some("1mo"),
492        // Not published by this provider.
493        CandleInterval::Hour2
494        | CandleInterval::Hour6
495        | CandleInterval::Hour8
496        | CandleInterval::Hour12
497        | CandleInterval::Day3 => None,
498    }
499}
500
501/// Whether the provider serves candles at `interval`.
502#[must_use]
503pub fn supports_candle_interval(interval: CandleInterval) -> bool {
504    candle_interval_str(interval).is_some()
505}
506
507/// How a dataset spells the display symbol the WebSocket subscribes on.
508///
509/// The provider uses three spellings and each dataset uses exactly one, which is what the
510/// per-dataset [`ExchangeId`] split buys at this boundary: the shape is known statically from the
511/// connector type, so no runtime discriminator is needed to reconstruct a symbol.
512#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
513pub enum LseSymbolShape {
514    /// `BASE/QUOTE` — FX, crypto and CFDs: `EUR/USD`, `BTC/USD`, `SPX500/USD`, `VIX/USD`.
515    Pair,
516    /// A bare ticker, venue suffix included — equities and futures: `AAPL`, `BP.L`, `ES.F`.
517    Bare,
518    /// An unpadded OSI option contract symbol — option contracts: `SPY260930C00700000`.
519    ///
520    /// Built from the base asset as the root and the instrument's
521    /// [`MarketDataInstrumentKind::Option`] contract; the quote asset plays no part. The expiry is
522    /// read as a UTC date, so an instant on the New York evening names the next day's contract.
523    ///
524    /// An instrument that is not an option, or whose strike OSI cannot carry (more than three
525    /// decimal places, not positive, or 100,000 or more), has no OSI symbol. It is given a
526    /// descriptive non-OSI one instead, which the subscriber rejects by name before anything is
527    /// sent.
528    OptionContract,
529}
530
531/// A London Strategic Edge WebSocket server, and the symbol spelling its dataset uses.
532///
533/// Implemented by each `LseServer*` marker type. It exists so the symbol reconstruction below can
534/// be written once per instrument representation rather than once per representation *per server*,
535/// and so each dataset's spelling is declared in exactly one place and cannot drift from it.
536pub trait LseServer: crate::exchange::ExchangeServer {
537    /// The spelling this dataset's display symbols take.
538    const SYMBOL_SHAPE: LseSymbolShape;
539}
540
541/// A London Strategic Edge server whose ticks carry a bid and an ask.
542///
543/// Every dataset but options. The options channel publishes both sides as `null` on every tick, so
544/// an [`OrderBooksL1`](crate::subscription::book::OrderBooksL1) stream on it would never produce a
545/// quote; this bound makes subscribing to one a compile error rather than a silent stream of empty
546/// books.
547pub trait LseQuoteServer: LseServer {}
548
549/// A London Strategic Edge display symbol, as the WebSocket expects it.
550///
551/// This is the provider's own display symbol — the same key its REST vault serves candles on, not
552/// a slug. See [`slug`] for why the two are not interchangeable.
553#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
554pub struct LseMarket(pub SmolStr);
555
556impl AsRef<str> for LseMarket {
557    fn as_ref(&self) -> &str {
558        &self.0
559    }
560}
561
562impl std::fmt::Display for LseMarket {
563    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
564        f.write_str(&self.0)
565    }
566}
567
568/// Reconstruct a display symbol from a registered instrument's assets.
569///
570/// The provider normalises case server-side (`eur/usd` is confirmed as `EUR/USD`), but the symbol
571/// is also the key of the instrument map a tick is resolved through, so it is uppercased here to
572/// match what the server will echo back on the tick.
573///
574/// The base asset's venue suffix is deliberately *not* stripped — see [`underlying`], which retains
575/// it for the same reason: `BP.L` and `BP` are different instruments in different currencies.
576fn market_symbol(
577    shape: LseSymbolShape,
578    base: &AssetNameInternal,
579    quote: &AssetNameInternal,
580    kind: &MarketDataInstrumentKind,
581) -> LseMarket {
582    match shape {
583        LseSymbolShape::Pair => LseMarket(format_smolstr!(
584            "{}/{}",
585            base.name().to_uppercase_smolstr(),
586            quote.name().to_uppercase_smolstr()
587        )),
588        LseSymbolShape::Bare => LseMarket(base.name().to_uppercase_smolstr()),
589        LseSymbolShape::OptionContract => {
590            let spelled = match kind {
591                MarketDataInstrumentKind::Option(contract) => osi::symbol(base.name(), contract),
592                _ => None,
593            };
594
595            // `Identifier::id` is infallible, so an instrument with no OSI spelling cannot fail
596            // here. It gets a symbol that says why instead, which cannot parse as OSI, so the
597            // subscriber's guard rejects the batch naming it before a single subscribe is sent.
598            LseMarket(spelled.unwrap_or_else(|| {
599                format_smolstr!(
600                    "{} (no OSI symbol for {kind})",
601                    base.name().to_uppercase_smolstr()
602                )
603            }))
604        }
605    }
606}
607
608/// An instrument representation a London Strategic Edge subscription can spell as a display symbol.
609///
610/// Implemented for every instrument type this crate subscribes with: [`MarketDataInstrument`],
611/// [`Keyed`] over it, and [`MarketInstrumentData`]. Every [`Subscription`] to an [`Lse`] dataset over
612/// an implementor identifies its [`LseMarket`] through it, so one blanket `Identifier` impl covers
613/// every dataset and every representation.
614///
615/// It is also what lets [`DynamicStreams`](crate::streams::builder::dynamic::DynamicStreams) route
616/// to this integration: a bound on the instrument type alone implies the identifier for every
617/// dataset, where the alternative is one bound per dataset and kind.
618pub trait LseInstrument {
619    /// The display symbol this instrument subscribes to on a dataset spelling symbols as `shape`.
620    fn lse_market(&self, shape: LseSymbolShape) -> LseMarket;
621}
622
623impl<Server, Instrument, Kind> Identifier<LseMarket> for Subscription<Lse<Server>, Instrument, Kind>
624where
625    Server: LseServer,
626    Instrument: LseInstrument,
627{
628    fn id(&self) -> LseMarket {
629        self.instrument.lse_market(Server::SYMBOL_SHAPE)
630    }
631}
632
633impl LseInstrument for MarketDataInstrument {
634    fn lse_market(&self, shape: LseSymbolShape) -> LseMarket {
635        market_symbol(shape, &self.base, &self.quote, &self.kind)
636    }
637}
638
639impl<InstrumentKey> LseInstrument for Keyed<InstrumentKey, MarketDataInstrument> {
640    fn lse_market(&self, shape: LseSymbolShape) -> LseMarket {
641        self.value.lse_market(shape)
642    }
643}
644
645/// Takes the exchange-side name as given, so it needs no per-dataset shape: the caller has already
646/// supplied the provider's own symbol, and there is nothing to reconstruct.
647///
648/// Case is still normalised, for the same reason the reconstruction path normalises it: the symbol
649/// is the instrument-map key a tick resolves through, and the provider echoes `EUR/USD` on the tick
650/// however the subscribe spelled it. A lowercase `name_exchange` would be *confirmed*, then tick
651/// under a key the map does not hold and deliver nothing — the silent failure the reconstruction
652/// path already avoids, and there is no reason for this path to keep it. On a correctly spelled
653/// exchange name the call is a no-op.
654impl<InstrumentKey> LseInstrument for MarketInstrumentData<InstrumentKey> {
655    fn lse_market(&self, _shape: LseSymbolShape) -> LseMarket {
656        LseMarket(self.name_exchange.name().to_uppercase_smolstr())
657    }
658}
659
660#[cfg(test)]
661#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
662mod tests {
663    use super::*;
664
665    #[test]
666    fn test_quote_asset_pair_shaped_symbols() {
667        for (symbol, expected) in [
668            ("EUR/USD", "USD"),
669            ("BTC/USD", "USD"),
670            ("XAU/USD", "USD"),
671            ("SPX500/USD", "USD"),
672            ("EUR/GBP", "GBP"),
673        ] {
674            assert_eq!(quote_asset(symbol), AssetNameExchange::new(expected));
675        }
676    }
677
678    #[test]
679    fn test_quote_asset_venue_suffixes() {
680        for (symbol, expected) in [
681            ("BP.L", "GBX"),
682            ("7203.T", "JPY"),
683            ("0700.HK", "HKD"),
684            ("INFY.NS", "INR"),
685            ("BHP.AX", "AUD"),
686            ("005930.KS", "KRW"),
687            ("2330.TW", "TWD"),
688        ] {
689            assert_eq!(quote_asset(symbol), AssetNameExchange::new(expected));
690        }
691    }
692
693    #[test]
694    fn test_quote_asset_london_listings_are_pence_not_pounds() {
695        // The 100x trap. GBX must be a genuinely distinct name from GBP -- asset names are
696        // lowercased internally, so a `GBp`/`GBP` casing distinction would collapse to one asset.
697        //
698        // Every casing must reach GBX: falling through to the USD default is not a near miss but
699        // the full 100x error, since GBX prices are pence and USD/GBP prices are units.
700        for symbol in ["BP.L", "bp.l", "BP.l", "bp.L"] {
701            let gbx = quote_asset(symbol);
702            assert_eq!(gbx, AssetNameExchange::new("GBX"), "{symbol}");
703            assert_ne!(gbx, AssetNameExchange::new("GBP"), "{symbol}");
704            assert_ne!(gbx.name().to_lowercase_smolstr(), "gbp", "{symbol}");
705        }
706    }
707
708    #[test]
709    fn test_quote_asset_venue_suffixes_are_case_insensitive() {
710        // The suffix arms are literals, so every venue -- not just `.L` -- would default to USD on
711        // a lowercase spelling. Each of these is a currency error, not a cosmetic one.
712        for (symbol, expected) in [
713            ("7203.t", "JPY"),
714            ("0700.hk", "HKD"),
715            ("RELIANCE.ns", "INR"),
716            ("BHP.ax", "AUD"),
717            ("005930.ks", "KRW"),
718            ("2330.tw", "TWD"),
719        ] {
720            assert_eq!(
721                quote_asset(symbol),
722                AssetNameExchange::new(expected),
723                "{symbol} must resolve its venue regardless of case"
724            );
725        }
726    }
727
728    #[test]
729    fn test_quote_asset_share_classes_are_not_venues() {
730        // `.A` and `.B` are US share classes. A naive `.`-split venue parse maps `BRK.B` to a
731        // venue that does not exist; these must fall through to USD.
732        for symbol in ["BRK.B", "BF.A", "HEI.A"] {
733            assert_eq!(quote_asset(symbol), AssetNameExchange::new("USD"));
734        }
735
736        for suffix in NON_VENUE_SUFFIXES {
737            assert_eq!(
738                quote_asset(&format!("XYZ.{suffix}")),
739                AssetNameExchange::new("USD"),
740                "{suffix} must not select a quote currency"
741            );
742        }
743    }
744
745    #[test]
746    fn test_quote_asset_bare_tickers_default_to_usd() {
747        for symbol in ["AAPL", "MSFT", "SPY", "ES.F"] {
748            assert_eq!(quote_asset(symbol), AssetNameExchange::new("USD"));
749        }
750    }
751
752    #[test]
753    fn test_underlying_retains_the_venue_suffix() {
754        // Stripping `.L` would merge the London listing with a same-ticker US listing into one
755        // asset on one exchange, despite the two being quoted in different currencies.
756        let london = underlying("BP.L");
757        assert_eq!(london.base, AssetNameExchange::new("BP.L"));
758        assert_eq!(london.quote, AssetNameExchange::new("GBX"));
759
760        let us = underlying("BP");
761        assert_eq!(us.base, AssetNameExchange::new("BP"));
762        assert_eq!(us.quote, AssetNameExchange::new("USD"));
763
764        assert_ne!(london.base, us.base);
765    }
766
767    #[test]
768    fn test_underlying_pair_shaped_symbols() {
769        let fx = underlying("EUR/USD");
770        assert_eq!(fx.base, AssetNameExchange::new("EUR"));
771        assert_eq!(fx.quote, AssetNameExchange::new("USD"));
772
773        let cfd = underlying("SPX500/USD");
774        assert_eq!(cfd.base, AssetNameExchange::new("SPX500"));
775        assert_eq!(cfd.quote, AssetNameExchange::new("USD"));
776    }
777
778    #[test]
779    fn test_slug_transformation() {
780        for (symbol, expected) in [
781            ("AAPL", "aapl"),
782            ("EUR/USD", "eur_usd"),
783            ("SPX500/USD", "spx500_usd"),
784            ("NQ.F", "nq"),
785            // The `.F` strip is part of the case-normalised transformation, not a literal match.
786            ("NQ.f", "nq"),
787            ("nq.f", "nq"),
788        ] {
789            assert_eq!(slug(symbol).unwrap().as_str(), expected, "{symbol}");
790        }
791    }
792
793    #[test]
794    fn test_slug_rejects_ambiguous_symbols() {
795        // Written out independently of the constant rather than iterated from it: a test that reads
796        // the list it is checking would still pass if a stem were accidentally deleted, which is the
797        // one regression that matters here -- a dropped stem silently re-admits a symbol whose slug
798        // resolves to the wrong series.
799        let expected = [
800            "fbtp", "fdax", "fdxm", "fesx", "fgbl", "fgbm", "fgbs", "fmeu", "fmwo", "foat", "fsmi",
801            "es", "si",
802        ];
803        assert_eq!(
804            AMBIGUOUS_SLUG_STEMS, expected,
805            "the ambiguous-stem list changed; confirm the new set against the provider's catalog"
806        );
807
808        // Both spellings must be rejected: the bare and `.F` series are different data, and the
809        // endpoint answers 200 for the slug regardless of which one the caller meant.
810        for stem in expected {
811            for symbol in [stem.to_uppercase(), format!("{}.F", stem.to_uppercase())] {
812                let error = slug(&symbol).unwrap_err();
813                assert!(
814                    matches!(error, LseError::AmbiguousSlug { .. }),
815                    "{symbol} should be ambiguous, got {error:?}"
816                );
817            }
818        }
819    }
820
821    #[test]
822    fn test_slug_ambiguity_is_case_insensitive() {
823        // All four casings of the suffixed spelling, not just the canonical one. A lowercase `.f`
824        // is the spelling an exact-literal strip lets through -- it leaves the stem as `fbtp.f`,
825        // which matches no entry in the ambiguous list and so is silently accepted.
826        for symbol in ["fbtp.F", "FBTP.F", "fbtp.f", "FBTP.f", "FbTp.F"] {
827            let error = slug(symbol).unwrap_err();
828            assert!(
829                matches!(error, LseError::AmbiguousSlug { .. }),
830                "{symbol} should be ambiguous, got {error:?}"
831            );
832        }
833
834        // The bare spelling too, in every casing.
835        for symbol in ["fbtp", "FBTP", "FbTp"] {
836            assert!(
837                matches!(slug(symbol).unwrap_err(), LseError::AmbiguousSlug { .. }),
838                "{symbol} should be ambiguous"
839            );
840        }
841    }
842
843    #[test]
844    fn test_dataset_exchange_and_kind_mapping() {
845        use LseDataset::*;
846
847        for (dataset, exchange, kind) in [
848            (
849                Stocks,
850                ExchangeId::LseEquities,
851                MarketDataInstrumentKind::Spot,
852            ),
853            (Etf, ExchangeId::LseEquities, MarketDataInstrumentKind::Spot),
854            (
855                Crypto,
856                ExchangeId::LseCrypto,
857                MarketDataInstrumentKind::Spot,
858            ),
859            (Fx, ExchangeId::LseFx, MarketDataInstrumentKind::Spot),
860            (Index, ExchangeId::LseCfd, MarketDataInstrumentKind::Cfd),
861            (Commodity, ExchangeId::LseCfd, MarketDataInstrumentKind::Cfd),
862            (
863                InterestRates,
864                ExchangeId::LseCfd,
865                MarketDataInstrumentKind::Cfd,
866            ),
867            (
868                CurrencyIndex,
869                ExchangeId::LseCfd,
870                MarketDataInstrumentKind::Cfd,
871            ),
872            (
873                Volatility,
874                ExchangeId::LseCfd,
875                MarketDataInstrumentKind::Cfd,
876            ),
877            // Futures are CFDs, not `Future`: continuous proxies with no contract chain or expiry.
878            (
879                Futures,
880                ExchangeId::LseFutures,
881                MarketDataInstrumentKind::Cfd,
882            ),
883        ] {
884            assert_eq!(dataset.exchange_id(), exchange, "{dataset:?}");
885            assert_eq!(dataset.market_data_kind(), kind, "{dataset:?}");
886        }
887    }
888
889    #[test]
890    fn test_dataset_catalog_str_roundtrip() {
891        for dataset in LseDataset::ALL {
892            assert_eq!(
893                LseDataset::from_catalog_str(dataset.as_catalog_str()).unwrap(),
894                dataset
895            );
896        }
897
898        // Reference datasets are not instruments and must not parse as price datasets.
899        for reference in ["bonds", "sovereign_yields", "economics", "credit_indices"] {
900            assert!(matches!(
901                LseDataset::from_catalog_str(reference).unwrap_err(),
902                LseError::UnknownDataset(_)
903            ));
904        }
905    }
906
907    /// The resolutions the provider itself enumerates when it rejects an invalid `timeframe`:
908    ///
909    /// ```text
910    /// invalid timeframe '7q'; valid: 1s, 5s, 15s, 30s, 1m, 3m, 5m, 15m, 30m, 1h, 4h, 1d, 1w, 1mo
911    /// ```
912    const PROVIDER_ADVERTISED_TIMEFRAMES: [&str; 14] = [
913        "1s", "5s", "15s", "30s", "1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d", "1w", "1mo",
914    ];
915
916    #[test]
917    fn test_supported_intervals_match_the_providers_own_list() {
918        // Pins the mapping to the provider's advertised set in both directions, so neither a new
919        // `CandleInterval` variant nor a typo can drift it silently.
920        let mapped = CandleInterval::ALL
921            .into_iter()
922            .filter_map(candle_interval_str)
923            .collect::<Vec<_>>();
924
925        assert_eq!(mapped, PROVIDER_ADVERTISED_TIMEFRAMES);
926    }
927
928    #[test]
929    fn test_month_is_spelled_differently_from_the_shared_enum() {
930        // The shared enum follows Binance's kline convention (`1M`); this provider wants `1mo`.
931        assert_eq!(candle_interval_str(CandleInterval::Month1), Some("1mo"));
932        assert_eq!(CandleInterval::Month1.as_str(), "1M");
933    }
934
935    #[test]
936    fn test_unserved_resolutions_are_reported_unsupported() {
937        for interval in [
938            CandleInterval::Hour2,
939            CandleInterval::Hour6,
940            CandleInterval::Hour8,
941            CandleInterval::Hour12,
942            CandleInterval::Day3,
943        ] {
944            assert!(
945                !supports_candle_interval(interval),
946                "{interval} is not published by this provider"
947            );
948        }
949    }
950
951    #[test]
952    fn test_sub_minute_resolutions_are_supported() {
953        // These have no Binance kline equivalent and exist in the shared enum largely for this
954        // provider, so a regression here would be quiet.
955        for interval in [
956            CandleInterval::Sec1,
957            CandleInterval::Sec5,
958            CandleInterval::Sec15,
959            CandleInterval::Sec30,
960        ] {
961            assert!(supports_candle_interval(interval), "{interval}");
962        }
963    }
964
965    #[test]
966    fn test_every_dataset_maps_to_a_declared_support_arm() {
967        // Each dataset's (exchange, kind) pair must be declared supported, or subscriptions built
968        // from this mapping would be rejected at validation.
969        for dataset in LseDataset::ALL {
970            assert!(
971                crate::subscription::exchange_supports_instrument_kind(
972                    dataset.exchange_id(),
973                    &dataset.market_data_kind()
974                ),
975                "{dataset:?} maps to an unsupported (exchange, kind) pair"
976            );
977        }
978    }
979
980    mod market_symbol_reconstruction {
981        use super::*;
982        use crate::exchange::lse::{
983            LseCfd, LseServerCfd, LseServerCrypto, LseServerEquities, LseServerFutures,
984            LseServerFx, LseServerOptions,
985        };
986        use crate::subscription::trade::PublicTrades;
987        use rust_decimal::Decimal;
988        use rust_decimal_macros::dec;
989        use rustrade_instrument::instrument::{
990            kind::option::{OptionExercise, OptionKind},
991            market_data::kind::MarketDataOptionContract,
992        };
993
994        /// Build a subscription for `Server` and resolve it to the symbol it would subscribe on.
995        ///
996        /// The return type is what selects `Identifier<LseMarket>` over the `Identifier<LseChannel>`
997        /// impl the same subscription also carries.
998        fn market_of<Server>(base: &str, quote: &str, kind: MarketDataInstrumentKind) -> LseMarket
999        where
1000            Server: LseServer,
1001        {
1002            let subscription: Subscription<Lse<Server>, MarketDataInstrument, PublicTrades> =
1003                Subscription::from((Lse::<Server>::default(), base, quote, kind, PublicTrades));
1004
1005            subscription.id()
1006        }
1007
1008        #[test]
1009        fn pair_shaped_datasets_reconstruct_base_slash_quote() {
1010            assert_eq!(
1011                market_of::<LseServerFx>("eur", "usd", MarketDataInstrumentKind::Spot).as_ref(),
1012                "EUR/USD"
1013            );
1014            assert_eq!(
1015                market_of::<LseServerCrypto>("btc", "usd", MarketDataInstrumentKind::Spot).as_ref(),
1016                "BTC/USD"
1017            );
1018            assert_eq!(
1019                market_of::<LseServerCfd>("spx500", "usd", MarketDataInstrumentKind::Cfd).as_ref(),
1020                "SPX500/USD"
1021            );
1022        }
1023
1024        #[test]
1025        fn bare_shaped_datasets_reconstruct_the_ticker_alone() {
1026            assert_eq!(
1027                market_of::<LseServerEquities>("aapl", "usd", MarketDataInstrumentKind::Spot)
1028                    .as_ref(),
1029                "AAPL"
1030            );
1031        }
1032
1033        /// The suffix is part of the base asset, and dropping it would subscribe to the US listing
1034        /// while the engine marks the London one — a different instrument in a different currency.
1035        #[test]
1036        fn a_venue_suffix_survives_symbol_reconstruction() {
1037            assert_eq!(
1038                market_of::<LseServerEquities>("bp.l", "gbx", MarketDataInstrumentKind::Spot)
1039                    .as_ref(),
1040                "BP.L"
1041            );
1042            assert_eq!(
1043                market_of::<LseServerFutures>("es.f", "usd", MarketDataInstrumentKind::Cfd)
1044                    .as_ref(),
1045                "ES.F"
1046            );
1047        }
1048
1049        /// The reconstructed symbol is the instrument-map key a tick is resolved through, so it
1050        /// must match the casing the provider echoes back rather than the caller's spelling.
1051        #[test]
1052        fn reconstruction_uppercases_however_the_instrument_was_registered() {
1053            assert_eq!(
1054                market_of::<LseServerFx>("EuR", "uSd", MarketDataInstrumentKind::Spot).as_ref(),
1055                "EUR/USD"
1056            );
1057        }
1058
1059        /// Reconstruction must invert the split the rest of the integration derives assets with,
1060        /// or the WebSocket and the REST vault would disagree about what one instrument is called.
1061        ///
1062        /// The shape comes from the dataset's own [`LseServer::SYMBOL_SHAPE`] rather than being
1063        /// inferred from the symbol. Inferring it is what [`underlying`] already does, so deriving
1064        /// both halves the same way would make the round trip agree with itself no matter what the
1065        /// connectors declare — and a connector declaring the wrong shape is precisely the failure
1066        /// that produces an unsubscribable symbol, since [`market_symbol`] dispatches on the
1067        /// declaration and not on the string.
1068        #[test]
1069        fn reconstruction_inverts_the_underlying_split() {
1070            fn round_trips<Server>(symbol: &str)
1071            where
1072                Server: LseServer,
1073            {
1074                let split = underlying(symbol);
1075
1076                let rebuilt = market_symbol(
1077                    Server::SYMBOL_SHAPE,
1078                    &AssetNameInternal::new(split.base.name().clone()),
1079                    &AssetNameInternal::new(split.quote.name().clone()),
1080                    &MarketDataInstrumentKind::Spot,
1081                );
1082
1083                assert_eq!(
1084                    rebuilt.as_ref(),
1085                    symbol,
1086                    "{symbol} does not survive a round trip through {:?}, the shape its dataset \
1087                     declares",
1088                    Server::SYMBOL_SHAPE,
1089                );
1090            }
1091
1092            round_trips::<LseServerFx>("EUR/USD");
1093            round_trips::<LseServerCrypto>("BTC/USD");
1094            round_trips::<LseServerCfd>("VIX/USD");
1095            round_trips::<LseServerEquities>("AAPL");
1096            round_trips::<LseServerEquities>("BP.L");
1097            round_trips::<LseServerFutures>("ES.F");
1098        }
1099
1100        fn option(kind: OptionKind, strike: Decimal) -> MarketDataInstrumentKind {
1101            MarketDataInstrumentKind::Option(MarketDataOptionContract {
1102                kind,
1103                exercise: OptionExercise::American,
1104                expiry: "2026-09-30T20:00:00Z".parse().unwrap(),
1105                strike,
1106            })
1107        }
1108
1109        /// An option contract is spelled from the base asset as its root; the quote plays no part.
1110        #[test]
1111        fn an_option_contract_reconstructs_its_osi_symbol() {
1112            assert_eq!(
1113                market_of::<LseServerOptions>("spy", "usd", option(OptionKind::Call, dec!(700)))
1114                    .as_ref(),
1115                "SPY260930C00700000"
1116            );
1117            assert_eq!(
1118                market_of::<LseServerOptions>("f", "usd", option(OptionKind::Put, dec!(2.67)))
1119                    .as_ref(),
1120                "F260930P00002670"
1121            );
1122        }
1123
1124        /// No OSI spelling exists for these, and `Identifier::id` cannot fail. What comes back must
1125        /// be something the subscriber's OSI guard rejects, and must say why when it is quoted in
1126        /// that rejection.
1127        #[test]
1128        fn an_instrument_with_no_osi_spelling_gets_a_symbol_the_guard_rejects() {
1129            for kind in [
1130                MarketDataInstrumentKind::Spot,
1131                option(OptionKind::Call, dec!(700.1234)),
1132            ] {
1133                let market = market_of::<LseServerOptions>("spy", "usd", kind);
1134
1135                assert_eq!(osi::root(market.as_ref()), None, "{market}");
1136                assert!(market.as_ref().contains("no OSI symbol"), "{market}");
1137            }
1138        }
1139
1140        /// An exchange-side name is the provider's own symbol, so it is used as given rather than
1141        /// rebuilt through the dataset's shape.
1142        #[test]
1143        fn an_exchange_named_instrument_is_taken_verbatim() {
1144            assert_eq!(
1145                market_of_exchange_named("XAU/USD").as_ref(),
1146                "XAU/USD",
1147                "a correctly spelled exchange name must pass through untouched",
1148            );
1149        }
1150
1151        /// The one normalisation that still applies, and why: the symbol is the instrument-map key
1152        /// a tick resolves through, and the provider echoes the uppercase spelling on the tick
1153        /// whatever the subscribe said. Left alone, a lowercase name would be confirmed and then
1154        /// tick under a key the map does not hold.
1155        #[test]
1156        fn an_exchange_named_instrument_is_uppercased_like_a_reconstructed_one() {
1157            assert_eq!(market_of_exchange_named("xau/usd").as_ref(), "XAU/USD");
1158            assert_eq!(market_of_exchange_named("bp.l").as_ref(), "BP.L");
1159        }
1160
1161        fn market_of_exchange_named(name_exchange: &str) -> LseMarket {
1162            let subscription = Subscription {
1163                exchange: LseCfd::default(),
1164                instrument: MarketInstrumentData {
1165                    key: 0_usize,
1166                    name_exchange: InstrumentNameExchange::new(name_exchange),
1167                    kind: MarketDataInstrumentKind::Cfd,
1168                },
1169                kind: PublicTrades,
1170            };
1171
1172            subscription.id()
1173        }
1174    }
1175}