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}