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 pub fn from_descriptor(desc: &VenueDescriptor) -> Self {
37 let client = ConfigurableExchangeClient::new(desc.clone());
38
39 let order_book: Option<Box<dyn OrderBookClient>> = if desc.has_order_book() {
40 Some(Box::new(client.clone()))
41 } else {
42 None
43 };
44 let ticker: Option<Box<dyn TickerClient>> = if desc.has_ticker() {
45 Some(Box::new(client.clone()))
46 } else {
47 None
48 };
49 let trade_history: Option<Box<dyn TradeHistoryClient>> = if desc.has_trades() {
50 Some(Box::new(client.clone()))
51 } else {
52 None
53 };
54 let assets = desc.capabilities.assets.is_some().then(|| client.clone());
55 let ohlc: Option<Box<dyn OhlcClient>> = if desc.has_ohlc() {
56 Some(Box::new(client))
57 } else {
58 None
59 };
60
61 Self {
62 venue_id: desc.id.clone(),
63 venue_name: desc.name.clone(),
64 descriptor: desc.clone(),
65 order_book,
66 ticker,
67 trade_history,
68 ohlc,
69 assets,
70 }
71 }
72
73 pub fn venue_id(&self) -> &str {
79 &self.venue_id
80 }
81
82 pub fn venue_name(&self) -> &str {
84 &self.venue_name
85 }
86
87 pub fn venue_symbol(&self, pair: &super::pair::Pair) -> String {
90 self.descriptor
91 .format_pair(&pair.base, pair.quote.as_deref())
92 }
93
94 pub fn has_order_book(&self) -> bool {
100 self.order_book.is_some()
101 }
102
103 pub fn has_ticker(&self) -> bool {
105 self.ticker.is_some()
106 }
107
108 pub fn has_trade_history(&self) -> bool {
110 self.trade_history.is_some()
111 }
112
113 pub fn has_ohlc(&self) -> bool {
115 self.ohlc.is_some()
116 }
117
118 pub async fn fetch_order_book(&self, pair: &str) -> Result<OrderBook> {
124 self.order_book
125 .as_ref()
126 .ok_or_else(|| venue_error(&self.venue_id, "does not support order book"))?
127 .fetch_order_book(pair)
128 .await
129 }
130
131 pub async fn fetch_ticker(&self, pair: &str) -> Result<Ticker> {
133 self.ticker
134 .as_ref()
135 .ok_or_else(|| venue_error(&self.venue_id, "does not support ticker"))?
136 .fetch_ticker(pair)
137 .await
138 }
139
140 pub async fn fetch_recent_trades(&self, pair: &str, limit: u32) -> Result<Vec<Trade>> {
142 self.trade_history
143 .as_ref()
144 .ok_or_else(|| venue_error(&self.venue_id, "does not support trades"))?
145 .fetch_recent_trades(pair, limit)
146 .await
147 }
148
149 pub async fn fetch_token_addresses(
152 &self,
153 pair: &super::pair::Pair,
154 ) -> Option<Result<Vec<super::assets::TokenAddress>>> {
155 let client = self.assets.as_ref()?;
156 let quote = pair
157 .quote
158 .clone()
159 .unwrap_or_else(|| self.descriptor.symbol.default_quote.clone());
160 Some(
161 client
162 .fetch_assets()
163 .await
164 .map(|assets| super::assets::token_addresses(&pair.base, "e, &assets)),
165 )
166 }
167
168 pub async fn fetch_ohlc(&self, pair: &str, interval: &str, limit: u32) -> Result<Vec<Candle>> {
170 self.ohlc
171 .as_ref()
172 .ok_or_else(|| venue_error(&self.venue_id, "does not support OHLC"))?
173 .fetch_ohlc(pair, interval, limit)
174 .await
175 }
176
177 pub async fn fetch_market_snapshot(&self, pair: &str, trades_limit: u32) -> MarketSnapshot {
187 let order_book = if self.has_order_book() {
188 self.fetch_order_book(pair).await.ok()
189 } else {
190 None
191 };
192
193 let ticker = if self.has_ticker() {
194 self.fetch_ticker(pair).await.ok()
195 } else {
196 None
197 };
198
199 let recent_trades = if self.has_trade_history() {
200 self.fetch_recent_trades(pair, trades_limit).await.ok()
201 } else {
202 None
203 };
204
205 MarketSnapshot {
206 order_book,
207 ticker,
208 recent_trades,
209 }
210 }
211}
212
213impl std::fmt::Debug for ExchangeClient {
214 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215 f.debug_struct("ExchangeClient")
216 .field("venue_id", &self.venue_id)
217 .field("venue_name", &self.venue_name)
218 .field("has_order_book", &self.has_order_book())
219 .field("has_ticker", &self.has_ticker())
220 .field("has_trade_history", &self.has_trade_history())
221 .field("has_ohlc", &self.has_ohlc())
222 .finish()
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use crate::market::descriptor::{CapabilitySet, SymbolCase, SymbolConfig, VenueDescriptor};
230 use crate::market::pair::Pair;
231 use crate::market::registry::VenueRegistry;
232 use std::collections::HashMap;
233
234 fn make_empty_descriptor() -> VenueDescriptor {
235 VenueDescriptor {
236 id: "empty".to_string(),
237 name: "Empty".to_string(),
238 base_url: "https://example.com".to_string(),
239 timeout_secs: Some(5),
240 rate_limit_per_sec: None,
241 symbol: SymbolConfig {
242 template: "{base}{quote}".to_string(),
243 default_quote: "USDT".to_string(),
244 case: SymbolCase::Upper,
245 },
246 headers: HashMap::new(),
247 error_envelope: None,
248 capabilities: CapabilitySet::default(),
249 }
250 }
251
252 #[test]
253 fn test_exchange_client_from_binance_descriptor() {
254 let registry = VenueRegistry::default();
255 let desc = registry.get("binance").unwrap();
256 let client = ExchangeClient::from_descriptor(desc);
257
258 assert_eq!(client.venue_id(), "binance");
259 assert_eq!(client.venue_name(), "Binance Spot");
260 assert!(client.has_order_book());
261 assert!(client.has_ticker());
262 assert!(client.has_trade_history());
263 }
264
265 #[test]
266 fn test_exchange_client_format_pair() {
267 let registry = VenueRegistry::default();
268 let desc = registry.get("binance").unwrap();
269 let client = ExchangeClient::from_descriptor(desc);
270 assert_eq!(client.venue_symbol(&Pair::parse("ETH_USD")), "ETHUSD");
271 assert_eq!(client.venue_symbol(&Pair::parse("BTC")), "BTCUSDT");
272 }
273
274 #[test]
277 fn test_dai_usdc_on_binance_is_daiusdc() {
278 let registry = VenueRegistry::default();
279 let client = ExchangeClient::from_descriptor(registry.get("binance").unwrap());
280 assert_eq!(client.venue_symbol(&Pair::parse("DAI_USDC")), "DAIUSDC");
281 }
282
283 #[test]
284 fn test_exchange_client_all_venues() {
285 let registry = VenueRegistry::default();
286 for venue_id in registry.list() {
287 let desc = registry.get(venue_id).unwrap();
288 let client = ExchangeClient::from_descriptor(desc);
289 assert_eq!(client.venue_id(), venue_id);
290 assert!(
292 client.has_order_book(),
293 "Venue {} missing order_book capability",
294 venue_id
295 );
296 }
297 }
298
299 #[test]
300 fn test_exchange_client_debug() {
301 let registry = VenueRegistry::default();
302 let desc = registry.get("okx").unwrap();
303 let client = ExchangeClient::from_descriptor(desc);
304 let debug = format!("{:?}", client);
305 assert!(debug.contains("okx"));
306 assert!(debug.contains("has_order_book: true"));
307 }
308
309 #[test]
310 fn test_exchange_client_has_ohlc_for_binance() {
311 let registry = VenueRegistry::default();
312 let desc = registry.get("binance").unwrap();
313 let client = ExchangeClient::from_descriptor(desc);
314 assert!(client.has_ohlc());
315 }
316
317 #[test]
318 fn test_empty_descriptor_has_no_capabilities() {
319 let desc = make_empty_descriptor();
320 let client = ExchangeClient::from_descriptor(&desc);
321
322 assert!(!client.has_order_book());
323 assert!(!client.has_ticker());
324 assert!(!client.has_trade_history());
325 }
326
327 #[tokio::test]
328 async fn test_fetch_order_book_without_capability_returns_error() {
329 let desc = make_empty_descriptor();
330 let client = ExchangeClient::from_descriptor(&desc);
331
332 let err = client.fetch_order_book("BTCUSDT").await.unwrap_err();
333 let msg = err.to_string();
334 assert!(msg.contains("(empty)"));
335 assert!(msg.contains("does not support order book"));
336 }
337
338 #[tokio::test]
339 async fn test_fetch_ticker_without_capability_returns_error() {
340 let desc = make_empty_descriptor();
341 let client = ExchangeClient::from_descriptor(&desc);
342
343 let err = client.fetch_ticker("BTCUSDT").await.unwrap_err();
344 let msg = err.to_string();
345 assert!(msg.contains("(empty)"));
346 assert!(msg.contains("does not support ticker"));
347 }
348
349 #[tokio::test]
350 async fn test_fetch_ohlc_without_capability_returns_error() {
351 let desc = make_empty_descriptor();
352 let client = ExchangeClient::from_descriptor(&desc);
353
354 let err = client.fetch_ohlc("BTCUSDT", "1h", 100).await.unwrap_err();
355 let msg = err.to_string();
356 assert!(msg.contains("(empty)"));
357 assert!(msg.contains("does not support OHLC"));
358 }
359
360 #[tokio::test]
361 async fn test_fetch_recent_trades_without_capability_returns_error() {
362 let desc = make_empty_descriptor();
363 let client = ExchangeClient::from_descriptor(&desc);
364
365 let err = client.fetch_recent_trades("BTCUSDT", 50).await.unwrap_err();
366 let msg = err.to_string();
367 assert!(msg.contains("(empty)"));
368 assert!(msg.contains("does not support trades"));
369 }
370
371 #[tokio::test]
372 async fn test_fetch_market_snapshot_empty_descriptor_returns_all_none() {
373 let desc = make_empty_descriptor();
374 let client = ExchangeClient::from_descriptor(&desc);
375
376 let snapshot = client
377 .fetch_market_snapshot("BTCUSDT", DEFAULT_SNAPSHOT_TRADES)
378 .await;
379
380 assert!(snapshot.order_book.is_none());
381 assert!(snapshot.ticker.is_none());
382 assert!(snapshot.recent_trades.is_none());
383 }
384}