use super::Lse;
use crate::exchange::lse::error::LseError;
use crate::subscription::candle::CandleInterval;
use crate::{Identifier, instrument::MarketInstrumentData, subscription::Subscription};
use rustrade_instrument::asset::name::AssetNameInternal;
use rustrade_instrument::instrument::name::InstrumentNameExchange;
use rustrade_instrument::instrument::quote::InstrumentQuoteAsset;
use rustrade_instrument::{
Keyed, Underlying, asset::name::AssetNameExchange, exchange::ExchangeId,
index::IndexedInstruments, instrument::InstrumentIndex,
instrument::market_data::MarketDataInstrument,
instrument::market_data::kind::MarketDataInstrumentKind,
};
use smol_str::{SmolStr, StrExt, format_smolstr};
#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub enum LseDataset {
Stocks,
Etf,
Crypto,
Fx,
Index,
Commodity,
InterestRates,
CurrencyIndex,
Volatility,
Futures,
}
impl LseDataset {
pub const ALL: [Self; 10] = [
Self::Stocks,
Self::Etf,
Self::Crypto,
Self::Fx,
Self::Index,
Self::Commodity,
Self::InterestRates,
Self::CurrencyIndex,
Self::Volatility,
Self::Futures,
];
pub fn as_catalog_str(&self) -> &'static str {
match self {
Self::Stocks => "stocks",
Self::Etf => "etf",
Self::Crypto => "crypto",
Self::Fx => "fx",
Self::Index => "index",
Self::Commodity => "commodity",
Self::InterestRates => "interest_rates",
Self::CurrencyIndex => "currency_index",
Self::Volatility => "volatility",
Self::Futures => "futures",
}
}
pub fn from_catalog_str(dataset: &str) -> Result<Self, LseError> {
Self::ALL
.into_iter()
.find(|candidate| candidate.as_catalog_str() == dataset)
.ok_or_else(|| LseError::UnknownDataset(dataset.to_string()))
}
pub fn exchange_id(&self) -> ExchangeId {
match self {
Self::Stocks | Self::Etf => ExchangeId::LseEquities,
Self::Crypto => ExchangeId::LseCrypto,
Self::Fx => ExchangeId::LseFx,
Self::Futures => ExchangeId::LseFutures,
Self::Index
| Self::Commodity
| Self::InterestRates
| Self::CurrencyIndex
| Self::Volatility => ExchangeId::LseCfd,
}
}
pub fn is_candle_class(&self) -> bool {
match self {
Self::Stocks
| Self::Etf
| Self::Crypto
| Self::Fx
| Self::Index
| Self::Commodity
| Self::Futures => true,
Self::InterestRates | Self::CurrencyIndex | Self::Volatility => false,
}
}
pub fn market_data_kind(&self) -> MarketDataInstrumentKind {
match self {
Self::Stocks | Self::Etf | Self::Crypto | Self::Fx => MarketDataInstrumentKind::Spot,
Self::Index
| Self::Commodity
| Self::InterestRates
| Self::CurrencyIndex
| Self::Volatility
| Self::Futures => MarketDataInstrumentKind::Cfd,
}
}
pub fn ws_category(&self) -> Option<&'static str> {
match self {
Self::Stocks => Some("Stocks"),
Self::Etf => Some("ETFs"),
Self::Crypto => Some("Crypto"),
Self::Fx => Some("Forex"),
Self::Index => Some("Indices"),
Self::Commodity => Some("Commodities"),
Self::InterestRates | Self::CurrencyIndex | Self::Volatility | Self::Futures => None,
}
}
}
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub struct LseSlug(SmolStr);
impl LseSlug {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl AsRef<str> for LseSlug {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for LseSlug {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
const AMBIGUOUS_SLUG_STEMS: [&str; 13] = [
"fbtp", "fdax", "fdxm", "fesx", "fgbl", "fgbm", "fgbs", "fmeu", "fmwo", "foat", "fsmi", "es",
"si",
];
#[cfg(test)]
const NON_VENUE_SUFFIXES: [&str; 3] = ["A", "B", "F"];
pub fn quote_asset(symbol: &str) -> AssetNameExchange {
if let Some((_, quote)) = symbol.split_once('/') {
return AssetNameExchange::new(quote.to_uppercase_smolstr());
}
let quote = match symbol.rsplit_once('.') {
Some((_, suffix)) => match suffix.to_uppercase_smolstr().as_str() {
"L" => "GBX",
"T" => "JPY",
"HK" => "HKD",
"NS" => "INR",
"AX" => "AUD",
"KS" => "KRW",
"TW" => "TWD",
_ => "USD",
},
None => "USD",
};
AssetNameExchange::new(quote)
}
pub fn underlying(symbol: &str) -> Underlying<AssetNameExchange> {
let quote = quote_asset(symbol);
let base = match symbol.split_once('/') {
Some((base, _)) => base,
None => symbol,
};
Underlying::new(AssetNameExchange::new(base.to_uppercase_smolstr()), quote)
}
pub fn instrument_index_for(
instruments: &IndexedInstruments,
exchange: ExchangeId,
symbol: &str,
) -> Result<InstrumentIndex, LseError> {
let wanted = InstrumentNameExchange::new(symbol);
let instrument = instruments
.instruments()
.iter()
.find(|keyed| keyed.value.exchange.value == exchange && keyed.value.name_exchange == wanted)
.ok_or_else(|| LseError::UnknownInstrument {
symbol: symbol.to_owned(),
exchange,
registered: instruments
.instruments()
.iter()
.filter(|keyed| keyed.value.exchange.value == exchange)
.map(|keyed| keyed.value.name_exchange.to_string())
.collect::<Vec<_>>()
.join(", "),
})?;
let pricing_asset = match instrument.value.quote {
InstrumentQuoteAsset::UnderlyingQuote => instrument.value.underlying.quote,
InstrumentQuoteAsset::UnderlyingBase => instrument.value.underlying.base,
};
let registered =
instruments
.find_asset(pricing_asset)
.map_err(|error| LseError::UnknownInstrument {
symbol: symbol.to_owned(),
exchange,
registered: error.to_string(),
})?;
let expected = AssetNameInternal::new(quote_asset(symbol).name().clone());
if registered.asset.name_internal != expected {
return Err(LseError::QuoteAssetMismatch {
symbol: symbol.to_owned(),
exchange,
expected: expected.to_string(),
registered: registered.asset.name_internal.to_string(),
});
}
Ok(instrument.key)
}
pub fn slug(symbol: &str) -> Result<LseSlug, LseError> {
let normalised = symbol.replace('/', "_").to_lowercase_smolstr();
let slug = normalised.strip_suffix(".f").unwrap_or(&normalised);
if AMBIGUOUS_SLUG_STEMS.contains(&slug) {
return Err(LseError::AmbiguousSlug {
symbol: symbol.to_string(),
slug: slug.to_string(),
});
}
Ok(LseSlug(SmolStr::new(slug)))
}
#[must_use]
pub fn candle_interval_str(interval: CandleInterval) -> Option<&'static str> {
match interval {
CandleInterval::Sec1 => Some("1s"),
CandleInterval::Sec5 => Some("5s"),
CandleInterval::Sec15 => Some("15s"),
CandleInterval::Sec30 => Some("30s"),
CandleInterval::Min1 => Some("1m"),
CandleInterval::Min3 => Some("3m"),
CandleInterval::Min5 => Some("5m"),
CandleInterval::Min15 => Some("15m"),
CandleInterval::Min30 => Some("30m"),
CandleInterval::Hour1 => Some("1h"),
CandleInterval::Hour4 => Some("4h"),
CandleInterval::Day1 => Some("1d"),
CandleInterval::Week1 => Some("1w"),
CandleInterval::Month1 => Some("1mo"),
CandleInterval::Hour2
| CandleInterval::Hour6
| CandleInterval::Hour8
| CandleInterval::Hour12
| CandleInterval::Day3 => None,
}
}
#[must_use]
pub fn supports_candle_interval(interval: CandleInterval) -> bool {
candle_interval_str(interval).is_some()
}
#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub enum LseSymbolShape {
Pair,
Bare,
}
pub trait LseServer: crate::exchange::ExchangeServer {
const SYMBOL_SHAPE: LseSymbolShape;
}
#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
pub struct LseMarket(pub SmolStr);
impl AsRef<str> for LseMarket {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for LseMarket {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
fn market_symbol(
shape: LseSymbolShape,
base: &AssetNameInternal,
quote: &AssetNameInternal,
) -> LseMarket {
match shape {
LseSymbolShape::Pair => LseMarket(format_smolstr!(
"{}/{}",
base.name().to_uppercase_smolstr(),
quote.name().to_uppercase_smolstr()
)),
LseSymbolShape::Bare => LseMarket(base.name().to_uppercase_smolstr()),
}
}
impl<Server, Kind> Identifier<LseMarket> for Subscription<Lse<Server>, MarketDataInstrument, Kind>
where
Server: LseServer,
{
fn id(&self) -> LseMarket {
market_symbol(
Server::SYMBOL_SHAPE,
&self.instrument.base,
&self.instrument.quote,
)
}
}
impl<Server, InstrumentKey, Kind> Identifier<LseMarket>
for Subscription<Lse<Server>, Keyed<InstrumentKey, MarketDataInstrument>, Kind>
where
Server: LseServer,
{
fn id(&self) -> LseMarket {
market_symbol(
Server::SYMBOL_SHAPE,
&self.instrument.value.base,
&self.instrument.value.quote,
)
}
}
impl<Server, InstrumentKey, Kind> Identifier<LseMarket>
for Subscription<Lse<Server>, MarketInstrumentData<InstrumentKey>, Kind>
{
fn id(&self) -> LseMarket {
LseMarket(self.instrument.name_exchange.name().to_uppercase_smolstr())
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::*;
#[test]
fn test_quote_asset_pair_shaped_symbols() {
for (symbol, expected) in [
("EUR/USD", "USD"),
("BTC/USD", "USD"),
("XAU/USD", "USD"),
("SPX500/USD", "USD"),
("EUR/GBP", "GBP"),
] {
assert_eq!(quote_asset(symbol), AssetNameExchange::new(expected));
}
}
#[test]
fn test_quote_asset_venue_suffixes() {
for (symbol, expected) in [
("BP.L", "GBX"),
("7203.T", "JPY"),
("0700.HK", "HKD"),
("INFY.NS", "INR"),
("BHP.AX", "AUD"),
("005930.KS", "KRW"),
("2330.TW", "TWD"),
] {
assert_eq!(quote_asset(symbol), AssetNameExchange::new(expected));
}
}
#[test]
fn test_quote_asset_london_listings_are_pence_not_pounds() {
for symbol in ["BP.L", "bp.l", "BP.l", "bp.L"] {
let gbx = quote_asset(symbol);
assert_eq!(gbx, AssetNameExchange::new("GBX"), "{symbol}");
assert_ne!(gbx, AssetNameExchange::new("GBP"), "{symbol}");
assert_ne!(gbx.name().to_lowercase_smolstr(), "gbp", "{symbol}");
}
}
#[test]
fn test_quote_asset_venue_suffixes_are_case_insensitive() {
for (symbol, expected) in [
("7203.t", "JPY"),
("0700.hk", "HKD"),
("RELIANCE.ns", "INR"),
("BHP.ax", "AUD"),
("005930.ks", "KRW"),
("2330.tw", "TWD"),
] {
assert_eq!(
quote_asset(symbol),
AssetNameExchange::new(expected),
"{symbol} must resolve its venue regardless of case"
);
}
}
#[test]
fn test_quote_asset_share_classes_are_not_venues() {
for symbol in ["BRK.B", "BF.A", "HEI.A"] {
assert_eq!(quote_asset(symbol), AssetNameExchange::new("USD"));
}
for suffix in NON_VENUE_SUFFIXES {
assert_eq!(
quote_asset(&format!("XYZ.{suffix}")),
AssetNameExchange::new("USD"),
"{suffix} must not select a quote currency"
);
}
}
#[test]
fn test_quote_asset_bare_tickers_default_to_usd() {
for symbol in ["AAPL", "MSFT", "SPY", "ES.F"] {
assert_eq!(quote_asset(symbol), AssetNameExchange::new("USD"));
}
}
#[test]
fn test_underlying_retains_the_venue_suffix() {
let london = underlying("BP.L");
assert_eq!(london.base, AssetNameExchange::new("BP.L"));
assert_eq!(london.quote, AssetNameExchange::new("GBX"));
let us = underlying("BP");
assert_eq!(us.base, AssetNameExchange::new("BP"));
assert_eq!(us.quote, AssetNameExchange::new("USD"));
assert_ne!(london.base, us.base);
}
#[test]
fn test_underlying_pair_shaped_symbols() {
let fx = underlying("EUR/USD");
assert_eq!(fx.base, AssetNameExchange::new("EUR"));
assert_eq!(fx.quote, AssetNameExchange::new("USD"));
let cfd = underlying("SPX500/USD");
assert_eq!(cfd.base, AssetNameExchange::new("SPX500"));
assert_eq!(cfd.quote, AssetNameExchange::new("USD"));
}
#[test]
fn test_slug_transformation() {
for (symbol, expected) in [
("AAPL", "aapl"),
("EUR/USD", "eur_usd"),
("SPX500/USD", "spx500_usd"),
("NQ.F", "nq"),
("NQ.f", "nq"),
("nq.f", "nq"),
] {
assert_eq!(slug(symbol).unwrap().as_str(), expected, "{symbol}");
}
}
#[test]
fn test_slug_rejects_ambiguous_symbols() {
let expected = [
"fbtp", "fdax", "fdxm", "fesx", "fgbl", "fgbm", "fgbs", "fmeu", "fmwo", "foat", "fsmi",
"es", "si",
];
assert_eq!(
AMBIGUOUS_SLUG_STEMS, expected,
"the ambiguous-stem list changed; confirm the new set against the provider's catalog"
);
for stem in expected {
for symbol in [stem.to_uppercase(), format!("{}.F", stem.to_uppercase())] {
let error = slug(&symbol).unwrap_err();
assert!(
matches!(error, LseError::AmbiguousSlug { .. }),
"{symbol} should be ambiguous, got {error:?}"
);
}
}
}
#[test]
fn test_slug_ambiguity_is_case_insensitive() {
for symbol in ["fbtp.F", "FBTP.F", "fbtp.f", "FBTP.f", "FbTp.F"] {
let error = slug(symbol).unwrap_err();
assert!(
matches!(error, LseError::AmbiguousSlug { .. }),
"{symbol} should be ambiguous, got {error:?}"
);
}
for symbol in ["fbtp", "FBTP", "FbTp"] {
assert!(
matches!(slug(symbol).unwrap_err(), LseError::AmbiguousSlug { .. }),
"{symbol} should be ambiguous"
);
}
}
#[test]
fn test_dataset_exchange_and_kind_mapping() {
use LseDataset::*;
for (dataset, exchange, kind) in [
(
Stocks,
ExchangeId::LseEquities,
MarketDataInstrumentKind::Spot,
),
(Etf, ExchangeId::LseEquities, MarketDataInstrumentKind::Spot),
(
Crypto,
ExchangeId::LseCrypto,
MarketDataInstrumentKind::Spot,
),
(Fx, ExchangeId::LseFx, MarketDataInstrumentKind::Spot),
(Index, ExchangeId::LseCfd, MarketDataInstrumentKind::Cfd),
(Commodity, ExchangeId::LseCfd, MarketDataInstrumentKind::Cfd),
(
InterestRates,
ExchangeId::LseCfd,
MarketDataInstrumentKind::Cfd,
),
(
CurrencyIndex,
ExchangeId::LseCfd,
MarketDataInstrumentKind::Cfd,
),
(
Volatility,
ExchangeId::LseCfd,
MarketDataInstrumentKind::Cfd,
),
(
Futures,
ExchangeId::LseFutures,
MarketDataInstrumentKind::Cfd,
),
] {
assert_eq!(dataset.exchange_id(), exchange, "{dataset:?}");
assert_eq!(dataset.market_data_kind(), kind, "{dataset:?}");
}
}
#[test]
fn test_dataset_catalog_str_roundtrip() {
for dataset in LseDataset::ALL {
assert_eq!(
LseDataset::from_catalog_str(dataset.as_catalog_str()).unwrap(),
dataset
);
}
for reference in ["bonds", "sovereign_yields", "economics", "credit_indices"] {
assert!(matches!(
LseDataset::from_catalog_str(reference).unwrap_err(),
LseError::UnknownDataset(_)
));
}
}
const PROVIDER_ADVERTISED_TIMEFRAMES: [&str; 14] = [
"1s", "5s", "15s", "30s", "1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d", "1w", "1mo",
];
#[test]
fn test_supported_intervals_match_the_providers_own_list() {
let mapped = CandleInterval::ALL
.into_iter()
.filter_map(candle_interval_str)
.collect::<Vec<_>>();
assert_eq!(mapped, PROVIDER_ADVERTISED_TIMEFRAMES);
}
#[test]
fn test_month_is_spelled_differently_from_the_shared_enum() {
assert_eq!(candle_interval_str(CandleInterval::Month1), Some("1mo"));
assert_eq!(CandleInterval::Month1.as_str(), "1M");
}
#[test]
fn test_unserved_resolutions_are_reported_unsupported() {
for interval in [
CandleInterval::Hour2,
CandleInterval::Hour6,
CandleInterval::Hour8,
CandleInterval::Hour12,
CandleInterval::Day3,
] {
assert!(
!supports_candle_interval(interval),
"{interval} is not published by this provider"
);
}
}
#[test]
fn test_sub_minute_resolutions_are_supported() {
for interval in [
CandleInterval::Sec1,
CandleInterval::Sec5,
CandleInterval::Sec15,
CandleInterval::Sec30,
] {
assert!(supports_candle_interval(interval), "{interval}");
}
}
#[test]
fn test_every_dataset_maps_to_a_declared_support_arm() {
for dataset in LseDataset::ALL {
assert!(
crate::subscription::exchange_supports_instrument_kind(
dataset.exchange_id(),
&dataset.market_data_kind()
),
"{dataset:?} maps to an unsupported (exchange, kind) pair"
);
}
}
mod market_symbol_reconstruction {
use super::*;
use crate::exchange::lse::{
LseCfd, LseServerCfd, LseServerCrypto, LseServerEquities, LseServerFutures, LseServerFx,
};
use crate::subscription::trade::PublicTrades;
fn market_of<Server>(base: &str, quote: &str, kind: MarketDataInstrumentKind) -> LseMarket
where
Server: LseServer,
{
let subscription: Subscription<Lse<Server>, MarketDataInstrument, PublicTrades> =
Subscription::from((Lse::<Server>::default(), base, quote, kind, PublicTrades));
subscription.id()
}
#[test]
fn pair_shaped_datasets_reconstruct_base_slash_quote() {
assert_eq!(
market_of::<LseServerFx>("eur", "usd", MarketDataInstrumentKind::Spot).as_ref(),
"EUR/USD"
);
assert_eq!(
market_of::<LseServerCrypto>("btc", "usd", MarketDataInstrumentKind::Spot).as_ref(),
"BTC/USD"
);
assert_eq!(
market_of::<LseServerCfd>("spx500", "usd", MarketDataInstrumentKind::Cfd).as_ref(),
"SPX500/USD"
);
}
#[test]
fn bare_shaped_datasets_reconstruct_the_ticker_alone() {
assert_eq!(
market_of::<LseServerEquities>("aapl", "usd", MarketDataInstrumentKind::Spot)
.as_ref(),
"AAPL"
);
}
#[test]
fn a_venue_suffix_survives_symbol_reconstruction() {
assert_eq!(
market_of::<LseServerEquities>("bp.l", "gbx", MarketDataInstrumentKind::Spot)
.as_ref(),
"BP.L"
);
assert_eq!(
market_of::<LseServerFutures>("es.f", "usd", MarketDataInstrumentKind::Cfd)
.as_ref(),
"ES.F"
);
}
#[test]
fn reconstruction_uppercases_however_the_instrument_was_registered() {
assert_eq!(
market_of::<LseServerFx>("EuR", "uSd", MarketDataInstrumentKind::Spot).as_ref(),
"EUR/USD"
);
}
#[test]
fn reconstruction_inverts_the_underlying_split() {
fn round_trips<Server>(symbol: &str)
where
Server: LseServer,
{
let split = underlying(symbol);
let rebuilt = market_symbol(
Server::SYMBOL_SHAPE,
&AssetNameInternal::new(split.base.name().clone()),
&AssetNameInternal::new(split.quote.name().clone()),
);
assert_eq!(
rebuilt.as_ref(),
symbol,
"{symbol} does not survive a round trip through {:?}, the shape its dataset \
declares",
Server::SYMBOL_SHAPE,
);
}
round_trips::<LseServerFx>("EUR/USD");
round_trips::<LseServerCrypto>("BTC/USD");
round_trips::<LseServerCfd>("VIX/USD");
round_trips::<LseServerEquities>("AAPL");
round_trips::<LseServerEquities>("BP.L");
round_trips::<LseServerFutures>("ES.F");
}
#[test]
fn an_exchange_named_instrument_is_taken_verbatim() {
assert_eq!(
market_of_exchange_named("XAU/USD").as_ref(),
"XAU/USD",
"a correctly spelled exchange name must pass through untouched",
);
}
#[test]
fn an_exchange_named_instrument_is_uppercased_like_a_reconstructed_one() {
assert_eq!(market_of_exchange_named("xau/usd").as_ref(), "XAU/USD");
assert_eq!(market_of_exchange_named("bp.l").as_ref(), "BP.L");
}
fn market_of_exchange_named(name_exchange: &str) -> LseMarket {
let subscription = Subscription {
exchange: LseCfd::default(),
instrument: MarketInstrumentData {
key: 0_usize,
name_exchange: InstrumentNameExchange::new(name_exchange),
kind: MarketDataInstrumentKind::Cfd,
},
kind: PublicTrades,
};
subscription.id()
}
}
}