1use crate::error::Result;
5use crate::market::configurable_client::{ConfigurableExchangeClient, venue_error};
6use crate::market::descriptor::VenueDescriptor;
7use crate::market::orderbook::{
8 Candle, MarketSnapshot, OhlcClient, OrderBook, OrderBookClient, Ticker, TickerClient, Trade,
9 TradeHistoryClient,
10};
11
12pub const DEFAULT_SNAPSHOT_TRADES: u32 = 50;
14
15pub struct ExchangeClient {
21 venue_id: String,
22 venue_name: String,
23 descriptor: VenueDescriptor,
24 order_book: Option<Box<dyn OrderBookClient>>,
25 ticker: Option<Box<dyn TickerClient>>,
26 trade_history: Option<Box<dyn TradeHistoryClient>>,
27 ohlc: Option<Box<dyn OhlcClient>>,
28 assets: Option<ConfigurableExchangeClient>,
29}
30
31impl ExchangeClient {
32 #[deprecated(
37 since = "0.8.0",
38 note = "pass the transport: use `ExchangeClient::from_descriptor_with_http`"
39 )]
40 pub fn from_descriptor(desc: &VenueDescriptor) -> Self {
41 Self::from_descriptor_with_http(desc, crate::http::shared::fallback())
42 }
43
44 pub fn from_descriptor_with_http(
47 desc: &VenueDescriptor,
48 http: std::sync::Arc<dyn crate::http::HttpClient>,
49 ) -> Self {
50 let client = ConfigurableExchangeClient::with_http(desc.clone(), http);
51
52 let order_book: Option<Box<dyn OrderBookClient>> = if desc.has_order_book() {
53 Some(Box::new(client.clone()))
54 } else {
55 None
56 };
57 let ticker: Option<Box<dyn TickerClient>> = if desc.has_ticker() {
58 Some(Box::new(client.clone()))
59 } else {
60 None
61 };
62 let trade_history: Option<Box<dyn TradeHistoryClient>> = if desc.has_trades() {
63 Some(Box::new(client.clone()))
64 } else {
65 None
66 };
67 let assets = desc.capabilities.assets.is_some().then(|| client.clone());
68 let ohlc: Option<Box<dyn OhlcClient>> = if desc.has_ohlc() {
69 Some(Box::new(client))
70 } else {
71 None
72 };
73
74 Self {
75 venue_id: desc.id.clone(),
76 venue_name: desc.name.clone(),
77 descriptor: desc.clone(),
78 order_book,
79 ticker,
80 trade_history,
81 ohlc,
82 assets,
83 }
84 }
85
86 pub fn venue_id(&self) -> &str {
92 &self.venue_id
93 }
94
95 pub fn venue_name(&self) -> &str {
97 &self.venue_name
98 }
99
100 pub fn venue_symbol(&self, pair: &super::pair::Pair) -> String {
103 self.descriptor
104 .format_pair(&pair.base, pair.quote.as_deref())
105 }
106
107 pub fn has_order_book(&self) -> bool {
113 self.order_book.is_some()
114 }
115
116 pub fn has_ticker(&self) -> bool {
118 self.ticker.is_some()
119 }
120
121 pub fn has_trade_history(&self) -> bool {
123 self.trade_history.is_some()
124 }
125
126 pub fn has_ohlc(&self) -> bool {
128 self.ohlc.is_some()
129 }
130
131 pub async fn fetch_order_book(&self, pair: &str) -> Result<OrderBook> {
137 self.order_book
138 .as_ref()
139 .ok_or_else(|| venue_error(&self.venue_id, "does not support order book"))?
140 .fetch_order_book(pair)
141 .await
142 }
143
144 pub async fn fetch_ticker(&self, pair: &str) -> Result<Ticker> {
146 self.ticker
147 .as_ref()
148 .ok_or_else(|| venue_error(&self.venue_id, "does not support ticker"))?
149 .fetch_ticker(pair)
150 .await
151 }
152
153 pub async fn fetch_recent_trades(&self, pair: &str, limit: u32) -> Result<Vec<Trade>> {
155 self.trade_history
156 .as_ref()
157 .ok_or_else(|| venue_error(&self.venue_id, "does not support trades"))?
158 .fetch_recent_trades(pair, limit)
159 .await
160 }
161
162 pub fn lists_assets(&self) -> bool {
164 self.assets.is_some()
165 }
166
167 pub async fn fetch_assets(&self) -> Option<Result<Vec<super::assets::AssetInfo>>> {
170 Some(self.assets.as_ref()?.fetch_assets().await)
171 }
172
173 pub async fn fetch_token_addresses(
176 &self,
177 pair: &super::pair::Pair,
178 ) -> Option<Result<Vec<super::assets::TokenAddress>>> {
179 let client = self.assets.as_ref()?;
180 let quote = pair
181 .quote
182 .clone()
183 .unwrap_or_else(|| self.descriptor.symbol.default_quote.clone());
184 Some(
185 client
186 .fetch_assets()
187 .await
188 .map(|assets| super::assets::token_addresses(&pair.base, "e, &assets)),
189 )
190 }
191
192 pub async fn fetch_ohlc(&self, pair: &str, interval: &str, limit: u32) -> Result<Vec<Candle>> {
194 self.ohlc
195 .as_ref()
196 .ok_or_else(|| venue_error(&self.venue_id, "does not support OHLC"))?
197 .fetch_ohlc(pair, interval, limit)
198 .await
199 }
200
201 pub async fn fetch_market_snapshot(&self, pair: &str, trades_limit: u32) -> MarketSnapshot {
211 let order_book = if self.has_order_book() {
212 self.fetch_order_book(pair).await.ok()
213 } else {
214 None
215 };
216
217 let ticker = if self.has_ticker() {
218 self.fetch_ticker(pair).await.ok()
219 } else {
220 None
221 };
222
223 let recent_trades = if self.has_trade_history() {
224 self.fetch_recent_trades(pair, trades_limit).await.ok()
225 } else {
226 None
227 };
228
229 MarketSnapshot {
230 order_book,
231 ticker,
232 recent_trades,
233 }
234 }
235}
236
237impl std::fmt::Debug for ExchangeClient {
238 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239 f.debug_struct("ExchangeClient")
240 .field("venue_id", &self.venue_id)
241 .field("venue_name", &self.venue_name)
242 .field("has_order_book", &self.has_order_book())
243 .field("has_ticker", &self.has_ticker())
244 .field("has_trade_history", &self.has_trade_history())
245 .field("has_ohlc", &self.has_ohlc())
246 .finish()
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use crate::market::descriptor::{CapabilitySet, SymbolCase, SymbolConfig, VenueDescriptor};
254 use crate::market::pair::Pair;
255 use crate::market::registry::VenueRegistry;
256 use std::collections::HashMap;
257
258 fn make_empty_descriptor() -> VenueDescriptor {
259 VenueDescriptor {
260 id: "empty".to_string(),
261 name: "Empty".to_string(),
262 base_url: "https://example.com".to_string(),
263 timeout_secs: Some(5),
264 rate_limit_per_sec: None,
265 symbol: SymbolConfig {
266 template: "{base}{quote}".to_string(),
267 default_quote: "USDT".to_string(),
268 case: SymbolCase::Upper,
269 },
270 headers: HashMap::new(),
271 error_envelope: None,
272 capabilities: CapabilitySet::default(),
273 }
274 }
275
276 #[test]
277 fn test_exchange_client_from_binance_descriptor() {
278 let registry = VenueRegistry::default();
279 let desc = registry.get("binance").unwrap();
280 let client = ExchangeClient::from_descriptor_with_http(desc, crate::http::test_transport());
281
282 assert_eq!(client.venue_id(), "binance");
283 assert_eq!(client.venue_name(), "Binance Spot");
284 assert!(client.has_order_book());
285 assert!(client.has_ticker());
286 assert!(client.has_trade_history());
287 }
288
289 #[test]
290 fn test_exchange_client_format_pair() {
291 let registry = VenueRegistry::default();
292 let desc = registry.get("binance").unwrap();
293 let client = ExchangeClient::from_descriptor_with_http(desc, crate::http::test_transport());
294 assert_eq!(client.venue_symbol(&Pair::parse("ETH_USD")), "ETHUSD");
295 assert_eq!(client.venue_symbol(&Pair::parse("BTC")), "BTCUSDT");
296 }
297
298 #[test]
301 fn test_dai_usdc_on_binance_is_daiusdc() {
302 let registry = VenueRegistry::default();
303 let client = ExchangeClient::from_descriptor_with_http(
304 registry.get("binance").unwrap(),
305 crate::http::test_transport(),
306 );
307 assert_eq!(client.venue_symbol(&Pair::parse("DAI_USDC")), "DAIUSDC");
308 }
309
310 #[test]
311 fn test_exchange_client_all_venues() {
312 let registry = VenueRegistry::default();
313 for venue_id in registry.list() {
314 let desc = registry.get(venue_id).unwrap();
315 let client =
316 ExchangeClient::from_descriptor_with_http(desc, crate::http::test_transport());
317 assert_eq!(client.venue_id(), venue_id);
318 assert!(
320 client.has_order_book(),
321 "Venue {} missing order_book capability",
322 venue_id
323 );
324 }
325 }
326
327 #[test]
328 fn test_exchange_client_debug() {
329 let registry = VenueRegistry::default();
330 let desc = registry.get("okx").unwrap();
331 let client = ExchangeClient::from_descriptor_with_http(desc, crate::http::test_transport());
332 let debug = format!("{:?}", client);
333 assert!(debug.contains("okx"));
334 assert!(debug.contains("has_order_book: true"));
335 }
336
337 #[test]
338 fn test_exchange_client_has_ohlc_for_binance() {
339 let registry = VenueRegistry::default();
340 let desc = registry.get("binance").unwrap();
341 let client = ExchangeClient::from_descriptor_with_http(desc, crate::http::test_transport());
342 assert!(client.has_ohlc());
343 }
344
345 #[test]
346 fn test_empty_descriptor_has_no_capabilities() {
347 let desc = make_empty_descriptor();
348 let client =
349 ExchangeClient::from_descriptor_with_http(&desc, crate::http::test_transport());
350
351 assert!(!client.has_order_book());
352 assert!(!client.has_ticker());
353 assert!(!client.has_trade_history());
354 }
355
356 #[tokio::test]
357 async fn test_fetch_order_book_without_capability_returns_error() {
358 let desc = make_empty_descriptor();
359 let client =
360 ExchangeClient::from_descriptor_with_http(&desc, crate::http::test_transport());
361
362 let err = client.fetch_order_book("BTCUSDT").await.unwrap_err();
363 let msg = err.to_string();
364 assert!(msg.contains("(empty)"));
365 assert!(msg.contains("does not support order book"));
366 }
367
368 #[tokio::test]
369 async fn test_fetch_ticker_without_capability_returns_error() {
370 let desc = make_empty_descriptor();
371 let client =
372 ExchangeClient::from_descriptor_with_http(&desc, crate::http::test_transport());
373
374 let err = client.fetch_ticker("BTCUSDT").await.unwrap_err();
375 let msg = err.to_string();
376 assert!(msg.contains("(empty)"));
377 assert!(msg.contains("does not support ticker"));
378 }
379
380 #[tokio::test]
381 async fn test_fetch_ohlc_without_capability_returns_error() {
382 let desc = make_empty_descriptor();
383 let client =
384 ExchangeClient::from_descriptor_with_http(&desc, crate::http::test_transport());
385
386 let err = client.fetch_ohlc("BTCUSDT", "1h", 100).await.unwrap_err();
387 let msg = err.to_string();
388 assert!(msg.contains("(empty)"));
389 assert!(msg.contains("does not support OHLC"));
390 }
391
392 #[tokio::test]
393 async fn test_fetch_recent_trades_without_capability_returns_error() {
394 let desc = make_empty_descriptor();
395 let client =
396 ExchangeClient::from_descriptor_with_http(&desc, crate::http::test_transport());
397
398 let err = client.fetch_recent_trades("BTCUSDT", 50).await.unwrap_err();
399 let msg = err.to_string();
400 assert!(msg.contains("(empty)"));
401 assert!(msg.contains("does not support trades"));
402 }
403
404 #[tokio::test]
405 async fn test_fetch_market_snapshot_empty_descriptor_returns_all_none() {
406 let desc = make_empty_descriptor();
407 let client =
408 ExchangeClient::from_descriptor_with_http(&desc, crate::http::test_transport());
409
410 let snapshot = client
411 .fetch_market_snapshot("BTCUSDT", DEFAULT_SNAPSHOT_TRADES)
412 .await;
413
414 assert!(snapshot.order_book.is_none());
415 assert!(snapshot.ticker.is_none());
416 assert!(snapshot.recent_trades.is_none());
417 }
418}