use crate::error::Result;
use crate::market::configurable_client::{ConfigurableExchangeClient, venue_error};
use crate::market::descriptor::VenueDescriptor;
use crate::market::orderbook::{
Candle, MarketSnapshot, OhlcClient, OrderBook, OrderBookClient, Ticker, TickerClient, Trade,
TradeHistoryClient,
};
pub const DEFAULT_SNAPSHOT_TRADES: u32 = 50;
pub struct ExchangeClient {
venue_id: String,
venue_name: String,
descriptor: VenueDescriptor,
order_book: Option<Box<dyn OrderBookClient>>,
ticker: Option<Box<dyn TickerClient>>,
trade_history: Option<Box<dyn TradeHistoryClient>>,
ohlc: Option<Box<dyn OhlcClient>>,
assets: Option<ConfigurableExchangeClient>,
}
impl ExchangeClient {
#[deprecated(
since = "0.8.0",
note = "pass the transport: use `ExchangeClient::from_descriptor_with_http`"
)]
pub fn from_descriptor(desc: &VenueDescriptor) -> Self {
Self::from_descriptor_with_http(desc, crate::http::shared::fallback())
}
pub fn from_descriptor_with_http(
desc: &VenueDescriptor,
http: std::sync::Arc<dyn crate::http::HttpClient>,
) -> Self {
let client = ConfigurableExchangeClient::with_http(desc.clone(), http);
let order_book: Option<Box<dyn OrderBookClient>> = if desc.has_order_book() {
Some(Box::new(client.clone()))
} else {
None
};
let ticker: Option<Box<dyn TickerClient>> = if desc.has_ticker() {
Some(Box::new(client.clone()))
} else {
None
};
let trade_history: Option<Box<dyn TradeHistoryClient>> = if desc.has_trades() {
Some(Box::new(client.clone()))
} else {
None
};
let assets = desc.capabilities.assets.is_some().then(|| client.clone());
let ohlc: Option<Box<dyn OhlcClient>> = if desc.has_ohlc() {
Some(Box::new(client))
} else {
None
};
Self {
venue_id: desc.id.clone(),
venue_name: desc.name.clone(),
descriptor: desc.clone(),
order_book,
ticker,
trade_history,
ohlc,
assets,
}
}
pub fn venue_id(&self) -> &str {
&self.venue_id
}
pub fn venue_name(&self) -> &str {
&self.venue_name
}
pub fn venue_symbol(&self, pair: &super::pair::Pair) -> String {
self.descriptor
.format_pair(&pair.base, pair.quote.as_deref())
}
pub fn has_order_book(&self) -> bool {
self.order_book.is_some()
}
pub fn has_ticker(&self) -> bool {
self.ticker.is_some()
}
pub fn has_trade_history(&self) -> bool {
self.trade_history.is_some()
}
pub fn has_ohlc(&self) -> bool {
self.ohlc.is_some()
}
pub async fn fetch_order_book(&self, pair: &str) -> Result<OrderBook> {
self.order_book
.as_ref()
.ok_or_else(|| venue_error(&self.venue_id, "does not support order book"))?
.fetch_order_book(pair)
.await
}
pub async fn fetch_ticker(&self, pair: &str) -> Result<Ticker> {
self.ticker
.as_ref()
.ok_or_else(|| venue_error(&self.venue_id, "does not support ticker"))?
.fetch_ticker(pair)
.await
}
pub async fn fetch_recent_trades(&self, pair: &str, limit: u32) -> Result<Vec<Trade>> {
self.trade_history
.as_ref()
.ok_or_else(|| venue_error(&self.venue_id, "does not support trades"))?
.fetch_recent_trades(pair, limit)
.await
}
pub fn lists_assets(&self) -> bool {
self.assets.is_some()
}
pub async fn fetch_assets(&self) -> Option<Result<Vec<super::assets::AssetInfo>>> {
Some(self.assets.as_ref()?.fetch_assets().await)
}
pub async fn fetch_token_addresses(
&self,
pair: &super::pair::Pair,
) -> Option<Result<Vec<super::assets::TokenAddress>>> {
let client = self.assets.as_ref()?;
let quote = pair
.quote
.clone()
.unwrap_or_else(|| self.descriptor.symbol.default_quote.clone());
Some(
client
.fetch_assets()
.await
.map(|assets| super::assets::token_addresses(&pair.base, "e, &assets)),
)
}
pub async fn fetch_ohlc(&self, pair: &str, interval: &str, limit: u32) -> Result<Vec<Candle>> {
self.ohlc
.as_ref()
.ok_or_else(|| venue_error(&self.venue_id, "does not support OHLC"))?
.fetch_ohlc(pair, interval, limit)
.await
}
pub async fn fetch_market_snapshot(&self, pair: &str, trades_limit: u32) -> MarketSnapshot {
let order_book = if self.has_order_book() {
self.fetch_order_book(pair).await.ok()
} else {
None
};
let ticker = if self.has_ticker() {
self.fetch_ticker(pair).await.ok()
} else {
None
};
let recent_trades = if self.has_trade_history() {
self.fetch_recent_trades(pair, trades_limit).await.ok()
} else {
None
};
MarketSnapshot {
order_book,
ticker,
recent_trades,
}
}
}
impl std::fmt::Debug for ExchangeClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ExchangeClient")
.field("venue_id", &self.venue_id)
.field("venue_name", &self.venue_name)
.field("has_order_book", &self.has_order_book())
.field("has_ticker", &self.has_ticker())
.field("has_trade_history", &self.has_trade_history())
.field("has_ohlc", &self.has_ohlc())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::market::descriptor::{CapabilitySet, SymbolCase, SymbolConfig, VenueDescriptor};
use crate::market::pair::Pair;
use crate::market::registry::VenueRegistry;
use std::collections::HashMap;
fn make_empty_descriptor() -> VenueDescriptor {
VenueDescriptor {
id: "empty".to_string(),
name: "Empty".to_string(),
base_url: "https://example.com".to_string(),
timeout_secs: Some(5),
rate_limit_per_sec: None,
symbol: SymbolConfig {
template: "{base}{quote}".to_string(),
default_quote: "USDT".to_string(),
case: SymbolCase::Upper,
},
headers: HashMap::new(),
error_envelope: None,
capabilities: CapabilitySet::default(),
}
}
#[test]
fn test_exchange_client_from_binance_descriptor() {
let registry = VenueRegistry::default();
let desc = registry.get("binance").unwrap();
let client = ExchangeClient::from_descriptor_with_http(desc, crate::http::test_transport());
assert_eq!(client.venue_id(), "binance");
assert_eq!(client.venue_name(), "Binance Spot");
assert!(client.has_order_book());
assert!(client.has_ticker());
assert!(client.has_trade_history());
}
#[test]
fn test_exchange_client_format_pair() {
let registry = VenueRegistry::default();
let desc = registry.get("binance").unwrap();
let client = ExchangeClient::from_descriptor_with_http(desc, crate::http::test_transport());
assert_eq!(client.venue_symbol(&Pair::parse("ETH_USD")), "ETHUSD");
assert_eq!(client.venue_symbol(&Pair::parse("BTC")), "BTCUSDT");
}
#[test]
fn test_dai_usdc_on_binance_is_daiusdc() {
let registry = VenueRegistry::default();
let client = ExchangeClient::from_descriptor_with_http(
registry.get("binance").unwrap(),
crate::http::test_transport(),
);
assert_eq!(client.venue_symbol(&Pair::parse("DAI_USDC")), "DAIUSDC");
}
#[test]
fn test_exchange_client_all_venues() {
let registry = VenueRegistry::default();
for venue_id in registry.list() {
let desc = registry.get(venue_id).unwrap();
let client =
ExchangeClient::from_descriptor_with_http(desc, crate::http::test_transport());
assert_eq!(client.venue_id(), venue_id);
assert!(
client.has_order_book(),
"Venue {} missing order_book capability",
venue_id
);
}
}
#[test]
fn test_exchange_client_debug() {
let registry = VenueRegistry::default();
let desc = registry.get("okx").unwrap();
let client = ExchangeClient::from_descriptor_with_http(desc, crate::http::test_transport());
let debug = format!("{:?}", client);
assert!(debug.contains("okx"));
assert!(debug.contains("has_order_book: true"));
}
#[test]
fn test_exchange_client_has_ohlc_for_binance() {
let registry = VenueRegistry::default();
let desc = registry.get("binance").unwrap();
let client = ExchangeClient::from_descriptor_with_http(desc, crate::http::test_transport());
assert!(client.has_ohlc());
}
#[test]
fn test_empty_descriptor_has_no_capabilities() {
let desc = make_empty_descriptor();
let client =
ExchangeClient::from_descriptor_with_http(&desc, crate::http::test_transport());
assert!(!client.has_order_book());
assert!(!client.has_ticker());
assert!(!client.has_trade_history());
}
#[tokio::test]
async fn test_fetch_order_book_without_capability_returns_error() {
let desc = make_empty_descriptor();
let client =
ExchangeClient::from_descriptor_with_http(&desc, crate::http::test_transport());
let err = client.fetch_order_book("BTCUSDT").await.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("(empty)"));
assert!(msg.contains("does not support order book"));
}
#[tokio::test]
async fn test_fetch_ticker_without_capability_returns_error() {
let desc = make_empty_descriptor();
let client =
ExchangeClient::from_descriptor_with_http(&desc, crate::http::test_transport());
let err = client.fetch_ticker("BTCUSDT").await.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("(empty)"));
assert!(msg.contains("does not support ticker"));
}
#[tokio::test]
async fn test_fetch_ohlc_without_capability_returns_error() {
let desc = make_empty_descriptor();
let client =
ExchangeClient::from_descriptor_with_http(&desc, crate::http::test_transport());
let err = client.fetch_ohlc("BTCUSDT", "1h", 100).await.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("(empty)"));
assert!(msg.contains("does not support OHLC"));
}
#[tokio::test]
async fn test_fetch_recent_trades_without_capability_returns_error() {
let desc = make_empty_descriptor();
let client =
ExchangeClient::from_descriptor_with_http(&desc, crate::http::test_transport());
let err = client.fetch_recent_trades("BTCUSDT", 50).await.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("(empty)"));
assert!(msg.contains("does not support trades"));
}
#[tokio::test]
async fn test_fetch_market_snapshot_empty_descriptor_returns_all_none() {
let desc = make_empty_descriptor();
let client =
ExchangeClient::from_descriptor_with_http(&desc, crate::http::test_transport());
let snapshot = client
.fetch_market_snapshot("BTCUSDT", DEFAULT_SNAPSHOT_TRADES)
.await;
assert!(snapshot.order_book.is_none());
assert!(snapshot.ticker.is_none());
assert!(snapshot.recent_trades.is_none());
}
}