use super::{
market::LseDataset,
resume::{LseResumeKey, LseResumeState, epoch_seconds, subscription_id},
};
use crate::{
Identifier,
exchange::Connector,
instrument::InstrumentData,
subscriber::{
Subscribed, Subscriber,
mapper::{SubscriptionMapper, WebSocketSubMapper},
validator::SubscriptionValidator,
},
subscription::{Subscription, SubscriptionKind, SubscriptionMeta},
};
use chrono::{DateTime, Utc};
use futures::{SinkExt, StreamExt};
use rustrade_instrument::exchange::ExchangeId;
use rustrade_integration::{
error::SocketError,
protocol::websocket::{WebSocket, WsMessage, connect},
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use smol_str::SmolStr;
use std::{collections::HashMap, env, fmt, sync::Arc, time::Duration};
use tracing::{debug, warn};
const API_KEY_ENV: &str = "LSE_API_KEY";
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 = env::var(API_KEY_ENV).map_err(|error| {
SocketError::Subscribe(match error {
env::VarError::NotPresent => format!("{API_KEY_ENV} is not set"),
env::VarError::NotUnicode(_) => {
format!("{API_KEY_ENV} is set but is not valid UTF-8")
}
})
})?;
Ok(Self::new(api_key))
}
}
#[derive(Clone, Debug)]
pub struct LseSubscriber {
credentials: LseCredentials,
resume: Option<Arc<LseResumeState>>,
}
impl LseSubscriber {
pub fn new(credentials: LseCredentials) -> Self {
Self {
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
}
pub(super) fn resume_state(&self) -> Option<Arc<LseResumeState>> {
self.resume.clone()
}
fn resume_start(
&self,
exchange: ExchangeId,
market: &str,
kind: &'static str,
) -> Option<DateTime<Utc>> {
self.resume
.as_ref()
.and_then(|state| {
state.watermark(&LseResumeKey::new(exchange, subscription_id(market), kind))
})
.map(|watermark| watermark.time_exchange)
}
}
impl Subscriber for LseSubscriber {
type SubMapper = WebSocketSubMapper;
async fn subscribe<Exchange, Instrument, Kind>(
&self,
subscriptions: &[Subscription<Exchange, Instrument, Kind>],
) -> Result<Subscribed<Instrument::Key>, 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 markets = requested_markets::<Exchange, Instrument, Kind>(subscriptions);
let mut websocket = connect(url).await?;
let authenticated = authenticate(&mut websocket, &self.credentials).await?;
debug!(
%exchange,
tier = ?authenticated.tier,
offered = authenticated.symbols.len(),
"authenticated to London Strategic Edge WebSocket",
);
check_subscription_cap(exchange, &markets, authenticated.max_subscriptions)?;
check_symbols_are_offered(exchange, &markets, &authenticated)?;
let SubscriptionMeta {
instrument_map,
ws_subscriptions: _,
} = Self::SubMapper::map::<Exchange, Instrument, Kind>(subscriptions);
let kind = subscriptions
.first()
.map(|subscription| subscription.kind.as_str());
for market in &markets {
let start = kind.and_then(|kind| self.resume_start(exchange, market, kind));
let message = subscribe_message(market, start);
debug!(%exchange, payload = ?message, "sending London Strategic Edge subscription");
websocket
.send(message)
.await
.map_err(|error| SocketError::WebSocket(Box::new(error)))?;
}
let (map, buffered_websocket_events) = Exchange::SubValidator::validate::<
Exchange,
Instrument::Key,
Kind,
>(instrument_map, &mut websocket)
.await?;
debug!(%exchange, "London Strategic Edge subscriptions confirmed");
Ok(Subscribed {
websocket,
map,
buffered_websocket_events,
})
}
}
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())
}
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 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 !markets.contains(&symbol) {
markets.push(symbol);
}
}
markets
}
fn check_subscription_cap(
exchange: ExchangeId,
markets: &[SmolStr],
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 markets.len() > cap {
return Err(SocketError::Subscribe(format!(
"{} symbols requested on {exchange} but this connection holds at most {cap} - the \
provider's rejection does not name the symbols it refuses, so there is no partial \
subscription to recover and the batch is rejected before it is sent",
markets.len(),
)));
}
Ok(())
}
fn check_symbols_are_offered(
exchange: ExchangeId,
markets: &[SmolStr],
authenticated: &LseAuthenticated,
) -> Result<(), SocketError> {
if authenticated.symbols.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 offered: HashMap<&str, Option<&str>> = authenticated
.symbols
.iter()
.map(|entry| (entry.symbol.as_str(), entry.category.as_deref()))
.collect();
let expected = expected_categories(exchange);
let mut unknown = Vec::new();
let mut miscategorised = Vec::new();
for market in markets {
match offered.get(market.as_str()) {
None => unknown.push(market.as_str()),
Some(category) => {
if let Some(category) = *category
&& !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))
}
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::{book::OrderBooksL1, 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 a_subscriber_without_resume_asks_for_no_replay_window() {
let subscriber = LseSubscriber::new(LseCredentials::new("key"));
assert_eq!(
subscriber.resume_start(ExchangeId::LseCrypto, "BTC/USD", PublicTrades.as_str()),
None
);
}
#[test]
fn a_resuming_subscriber_asks_for_a_window_only_where_it_has_delivered() {
let state = Arc::new(LseResumeState::new());
state.record(
&LseResumeKey::new(
ExchangeId::LseCrypto,
subscription_id("BTC/USD"),
PublicTrades.as_str(),
),
at("2026-08-14T10:16:55.161234Z"),
);
let subscriber = LseSubscriber::new(LseCredentials::new("key")).with_resume(state);
assert_eq!(
subscriber.resume_start(ExchangeId::LseCrypto, "BTC/USD", PublicTrades.as_str()),
Some(at("2026-08-14T10:16:55.161234Z")),
);
assert_eq!(
subscriber.resume_start(ExchangeId::LseCrypto, "ETH/USD", PublicTrades.as_str()),
None
);
}
#[test]
fn a_window_is_asked_for_per_kind_rather_than_per_symbol() {
let state = Arc::new(LseResumeState::new());
state.record(
&LseResumeKey::new(
ExchangeId::LseCrypto,
subscription_id("BTC/USD"),
PublicTrades.as_str(),
),
at("2026-08-14T10:16:55.161234Z"),
);
let subscriber = LseSubscriber::new(LseCredentials::new("key")).with_resume(state);
assert_eq!(
subscriber.resume_start(ExchangeId::LseCrypto, "BTC/USD", OrderBooksL1.as_str()),
None,
"a kind that has delivered nothing must subscribe live, not from another kind's mark",
);
}
#[test]
fn a_window_is_asked_for_per_dataset_rather_than_per_symbol() {
let state = Arc::new(LseResumeState::new());
state.record(
&LseResumeKey::new(
ExchangeId::LseEquities,
subscription_id("AAPL"),
PublicTrades.as_str(),
),
at("2026-08-14T10:16:55.161234Z"),
);
let subscriber = LseSubscriber::new(LseCredentials::new("key")).with_resume(state);
assert_eq!(
subscriber.resume_start(ExchangeId::LseEquities, "AAPL", PublicTrades.as_str()),
Some(at("2026-08-14T10:16:55.161234Z")),
);
assert_eq!(
subscriber.resume_start(ExchangeId::LseFutures, "AAPL", PublicTrades.as_str()),
None,
"a dataset that has delivered nothing must subscribe live, not from another's mark",
);
}
#[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 a_batch_within_the_cap_is_accepted() {
let requested = markets(&["EUR/USD", "GBP/USD"]);
assert!(check_subscription_cap(ExchangeId::LseFx, &requested, 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, 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, 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, &frame).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, &frame)
.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, &frame).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, &frame)
.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, &frame).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, &frame).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, &frame).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, &frame).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, &frame)
.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, &frame).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"));
}
}