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 checked_symbol(&self, pair: &super::pair::Pair) -> crate::error::Result<String> {
111 let quote = pair
112 .quote
113 .as_deref()
114 .unwrap_or(&self.descriptor.symbol.default_quote);
115 if pair.base.eq_ignore_ascii_case(quote) {
116 let base = pair.base.to_uppercase();
117 let defaulted = if pair.quote.is_none() {
118 format!(
119 " ({} quotes in {base} when no quote is named)",
120 self.descriptor.id
121 )
122 } else {
123 String::new()
124 };
125 return Err(crate::error::ScopeError::InvalidInput(format!(
126 "{base}/{base} is not a market{defaulted}: name the pair, such as {base}/USDC"
127 )));
128 }
129 Ok(self.venue_symbol(pair))
130 }
131
132 pub fn has_order_book(&self) -> bool {
138 self.order_book.is_some()
139 }
140
141 pub fn has_ticker(&self) -> bool {
143 self.ticker.is_some()
144 }
145
146 pub fn has_trade_history(&self) -> bool {
148 self.trade_history.is_some()
149 }
150
151 pub fn has_ohlc(&self) -> bool {
153 self.ohlc.is_some()
154 }
155
156 pub async fn fetch_order_book(&self, pair: &str) -> Result<OrderBook> {
162 self.order_book
163 .as_ref()
164 .ok_or_else(|| venue_error(&self.venue_id, "does not support order book"))?
165 .fetch_order_book(pair)
166 .await
167 }
168
169 pub async fn fetch_ticker(&self, pair: &str) -> Result<Ticker> {
171 self.ticker
172 .as_ref()
173 .ok_or_else(|| venue_error(&self.venue_id, "does not support ticker"))?
174 .fetch_ticker(pair)
175 .await
176 }
177
178 pub async fn fetch_recent_trades(&self, pair: &str, limit: u32) -> Result<Vec<Trade>> {
180 self.trade_history
181 .as_ref()
182 .ok_or_else(|| venue_error(&self.venue_id, "does not support trades"))?
183 .fetch_recent_trades(pair, limit)
184 .await
185 }
186
187 pub fn lists_assets(&self) -> bool {
189 self.assets.is_some()
190 }
191
192 pub async fn fetch_assets(&self) -> Option<Result<Vec<super::assets::AssetInfo>>> {
195 Some(self.assets.as_ref()?.fetch_assets().await)
196 }
197
198 pub async fn fetch_token_addresses(
201 &self,
202 pair: &super::pair::Pair,
203 ) -> Option<Result<Vec<super::assets::TokenAddress>>> {
204 let client = self.assets.as_ref()?;
205 let quote = pair
206 .quote
207 .clone()
208 .unwrap_or_else(|| self.descriptor.symbol.default_quote.clone());
209 Some(
210 client
211 .fetch_assets()
212 .await
213 .map(|assets| super::assets::token_addresses(&pair.base, "e, &assets)),
214 )
215 }
216
217 pub async fn fetch_ohlc(&self, pair: &str, interval: &str, limit: u32) -> Result<Vec<Candle>> {
219 self.ohlc
220 .as_ref()
221 .ok_or_else(|| venue_error(&self.venue_id, "does not support OHLC"))?
222 .fetch_ohlc(pair, interval, limit)
223 .await
224 }
225
226 pub async fn fetch_market_snapshot(&self, pair: &str, trades_limit: u32) -> MarketSnapshot {
236 let order_book = if self.has_order_book() {
237 self.fetch_order_book(pair).await.ok()
238 } else {
239 None
240 };
241
242 let ticker = if self.has_ticker() {
243 self.fetch_ticker(pair).await.ok()
244 } else {
245 None
246 };
247
248 let recent_trades = if self.has_trade_history() {
249 self.fetch_recent_trades(pair, trades_limit).await.ok()
250 } else {
251 None
252 };
253
254 MarketSnapshot {
255 order_book,
256 ticker,
257 recent_trades,
258 }
259 }
260}
261
262impl std::fmt::Debug for ExchangeClient {
263 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264 f.debug_struct("ExchangeClient")
265 .field("venue_id", &self.venue_id)
266 .field("venue_name", &self.venue_name)
267 .field("has_order_book", &self.has_order_book())
268 .field("has_ticker", &self.has_ticker())
269 .field("has_trade_history", &self.has_trade_history())
270 .field("has_ohlc", &self.has_ohlc())
271 .finish()
272 }
273}
274
275#[cfg(test)]
276mod tests {
277
278 #[test]
279 fn test_a_base_that_is_its_own_quote_is_not_a_market() {
280 let client = crate::market::testing::venue_at("binance", "http://localhost:1");
281 let err = client
282 .checked_symbol(&crate::market::Pair::parse("USDT"))
283 .unwrap_err()
284 .to_string();
285 assert!(err.contains("USDT/USDT is not a market"), "{err}");
286 assert!(
287 err.contains("binance quotes in USDT when no quote is named"),
288 "{err}"
289 );
290 let err = client
291 .checked_symbol(&crate::market::Pair::parse("usdc/USDC"))
292 .unwrap_err()
293 .to_string();
294 assert!(
295 err.contains("USDC/USDC is not a market: name the pair"),
296 "{err}"
297 );
298 assert_eq!(
299 client
300 .checked_symbol(&crate::market::Pair::parse("USDT/USDC"))
301 .unwrap(),
302 "USDTUSDC"
303 );
304 }
305 use super::*;
306 use crate::market::descriptor::{CapabilitySet, SymbolCase, SymbolConfig, VenueDescriptor};
307 use crate::market::pair::Pair;
308 use std::collections::HashMap;
309
310 fn make_empty_descriptor() -> VenueDescriptor {
311 VenueDescriptor {
312 id: "empty".to_string(),
313 name: "Empty".to_string(),
314 base_url: "https://example.com".to_string(),
315 timeout_secs: Some(5),
316 rate_limit_per_sec: None,
317 symbol: SymbolConfig {
318 template: "{base}{quote}".to_string(),
319 default_quote: "USDT".to_string(),
320 case: SymbolCase::Upper,
321 },
322 headers: HashMap::new(),
323 error_envelope: None,
324 capabilities: CapabilitySet::default(),
325 }
326 }
327
328 #[test]
329 fn test_exchange_client_from_binance_descriptor() {
330 let registry = crate::market::testing::catalog();
331 let desc = registry.get("binance").unwrap();
332 let client = ExchangeClient::from_descriptor_with_http(desc, crate::http::test_transport());
333
334 assert_eq!(client.venue_id(), "binance");
335 assert_eq!(client.venue_name(), "Binance Spot");
336 assert!(client.has_order_book());
337 assert!(client.has_ticker());
338 assert!(client.has_trade_history());
339 }
340
341 #[test]
342 fn test_exchange_client_format_pair() {
343 let registry = crate::market::testing::catalog();
344 let desc = registry.get("binance").unwrap();
345 let client = ExchangeClient::from_descriptor_with_http(desc, crate::http::test_transport());
346 assert_eq!(client.venue_symbol(&Pair::parse("ETH_USD")), "ETHUSD");
347 assert_eq!(client.venue_symbol(&Pair::parse("BTC")), "BTCUSDT");
348 }
349
350 #[test]
353 fn test_dai_usdc_on_binance_is_daiusdc() {
354 let registry = crate::market::testing::catalog();
355 let client = ExchangeClient::from_descriptor_with_http(
356 registry.get("binance").unwrap(),
357 crate::http::test_transport(),
358 );
359 assert_eq!(client.venue_symbol(&Pair::parse("DAI_USDC")), "DAIUSDC");
360 }
361
362 #[test]
363 fn test_exchange_client_all_venues() {
364 let registry = crate::market::testing::catalog();
365 for venue_id in registry.list() {
366 let desc = registry.get(venue_id).unwrap();
367 let client =
368 ExchangeClient::from_descriptor_with_http(desc, crate::http::test_transport());
369 assert_eq!(client.venue_id(), venue_id);
370 assert!(
372 client.has_order_book(),
373 "Venue {} missing order_book capability",
374 venue_id
375 );
376 }
377 }
378
379 #[test]
380 fn test_exchange_client_debug() {
381 let registry = crate::market::testing::catalog();
382 let desc = registry.get("okx").unwrap();
383 let client = ExchangeClient::from_descriptor_with_http(desc, crate::http::test_transport());
384 let debug = format!("{:?}", client);
385 assert!(debug.contains("okx"));
386 assert!(debug.contains("has_order_book: true"));
387 }
388
389 #[test]
390 fn test_exchange_client_has_ohlc_for_binance() {
391 let registry = crate::market::testing::catalog();
392 let desc = registry.get("binance").unwrap();
393 let client = ExchangeClient::from_descriptor_with_http(desc, crate::http::test_transport());
394 assert!(client.has_ohlc());
395 }
396
397 #[test]
398 fn test_empty_descriptor_has_no_capabilities() {
399 let desc = make_empty_descriptor();
400 let client =
401 ExchangeClient::from_descriptor_with_http(&desc, crate::http::test_transport());
402
403 assert!(!client.has_order_book());
404 assert!(!client.has_ticker());
405 assert!(!client.has_trade_history());
406 }
407
408 #[tokio::test]
409 async fn test_fetch_order_book_without_capability_returns_error() {
410 let desc = make_empty_descriptor();
411 let client =
412 ExchangeClient::from_descriptor_with_http(&desc, crate::http::test_transport());
413
414 let err = client.fetch_order_book("BTCUSDT").await.unwrap_err();
415 let msg = err.to_string();
416 assert!(msg.contains("(empty)"));
417 assert!(msg.contains("does not support order book"));
418 }
419
420 #[tokio::test]
421 async fn test_fetch_ticker_without_capability_returns_error() {
422 let desc = make_empty_descriptor();
423 let client =
424 ExchangeClient::from_descriptor_with_http(&desc, crate::http::test_transport());
425
426 let err = client.fetch_ticker("BTCUSDT").await.unwrap_err();
427 let msg = err.to_string();
428 assert!(msg.contains("(empty)"));
429 assert!(msg.contains("does not support ticker"));
430 }
431
432 #[tokio::test]
433 async fn test_fetch_ohlc_without_capability_returns_error() {
434 let desc = make_empty_descriptor();
435 let client =
436 ExchangeClient::from_descriptor_with_http(&desc, crate::http::test_transport());
437
438 let err = client.fetch_ohlc("BTCUSDT", "1h", 100).await.unwrap_err();
439 let msg = err.to_string();
440 assert!(msg.contains("(empty)"));
441 assert!(msg.contains("does not support OHLC"));
442 }
443
444 #[tokio::test]
445 async fn test_fetch_recent_trades_without_capability_returns_error() {
446 let desc = make_empty_descriptor();
447 let client =
448 ExchangeClient::from_descriptor_with_http(&desc, crate::http::test_transport());
449
450 let err = client.fetch_recent_trades("BTCUSDT", 50).await.unwrap_err();
451 let msg = err.to_string();
452 assert!(msg.contains("(empty)"));
453 assert!(msg.contains("does not support trades"));
454 }
455
456 #[tokio::test]
457 async fn test_fetch_market_snapshot_empty_descriptor_returns_all_none() {
458 let desc = make_empty_descriptor();
459 let client =
460 ExchangeClient::from_descriptor_with_http(&desc, crate::http::test_transport());
461
462 let snapshot = client
463 .fetch_market_snapshot("BTCUSDT", DEFAULT_SNAPSHOT_TRADES)
464 .await;
465
466 assert!(snapshot.order_book.is_none());
467 assert!(snapshot.ticker.is_none());
468 assert!(snapshot.recent_trades.is_none());
469 }
470}