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    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    // =========================================================================
74    // Metadata
75    // =========================================================================
76
77    /// Venue ID (e.g., "binance").
78    pub fn venue_id(&self) -> &str {
79        &self.venue_id
80    }
81
82    /// Venue display name (e.g., "Binance Spot").
83    pub fn venue_name(&self) -> &str {
84        &self.venue_name
85    }
86
87    /// The venue's symbol for a pair; a pair with no quote uses the venue's
88    /// default quote.
89    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    // =========================================================================
95    // Capability discovery
96    // =========================================================================
97
98    /// Whether this client supports order book fetching.
99    pub fn has_order_book(&self) -> bool {
100        self.order_book.is_some()
101    }
102
103    /// Whether this client supports ticker fetching.
104    pub fn has_ticker(&self) -> bool {
105        self.ticker.is_some()
106    }
107
108    /// Whether this client supports trade history fetching.
109    pub fn has_trade_history(&self) -> bool {
110        self.trade_history.is_some()
111    }
112
113    /// Whether this client supports OHLC / kline data.
114    pub fn has_ohlc(&self) -> bool {
115        self.ohlc.is_some()
116    }
117
118    // =========================================================================
119    // Individual capability methods
120    // =========================================================================
121
122    /// Fetch order book (if supported).
123    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    /// Fetch ticker (if supported).
132    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    /// Fetch recent trades (if supported).
141    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    /// The token lines for `pair` from the venue's coin list, or `None`
150    /// when the venue has no `assets` capability.
151    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, &quote, &assets)),
165        )
166    }
167
168    /// Fetch OHLC candlesticks (if supported).
169    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    // =========================================================================
178    // Combined fetch
179    // =========================================================================
180
181    /// Fetch all available market data for a pair in one call, with up to
182    /// `trades_limit` recent trades.
183    ///
184    /// Each capability is fetched independently; failures in one capability
185    /// do not prevent others from succeeding (they just produce `None`).
186    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    /// The audit input: the CLI and the web both send `DAIUSDC`, not
275    /// `DAI_USDCUSDT`.
276    #[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            // All built-in venues should have at least order_book
291            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}