use super::{
connection::{AttachRequest, LseAttachment, LseConnection},
mapper::LseSubMapper,
market::LseDataset,
resume::{LseResumeState, epoch_seconds},
};
use crate::exchange::lse::transport::api_key_from_env;
use crate::exchange::osi;
use crate::{
Identifier,
exchange::Connector,
instrument::InstrumentData,
subscriber::{Subscribed, Subscriber, mapper::SubscriptionMapper},
subscription::{Subscription, SubscriptionKind, SubscriptionMeta},
};
use chrono::{DateTime, Utc};
use fnv::{FnvHashMap, FnvHashSet};
use futures::{SinkExt, StreamExt};
use rustrade_instrument::exchange::ExchangeId;
use rustrade_integration::{
error::SocketError,
protocol::websocket::{WebSocket, WsMessage},
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use smol_str::SmolStr;
use std::{fmt, sync::Arc, time::Duration};
use tracing::{debug, warn};
const AUTH_TIMEOUT: Duration = Duration::from_secs(15);
#[derive(Clone)]
pub struct LseCredentials {
api_key: String,
}
impl fmt::Debug for LseCredentials {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("LseCredentials")
.field("api_key", &"[REDACTED]")
.finish()
}
}
impl LseCredentials {
pub fn new(api_key: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
}
}
pub fn from_env() -> Result<Self, SocketError> {
let api_key = api_key_from_env().map_err(SocketError::Subscribe)?;
Ok(Self::new(api_key))
}
}
#[derive(Clone, Debug)]
pub struct LseSubscriber {
connection: Arc<LseConnection>,
resume: Option<Arc<LseResumeState>>,
}
impl LseSubscriber {
pub fn new(credentials: LseCredentials) -> Self {
Self {
connection: Arc::new(LseConnection::new(credentials)),
resume: None,
}
}
pub fn from_env() -> Result<Self, SocketError> {
Ok(Self::new(LseCredentials::from_env()?))
}
#[must_use]
pub fn with_resume(mut self, state: Arc<LseResumeState>) -> Self {
self.resume = Some(state);
self
}
#[cfg(test)]
pub(crate) fn connection_handles(&self) -> usize {
Arc::strong_count(&self.connection)
}
}
impl Subscriber for LseSubscriber {
type SubMapper = LseSubMapper;
type Transport = LseAttachment;
async fn subscribe<Exchange, Instrument, Kind>(
&self,
subscriptions: &[Subscription<Exchange, Instrument, Kind>],
) -> Result<Subscribed<Instrument::Key, Self::Transport>, SocketError>
where
Exchange: Connector + Send + Sync,
Kind: SubscriptionKind + Send + Sync,
Instrument: InstrumentData,
Subscription<Exchange, Instrument, Kind>:
Identifier<Exchange::Channel> + Identifier<Exchange::Market>,
{
let exchange = Exchange::ID;
let url = Exchange::url()?;
debug!(%exchange, %url, ?subscriptions, "subscribing to London Strategic Edge WebSocket");
let Some(kind) = subscriptions
.first()
.map(|subscription| subscription.kind.as_str())
else {
return Err(SocketError::Subscribe(format!(
"no subscriptions were given to subscribe to on {exchange}"
)));
};
let markets = requested_markets::<Exchange, Instrument, Kind>(subscriptions);
let underlyings = subscribes_per_underlying(exchange)
.then(|| option_underlyings(exchange, &markets))
.transpose()?;
if underlyings.is_some() && self.resume.is_some() {
warn!(
%exchange,
"London Strategic Edge option contracts do not resume; this stream will not \
replay what a reconnect missed",
);
}
let SubscriptionMeta {
instrument_map,
ws_subscriptions: _,
} = Self::SubMapper::map::<Exchange, Instrument, Kind>(subscriptions);
let transport = self
.connection
.attach(AttachRequest {
exchange,
url,
kind,
markets,
underlyings,
timeout: Exchange::subscription_timeout(),
resume: self.resume.clone(),
})
.await?;
debug!(%exchange, "attached to the London Strategic Edge connection");
Ok(Subscribed {
transport,
map: instrument_map,
buffered_websocket_events: Vec::new(),
})
}
}
pub(super) fn subscribe_message(symbol: &str, start: Option<DateTime<Utc>>) -> WsMessage {
let payload = match start {
Some(start) => json!({
"action": "subscribe",
"symbol": symbol,
"start": epoch_seconds(start),
}),
None => json!({ "action": "subscribe", "symbol": symbol }),
};
WsMessage::text(payload.to_string())
}
pub(super) fn subscribe_options_message(underlying: &str) -> WsMessage {
WsMessage::text(json!({ "action": "subscribe_options", "underlying": underlying }).to_string())
}
pub(super) fn unsubscribe_message(symbol: &str) -> WsMessage {
WsMessage::text(json!({ "action": "unsubscribe", "symbol": symbol }).to_string())
}
pub(super) fn unsubscribe_options_message(underlying: &str) -> WsMessage {
WsMessage::text(
json!({ "action": "unsubscribe_options", "underlying": underlying }).to_string(),
)
}
pub(super) fn subscribes_per_underlying(exchange: ExchangeId) -> bool {
exchange == ExchangeId::LseOptions
}
pub(super) fn option_underlyings(
exchange: ExchangeId,
markets: &[SmolStr],
) -> Result<Vec<SmolStr>, SocketError> {
let mut seen = FnvHashSet::default();
let mut underlyings = Vec::<SmolStr>::new();
let mut unspellable = Vec::new();
for market in markets {
match osi::root(market) {
Some(root) => {
if seen.insert(root) {
underlyings.push(SmolStr::new(root));
}
}
None => unspellable.push(market.as_str()),
}
}
if !unspellable.is_empty() {
return Err(SocketError::Subscribe(format!(
"{exchange} subscribes option contracts by OSI symbol (root, YYMMDD, C or P, strike in \
thousandths as eight digits), and {unspellable:?} have none - each must be an option \
instrument whose strike is positive, has at most three decimal places and is below \
100,000",
)));
}
Ok(underlyings)
}
fn requested_markets<Exchange, Instrument, Kind>(
subscriptions: &[Subscription<Exchange, Instrument, Kind>],
) -> Vec<SmolStr>
where
Exchange: Connector,
Subscription<Exchange, Instrument, Kind>: Identifier<Exchange::Market>,
{
let mut seen =
FnvHashSet::<SmolStr>::with_capacity_and_hasher(subscriptions.len(), Default::default());
let mut markets = Vec::<SmolStr>::with_capacity(subscriptions.len());
for subscription in subscriptions {
let market = Identifier::<Exchange::Market>::id(subscription);
let symbol = SmolStr::new(market.as_ref());
if seen.insert(symbol.clone()) {
markets.push(symbol);
}
}
markets
}
pub(super) fn check_subscription_cap(
exchange: ExchangeId,
added: usize,
held: usize,
max_subscriptions: Option<u32>,
) -> Result<(), SocketError> {
let Some(max) = max_subscriptions else {
warn!(
%exchange,
"London Strategic Edge reported no subscription cap; the batch cannot be checked \
before it is sent",
);
return Ok(());
};
let cap = usize::try_from(max).unwrap_or(usize::MAX);
if held.saturating_add(added) > cap {
let unit = if subscribes_per_underlying(exchange) {
"option underlyings"
} else {
"symbols"
};
return Err(SocketError::Subscribe(format!(
"{added} {unit} requested on {exchange} beyond the {held} subscriptions this \
connection already holds, but it holds at most {cap} - the cap is shared by every \
stream opened by this subscriber and its clones, and the provider's rejection does \
not name the subscriptions it refuses, so there is no partial subscription to recover \
and the batch is rejected before it is sent",
)));
}
Ok(())
}
#[derive(Debug, Default)]
pub(super) struct OfferedSymbols(FnvHashMap<SmolStr, Option<SmolStr>>);
impl OfferedSymbols {
pub(super) fn new(symbols: Vec<LseSymbol>) -> Self {
Self(
symbols
.into_iter()
.map(|entry| (entry.symbol, entry.category))
.collect(),
)
}
}
pub(super) fn check_symbols_are_offered(
exchange: ExchangeId,
markets: &[SmolStr],
offered: &OfferedSymbols,
) -> Result<(), SocketError> {
if offered.0.is_empty() {
warn!(
%exchange,
"London Strategic Edge published no symbol list; a typo'd symbol will be confirmed and \
then never tick",
);
return Ok(());
}
let expected = expected_categories(exchange);
let mut unknown = Vec::new();
let mut miscategorised = Vec::new();
for market in markets {
match offered.0.get(market.as_str()) {
None => unknown.push(market.as_str()),
Some(category) => {
if let Some(category) = category.as_deref()
&& !expected.is_empty()
&& !expected.contains(&category)
&& is_published_category(category)
{
miscategorised.push(format!("{market} is {category}"));
}
}
}
}
if !unknown.is_empty() {
return Err(SocketError::Subscribe(format!(
"London Strategic Edge does not offer {unknown:?} on its WebSocket - a symbol may be \
reachable through the catalog or the candle store and still be absent here, and \
subscribing to one the provider does not offer is CONFIRMED rather than rejected, \
then never ticks, while holding a subscription slot for the life of the connection",
)));
}
if !miscategorised.is_empty() {
return Err(SocketError::Subscribe(format!(
"London Strategic Edge categorises {miscategorised:?}, which does not match {exchange} \
(expected {expected:?}) - the symbol exists, but on a different dataset than the one \
being subscribed",
)));
}
Ok(())
}
fn expected_categories(exchange: ExchangeId) -> Vec<&'static str> {
LseDataset::ALL
.into_iter()
.filter(|dataset| dataset.exchange_id() == exchange)
.filter_map(|dataset| dataset.ws_category())
.collect()
}
fn is_published_category(category: &str) -> bool {
LseDataset::ALL
.into_iter()
.any(|dataset| dataset.ws_category() == Some(category))
}
pub(super) async fn authenticate(
websocket: &mut WebSocket,
credentials: &LseCredentials,
) -> Result<LseAuthenticated, SocketError> {
let auth = json!({ "action": "auth", "api_key": credentials.api_key }).to_string();
websocket
.send(WsMessage::text(auth))
.await
.map_err(|error| SocketError::WebSocket(Box::new(error)))?;
tokio::time::timeout(AUTH_TIMEOUT, async {
loop {
match websocket.next().await {
Some(Ok(WsMessage::Text(text))) => {
if let Some(result) = read_auth_frame(text.as_str()) {
return result;
}
}
Some(Ok(WsMessage::Binary(bytes))) => {
if let Ok(text) = std::str::from_utf8(&bytes)
&& let Some(result) = read_auth_frame(text)
{
return result;
}
}
Some(Ok(WsMessage::Close(frame))) => {
return Err(SocketError::Subscribe(format!(
"WebSocket closed during London Strategic Edge authentication: {frame:?}"
)));
}
Some(Ok(_)) => {}
Some(Err(error)) => return Err(SocketError::WebSocket(Box::new(error))),
None => {
return Err(SocketError::Subscribe(
"WebSocket closed before London Strategic Edge authentication completed"
.to_owned(),
));
}
}
}
})
.await
.map_err(|_| {
SocketError::Subscribe(format!(
"London Strategic Edge authentication timed out after {AUTH_TIMEOUT:?}"
))
})?
}
fn read_auth_frame(text: &str) -> Option<Result<LseAuthenticated, SocketError>> {
match serde_json::from_str::<LseAuthFrame>(text).ok()? {
LseAuthFrame::Authenticated(authenticated) => Some(Ok(authenticated)),
LseAuthFrame::Error { code, message } => Some(Err(SocketError::Subscribe(format!(
"London Strategic Edge rejected authentication ({}): {}",
code.as_deref().unwrap_or("unknown"),
message.as_deref().unwrap_or("no message"),
)))),
LseAuthFrame::Other => None,
}
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum LseAuthFrame {
Authenticated(LseAuthenticated),
Error {
#[serde(default)]
code: Option<SmolStr>,
#[serde(default)]
message: Option<SmolStr>,
},
#[serde(other)]
Other,
}
#[derive(Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct LseAuthenticated {
#[serde(default)]
pub tier: Option<SmolStr>,
#[serde(default)]
pub max_subscriptions: Option<u32>,
#[serde(default)]
pub symbols: Vec<LseSymbol>,
}
impl fmt::Debug for LseAuthenticated {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("LseAuthenticated")
.field("tier", &self.tier)
.field("max_subscriptions", &self.max_subscriptions)
.field("symbols", &format_args!("<{} offered>", self.symbols.len()))
.finish()
}
}
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub struct LseSymbol {
pub symbol: SmolStr,
#[serde(default)]
pub name: Option<SmolStr>,
#[serde(default)]
pub category: Option<SmolStr>,
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::*;
use crate::exchange::lse::LseFx;
use crate::subscription::trade::PublicTrades;
use rustrade_instrument::instrument::market_data::MarketDataInstrument;
use rustrade_instrument::instrument::market_data::kind::MarketDataInstrumentKind;
fn authenticated(symbols: &[(&str, Option<&str>)], max: Option<u32>) -> LseAuthenticated {
LseAuthenticated {
tier: Some("registered".into()),
max_subscriptions: max,
symbols: symbols
.iter()
.map(|(symbol, category)| LseSymbol {
symbol: SmolStr::new(symbol),
name: None,
category: category.map(SmolStr::new),
})
.collect(),
}
}
fn markets(symbols: &[&str]) -> Vec<SmolStr> {
symbols.iter().map(SmolStr::new).collect()
}
fn at(spelling: &str) -> DateTime<Utc> {
spelling.parse::<DateTime<Utc>>().unwrap()
}
fn micros_as_f64(time: DateTime<Utc>) -> f64 {
time.timestamp_micros() as f64
}
#[test]
fn credentials_do_not_print_the_key() {
let printed = format!("{:?}", LseCredentials::new("super-secret-key"));
assert!(!printed.contains("super-secret-key"), "{printed}");
assert!(printed.contains("REDACTED"), "{printed}");
}
#[test]
fn a_subscribe_payload_names_the_symbol_and_nothing_else() {
let WsMessage::Text(payload) = subscribe_message("EUR/USD", None) else {
panic!("expected a text payload");
};
let payload: serde_json::Value = serde_json::from_str(payload.as_str()).unwrap();
assert_eq!(payload, json!({"action": "subscribe", "symbol": "EUR/USD"}));
}
#[test]
fn a_subscribe_payload_carrying_a_resume_point_sends_it_as_epoch_seconds() {
let start = at("2026-08-14T10:16:55.161234Z");
let WsMessage::Text(payload) = subscribe_message("BTC/USD", Some(start)) else {
panic!("expected a text payload");
};
let payload: serde_json::Value = serde_json::from_str(payload.as_str()).unwrap();
let payload = payload.as_object().unwrap();
assert_eq!(payload.len(), 3, "{payload:?}");
assert_eq!(payload["action"], json!("subscribe"));
assert_eq!(payload["symbol"], json!("BTC/USD"));
let seconds = payload["start"]
.as_f64()
.unwrap_or_else(|| panic!("start must be a JSON number, not {:?}", payload["start"]));
let drift = seconds * 1_000_000.0 - micros_as_f64(start);
assert!(drift.abs() < 0.5, "start drifted {drift} microseconds");
}
#[test]
fn repeated_symbols_produce_one_subscribe_each() {
let subscription = |base: &str| -> Subscription<LseFx, MarketDataInstrument, PublicTrades> {
Subscription::from((
LseFx::default(),
base,
"usd",
MarketDataInstrumentKind::Spot,
PublicTrades,
))
};
let subscriptions = [
subscription("eur"),
subscription("gbp"),
subscription("eur"),
];
assert_eq!(
requested_markets(&subscriptions),
markets(&["EUR/USD", "GBP/USD"])
);
}
#[test]
fn an_options_subscribe_payload_names_the_underlying_and_nothing_else() {
let WsMessage::Text(payload) = subscribe_options_message("SPY") else {
panic!("expected a text payload");
};
let payload: serde_json::Value = serde_json::from_str(payload.as_str()).unwrap();
assert_eq!(
payload,
json!({"action": "subscribe_options", "underlying": "SPY"})
);
}
#[test]
fn only_the_options_dataset_subscribes_per_underlying() {
assert!(subscribes_per_underlying(ExchangeId::LseOptions));
for exchange in [
ExchangeId::LseFx,
ExchangeId::LseCrypto,
ExchangeId::LseEquities,
ExchangeId::LseFutures,
ExchangeId::LseCfd,
] {
assert!(!subscribes_per_underlying(exchange), "{exchange}");
}
}
#[test]
fn contracts_sharing_an_underlying_collapse_into_one_subscription() {
let requested = markets(&[
"SPY260930C00700000",
"QQQ260930P00500000",
"SPY260930P00650000",
"SPY261016C00700000",
]);
assert_eq!(
option_underlyings(ExchangeId::LseOptions, &requested).unwrap(),
markets(&["SPY", "QQQ"])
);
}
#[test]
fn a_contract_with_no_osi_symbol_fails_the_batch_naming_it() {
let requested = markets(&["SPY260930C00700000", "SPY", "SPY (no OSI symbol for spot)"]);
let error = option_underlyings(ExchangeId::LseOptions, &requested)
.unwrap_err()
.to_string();
assert!(error.contains(r#""SPY""#), "{error}");
assert!(error.contains("no OSI symbol for spot"), "{error}");
assert!(!error.contains("SPY260930C00700000"), "{error}");
}
#[test]
fn an_over_cap_options_batch_is_reported_in_underlyings() {
let requested = markets(&["SPY", "QQQ", "AAPL"]);
let error = check_subscription_cap(ExchangeId::LseOptions, requested.len(), 0, Some(2))
.unwrap_err()
.to_string();
assert!(error.contains("3 option underlyings"), "{error}");
assert!(error.contains("at most 2"), "{error}");
}
#[test]
fn slots_held_by_other_streams_count_against_the_cap() {
let error = check_subscription_cap(ExchangeId::LseFx, 2, 15, Some(16))
.unwrap_err()
.to_string();
assert!(error.contains("2 symbols"), "{error}");
assert!(error.contains("already holds"), "{error}");
assert!(error.contains("shared by every stream"), "{error}");
assert!(check_subscription_cap(ExchangeId::LseFx, 1, 15, Some(16)).is_ok());
}
#[test]
fn a_batch_within_the_cap_is_accepted() {
let requested = markets(&["EUR/USD", "GBP/USD"]);
assert!(check_subscription_cap(ExchangeId::LseFx, requested.len(), 0, Some(2)).is_ok());
}
#[test]
fn a_batch_over_the_cap_is_rejected_before_it_is_sent() {
let requested = markets(&["EUR/USD", "GBP/USD", "XAU/USD"]);
let error = check_subscription_cap(ExchangeId::LseFx, requested.len(), 0, Some(2))
.unwrap_err()
.to_string();
assert!(error.contains("at most 2"), "{error}");
}
#[test]
fn an_unreported_cap_does_not_reject_the_batch() {
let requested = markets(&["EUR/USD"]);
assert!(check_subscription_cap(ExchangeId::LseFx, requested.len(), 0, None).is_ok());
}
#[test]
fn an_offered_symbol_passes_the_guard() {
let frame = authenticated(&[("EUR/USD", Some("Forex"))], Some(16));
let requested = markets(&["EUR/USD"]);
assert!(
check_symbols_are_offered(
ExchangeId::LseFx,
&requested,
&OfferedSymbols::new(frame.symbols)
)
.is_ok()
);
}
#[test]
fn a_symbol_the_provider_does_not_offer_fails_the_batch() {
let frame = authenticated(&[("EUR/USD", Some("Forex"))], Some(16));
let requested = markets(&["EUR/USD", "NOPE_XYZ"]);
let error = check_symbols_are_offered(
ExchangeId::LseFx,
&requested,
&OfferedSymbols::new(frame.symbols),
)
.unwrap_err()
.to_string();
assert!(error.contains("NOPE_XYZ"), "{error}");
}
#[test]
fn an_absent_symbol_list_degrades_to_a_warning_rather_than_failing_the_batch() {
let frame = authenticated(&[], Some(16));
let requested = markets(&["NOPE_XYZ"]);
assert!(
check_symbols_are_offered(
ExchangeId::LseFx,
&requested,
&OfferedSymbols::new(frame.symbols)
)
.is_ok()
);
}
#[test]
fn a_contradicting_category_fails_the_batch() {
let frame = authenticated(&[("AAPL", Some("Stocks"))], Some(16));
let requested = markets(&["AAPL"]);
let error = check_symbols_are_offered(
ExchangeId::LseFx,
&requested,
&OfferedSymbols::new(frame.symbols),
)
.unwrap_err()
.to_string();
assert!(error.contains("AAPL is Stocks"), "{error}");
}
#[test]
fn an_unrecognised_label_is_not_a_contradiction_even_where_the_labels_are_complete() {
let expected = expected_categories(ExchangeId::LseFx);
assert_eq!(
expected,
["Forex"],
"this exchange's expectation is complete, which is the point: {expected:?}"
);
let frame = authenticated(&[("EUR/USD", Some("FX Majors"))], Some(16));
let requested = markets(&["EUR/USD"]);
assert!(
check_symbols_are_offered(
ExchangeId::LseFx,
&requested,
&OfferedSymbols::new(frame.symbols)
)
.is_ok()
);
}
#[test]
fn a_missing_category_is_not_a_contradiction() {
let frame = authenticated(&[("EUR/USD", None)], Some(16));
let requested = markets(&["EUR/USD"]);
assert!(
check_symbols_are_offered(
ExchangeId::LseFx,
&requested,
&OfferedSymbols::new(frame.symbols)
)
.is_ok()
);
}
#[test]
fn a_dataset_with_no_published_category_accepts_whatever_is_reported() {
assert!(expected_categories(ExchangeId::LseFutures).is_empty());
let frame = authenticated(&[("ES.F", Some("Anything"))], Some(16));
let requested = markets(&["ES.F"]);
assert!(
check_symbols_are_offered(
ExchangeId::LseFutures,
&requested,
&OfferedSymbols::new(frame.symbols)
)
.is_ok()
);
}
#[test]
fn a_label_no_dataset_is_known_by_is_not_a_contradiction() {
let expected = expected_categories(ExchangeId::LseCfd);
assert!(expected.contains(&"Indices"), "{expected:?}");
assert!(
!expected.contains(&"Volatility"),
"the expectation is partial by construction: {expected:?}"
);
let frame = authenticated(&[("VIX/USD", Some("Volatility"))], Some(16));
let requested = markets(&["VIX/USD"]);
assert!(
check_symbols_are_offered(
ExchangeId::LseCfd,
&requested,
&OfferedSymbols::new(frame.symbols)
)
.is_ok()
);
}
#[test]
fn a_label_belonging_to_another_dataset_still_fails_the_batch() {
let frame = authenticated(&[("AAPL", Some("Stocks"))], Some(16));
let requested = markets(&["AAPL"]);
let error = check_symbols_are_offered(
ExchangeId::LseCfd,
&requested,
&OfferedSymbols::new(frame.symbols),
)
.unwrap_err()
.to_string();
assert!(error.contains("AAPL is Stocks"), "{error}");
}
#[test]
fn stocks_and_etfs_both_satisfy_the_equities_identifier() {
let expected = expected_categories(ExchangeId::LseEquities);
assert!(expected.contains(&"Stocks"), "{expected:?}");
assert!(expected.contains(&"ETFs"), "{expected:?}");
let frame = authenticated(&[("AAPL", Some("Stocks")), ("SPY", Some("ETFs"))], Some(16));
let requested = markets(&["AAPL", "SPY"]);
assert!(
check_symbols_are_offered(
ExchangeId::LseEquities,
&requested,
&OfferedSymbols::new(frame.symbols)
)
.is_ok()
);
}
#[test]
fn the_welcome_frame_is_not_mistaken_for_an_auth_response() {
let welcome = r#"{"type":"welcome","message":"hello","symbols_available":8516}"#;
assert!(read_auth_frame(welcome).is_none());
}
#[test]
fn an_auth_response_yields_the_symbol_list_and_the_cap() {
let input = r#"{"type":"authenticated","tier":"registered","key_type":"main",
"max_subscriptions":16,"symbols":[
{"symbol":"BTC/USD","name":"Bitcoin","category":"Crypto"},
{"symbol":"ES.F"}
]}"#;
let frame = read_auth_frame(input).unwrap().unwrap();
assert_eq!(frame.tier.as_deref(), Some("registered"));
assert_eq!(frame.max_subscriptions, Some(16));
assert_eq!(frame.symbols.len(), 2);
assert_eq!(frame.symbols[1].symbol, "ES.F");
assert!(frame.symbols[1].name.is_none());
assert!(frame.symbols[1].category.is_none());
}
#[test]
fn a_rejected_key_is_reported_rather_than_awaited() {
let input = r#"{"type":"error","code":"INVALID_KEY","message":"invalid api key"}"#;
let error = read_auth_frame(input).unwrap().unwrap_err().to_string();
assert!(error.contains("INVALID_KEY"), "{error}");
}
#[test]
fn the_auth_frame_summarises_its_symbol_list_when_printed() {
let frame = authenticated(&[("BTC/USD", Some("Crypto")), ("ETH/USD", None)], Some(16));
let printed = format!("{frame:?}");
assert!(printed.contains("<2 offered>"), "{printed}");
assert!(!printed.contains("BTC/USD"), "{printed}");
}
#[test]
fn every_dataset_identifier_expects_only_categories_the_provider_publishes() {
for exchange in [
ExchangeId::LseFx,
ExchangeId::LseCrypto,
ExchangeId::LseEquities,
ExchangeId::LseFutures,
ExchangeId::LseCfd,
] {
for category in expected_categories(exchange) {
assert!(
LseDataset::ALL
.into_iter()
.any(|dataset| dataset.ws_category() == Some(category)),
"{exchange} expects an unpublished category {category:?}",
);
}
}
}
#[test]
fn unrelated_datasets_do_not_share_category_expectations() {
assert_eq!(expected_categories(ExchangeId::LseFx), vec!["Forex"]);
assert_eq!(expected_categories(ExchangeId::LseCrypto), vec!["Crypto"]);
assert!(!expected_categories(ExchangeId::LseCfd).contains(&"Forex"));
}
}