Skip to main content

scope/market/
exchange.rs

1//! ExchangeClient facade: composes OrderBook, Ticker, and TradeHistory
2//! capabilities behind a unified interface with capability discovery.
3
4use 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
12/// Recent trades in a market snapshot when the caller names no limit.
13pub const DEFAULT_SNAPSHOT_TRADES: u32 = 50;
14
15/// Unified exchange client that wraps per-capability trait objects.
16///
17/// Created by [`super::VenueRegistry::create_exchange_client`]. Provides
18/// capability discovery and a convenience `fetch_market_snapshot` that
19/// fetches all available data in parallel.
20pub 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    /// Build an ExchangeClient from a VenueDescriptor.
33    ///
34    /// Each capability that exists in the descriptor gets a
35    /// `ConfigurableExchangeClient` implementation wired in.
36    #[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    /// Build an ExchangeClient from a VenueDescriptor whose requests go
45    /// through `http`.
46    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    // =========================================================================
87    // Metadata
88    // =========================================================================
89
90    /// Venue ID (e.g., "binance").
91    pub fn venue_id(&self) -> &str {
92        &self.venue_id
93    }
94
95    /// Venue display name (e.g., "Binance Spot").
96    pub fn venue_name(&self) -> &str {
97        &self.venue_name
98    }
99
100    /// The venue's symbol for a pair; a pair with no quote uses the venue's
101    /// default quote.
102    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    // =========================================================================
108    // Capability discovery
109    // =========================================================================
110
111    /// Whether this client supports order book fetching.
112    pub fn has_order_book(&self) -> bool {
113        self.order_book.is_some()
114    }
115
116    /// Whether this client supports ticker fetching.
117    pub fn has_ticker(&self) -> bool {
118        self.ticker.is_some()
119    }
120
121    /// Whether this client supports trade history fetching.
122    pub fn has_trade_history(&self) -> bool {
123        self.trade_history.is_some()
124    }
125
126    /// Whether this client supports OHLC / kline data.
127    pub fn has_ohlc(&self) -> bool {
128        self.ohlc.is_some()
129    }
130
131    // =========================================================================
132    // Individual capability methods
133    // =========================================================================
134
135    /// Fetch order book (if supported).
136    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    /// Fetch ticker (if supported).
145    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    /// Fetch recent trades (if supported).
154    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    /// Whether the venue lists its coins' contracts (`assets`).
163    pub fn lists_assets(&self) -> bool {
164        self.assets.is_some()
165    }
166
167    /// The venue's coin list, or `None` when the venue has no `assets`
168    /// capability.
169    pub async fn fetch_assets(&self) -> Option<Result<Vec<super::assets::AssetInfo>>> {
170        Some(self.assets.as_ref()?.fetch_assets().await)
171    }
172
173    /// The token lines for `pair` from the venue's coin list, or `None`
174    /// when the venue has no `assets` capability.
175    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, &quote, &assets)),
189        )
190    }
191
192    /// Fetch OHLC candlesticks (if supported).
193    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    // =========================================================================
202    // Combined fetch
203    // =========================================================================
204
205    /// Fetch all available market data for a pair in one call, with up to
206    /// `trades_limit` recent trades.
207    ///
208    /// Each capability is fetched independently; failures in one capability
209    /// do not prevent others from succeeding (they just produce `None`).
210    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    /// The audit input: the CLI and the web both send `DAIUSDC`, not
299    /// `DAI_USDCUSDT`.
300    #[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            // All built-in venues should have at least order_book
319            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}