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, ScopeError};
5use crate::market::configurable_client::ConfigurableExchangeClient;
6use crate::market::descriptor::VenueDescriptor;
7use crate::market::orderbook::{
8    Candle, MarketSnapshot, OhlcClient, OrderBook, OrderBookClient, Ticker, TickerClient, Trade,
9    TradeHistoryClient,
10};
11
12/// Unified exchange client that wraps per-capability trait objects.
13///
14/// Created by [`super::VenueRegistry::create_exchange_client`]. Provides
15/// capability discovery and a convenience `fetch_market_snapshot` that
16/// fetches all available data in parallel.
17pub struct ExchangeClient {
18    venue_id: String,
19    venue_name: String,
20    descriptor: VenueDescriptor,
21    order_book: Option<Box<dyn OrderBookClient>>,
22    ticker: Option<Box<dyn TickerClient>>,
23    trade_history: Option<Box<dyn TradeHistoryClient>>,
24    ohlc: Option<Box<dyn OhlcClient>>,
25}
26
27impl ExchangeClient {
28    /// Build an ExchangeClient from a VenueDescriptor.
29    ///
30    /// Each capability that exists in the descriptor gets a
31    /// `ConfigurableExchangeClient` implementation wired in.
32    pub fn from_descriptor(desc: &VenueDescriptor) -> Self {
33        let client = ConfigurableExchangeClient::new(desc.clone());
34
35        let order_book: Option<Box<dyn OrderBookClient>> = if desc.has_order_book() {
36            Some(Box::new(client.clone()))
37        } else {
38            None
39        };
40        let ticker: Option<Box<dyn TickerClient>> = if desc.has_ticker() {
41            Some(Box::new(client.clone()))
42        } else {
43            None
44        };
45        let trade_history: Option<Box<dyn TradeHistoryClient>> = if desc.has_trades() {
46            Some(Box::new(client.clone()))
47        } else {
48            None
49        };
50        let ohlc: Option<Box<dyn OhlcClient>> = if desc.has_ohlc() {
51            Some(Box::new(client))
52        } else {
53            None
54        };
55
56        Self {
57            venue_id: desc.id.clone(),
58            venue_name: desc.name.clone(),
59            descriptor: desc.clone(),
60            order_book,
61            ticker,
62            trade_history,
63            ohlc,
64        }
65    }
66
67    // =========================================================================
68    // Metadata
69    // =========================================================================
70
71    /// Venue ID (e.g., "binance").
72    pub fn venue_id(&self) -> &str {
73        &self.venue_id
74    }
75
76    /// Venue display name (e.g., "Binance Spot").
77    pub fn venue_name(&self) -> &str {
78        &self.venue_name
79    }
80
81    /// Format a trading pair for this venue.
82    pub fn format_pair(&self, base: &str) -> String {
83        self.descriptor.format_pair(base, None)
84    }
85
86    /// Format a trading pair with explicit quote currency.
87    pub fn format_pair_with_quote(&self, base: &str, quote: &str) -> String {
88        self.descriptor.format_pair(base, Some(quote))
89    }
90
91    // =========================================================================
92    // Capability discovery
93    // =========================================================================
94
95    /// Whether this client supports order book fetching.
96    pub fn has_order_book(&self) -> bool {
97        self.order_book.is_some()
98    }
99
100    /// Whether this client supports ticker fetching.
101    pub fn has_ticker(&self) -> bool {
102        self.ticker.is_some()
103    }
104
105    /// Whether this client supports trade history fetching.
106    pub fn has_trade_history(&self) -> bool {
107        self.trade_history.is_some()
108    }
109
110    /// Whether this client supports OHLC / kline data.
111    pub fn has_ohlc(&self) -> bool {
112        self.ohlc.is_some()
113    }
114
115    // =========================================================================
116    // Individual capability methods
117    // =========================================================================
118
119    /// Fetch order book (if supported).
120    pub async fn fetch_order_book(&self, pair: &str) -> Result<OrderBook> {
121        self.order_book
122            .as_ref()
123            .ok_or_else(|| {
124                ScopeError::Chain(format!("{} does not support order book", self.venue_name))
125            })?
126            .fetch_order_book(pair)
127            .await
128    }
129
130    /// Fetch ticker (if supported).
131    pub async fn fetch_ticker(&self, pair: &str) -> Result<Ticker> {
132        self.ticker
133            .as_ref()
134            .ok_or_else(|| {
135                ScopeError::Chain(format!("{} does not support ticker", self.venue_name))
136            })?
137            .fetch_ticker(pair)
138            .await
139    }
140
141    /// Fetch recent trades (if supported).
142    pub async fn fetch_recent_trades(&self, pair: &str, limit: u32) -> Result<Vec<Trade>> {
143        self.trade_history
144            .as_ref()
145            .ok_or_else(|| {
146                ScopeError::Chain(format!("{} does not support trades", self.venue_name))
147            })?
148            .fetch_recent_trades(pair, limit)
149            .await
150    }
151
152    /// Fetch OHLC candlesticks (if supported).
153    pub async fn fetch_ohlc(&self, pair: &str, interval: &str, limit: u32) -> Result<Vec<Candle>> {
154        self.ohlc
155            .as_ref()
156            .ok_or_else(|| ScopeError::Chain(format!("{} does not support OHLC", self.venue_name)))?
157            .fetch_ohlc(pair, interval, limit)
158            .await
159    }
160
161    // =========================================================================
162    // Combined fetch
163    // =========================================================================
164
165    /// Fetch all available market data for a pair in one call.
166    ///
167    /// Each capability is fetched independently; failures in one capability
168    /// do not prevent others from succeeding (they just produce `None`).
169    pub async fn fetch_market_snapshot(&self, pair: &str) -> MarketSnapshot {
170        let order_book = if self.has_order_book() {
171            self.fetch_order_book(pair).await.ok()
172        } else {
173            None
174        };
175
176        let ticker = if self.has_ticker() {
177            self.fetch_ticker(pair).await.ok()
178        } else {
179            None
180        };
181
182        let recent_trades = if self.has_trade_history() {
183            self.fetch_recent_trades(pair, 50).await.ok()
184        } else {
185            None
186        };
187
188        MarketSnapshot {
189            order_book,
190            ticker,
191            recent_trades,
192        }
193    }
194}
195
196impl std::fmt::Debug for ExchangeClient {
197    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198        f.debug_struct("ExchangeClient")
199            .field("venue_id", &self.venue_id)
200            .field("venue_name", &self.venue_name)
201            .field("has_order_book", &self.has_order_book())
202            .field("has_ticker", &self.has_ticker())
203            .field("has_trade_history", &self.has_trade_history())
204            .field("has_ohlc", &self.has_ohlc())
205            .finish()
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use crate::market::descriptor::{CapabilitySet, SymbolCase, SymbolConfig, VenueDescriptor};
213    use crate::market::registry::VenueRegistry;
214    use std::collections::HashMap;
215
216    fn make_empty_descriptor() -> VenueDescriptor {
217        VenueDescriptor {
218            id: "empty".to_string(),
219            name: "Empty".to_string(),
220            base_url: "https://example.com".to_string(),
221            timeout_secs: Some(5),
222            rate_limit_per_sec: None,
223            symbol: SymbolConfig {
224                template: "{base}{quote}".to_string(),
225                default_quote: "USDT".to_string(),
226                case: SymbolCase::Upper,
227            },
228            headers: HashMap::new(),
229            capabilities: CapabilitySet::default(),
230        }
231    }
232
233    #[test]
234    fn test_exchange_client_from_binance_descriptor() {
235        let registry = VenueRegistry::default();
236        let desc = registry.get("binance").unwrap();
237        let client = ExchangeClient::from_descriptor(desc);
238
239        assert_eq!(client.venue_id(), "binance");
240        assert_eq!(client.venue_name(), "Binance Spot");
241        assert!(client.has_order_book());
242        assert!(client.has_ticker());
243        assert!(client.has_trade_history());
244    }
245
246    #[test]
247    fn test_exchange_client_format_pair() {
248        let registry = VenueRegistry::default();
249        let desc = registry.get("binance").unwrap();
250        let client = ExchangeClient::from_descriptor(desc);
251        assert_eq!(client.format_pair("BTC"), "BTCUSDT");
252        assert_eq!(client.format_pair_with_quote("ETH", "USD"), "ETHUSD");
253    }
254
255    #[test]
256    fn test_exchange_client_all_venues() {
257        let registry = VenueRegistry::default();
258        for venue_id in registry.list() {
259            let desc = registry.get(venue_id).unwrap();
260            let client = ExchangeClient::from_descriptor(desc);
261            assert_eq!(client.venue_id(), venue_id);
262            // All built-in venues should have at least order_book
263            assert!(
264                client.has_order_book(),
265                "Venue {} missing order_book capability",
266                venue_id
267            );
268        }
269    }
270
271    #[test]
272    fn test_exchange_client_debug() {
273        let registry = VenueRegistry::default();
274        let desc = registry.get("okx").unwrap();
275        let client = ExchangeClient::from_descriptor(desc);
276        let debug = format!("{:?}", client);
277        assert!(debug.contains("okx"));
278        assert!(debug.contains("has_order_book: true"));
279    }
280
281    #[test]
282    fn test_exchange_client_has_ohlc_for_binance() {
283        let registry = VenueRegistry::default();
284        let desc = registry.get("binance").unwrap();
285        let client = ExchangeClient::from_descriptor(desc);
286        assert!(client.has_ohlc());
287    }
288
289    #[test]
290    fn test_empty_descriptor_has_no_capabilities() {
291        let desc = make_empty_descriptor();
292        let client = ExchangeClient::from_descriptor(&desc);
293
294        assert!(!client.has_order_book());
295        assert!(!client.has_ticker());
296        assert!(!client.has_trade_history());
297    }
298
299    #[tokio::test]
300    async fn test_fetch_order_book_without_capability_returns_error() {
301        let desc = make_empty_descriptor();
302        let client = ExchangeClient::from_descriptor(&desc);
303
304        let err = client.fetch_order_book("BTCUSDT").await.unwrap_err();
305        let msg = err.to_string();
306        assert!(msg.contains("Empty"));
307        assert!(msg.contains("does not support order book"));
308    }
309
310    #[tokio::test]
311    async fn test_fetch_ticker_without_capability_returns_error() {
312        let desc = make_empty_descriptor();
313        let client = ExchangeClient::from_descriptor(&desc);
314
315        let err = client.fetch_ticker("BTCUSDT").await.unwrap_err();
316        let msg = err.to_string();
317        assert!(msg.contains("Empty"));
318        assert!(msg.contains("does not support ticker"));
319    }
320
321    #[tokio::test]
322    async fn test_fetch_ohlc_without_capability_returns_error() {
323        let desc = make_empty_descriptor();
324        let client = ExchangeClient::from_descriptor(&desc);
325
326        let err = client.fetch_ohlc("BTCUSDT", "1h", 100).await.unwrap_err();
327        let msg = err.to_string();
328        assert!(msg.contains("Empty"));
329        assert!(msg.contains("does not support OHLC"));
330    }
331
332    #[tokio::test]
333    async fn test_fetch_recent_trades_without_capability_returns_error() {
334        let desc = make_empty_descriptor();
335        let client = ExchangeClient::from_descriptor(&desc);
336
337        let err = client.fetch_recent_trades("BTCUSDT", 50).await.unwrap_err();
338        let msg = err.to_string();
339        assert!(msg.contains("Empty"));
340        assert!(msg.contains("does not support trades"));
341    }
342
343    #[tokio::test]
344    async fn test_fetch_market_snapshot_empty_descriptor_returns_all_none() {
345        let desc = make_empty_descriptor();
346        let client = ExchangeClient::from_descriptor(&desc);
347
348        let snapshot = client.fetch_market_snapshot("BTCUSDT").await;
349
350        assert!(snapshot.order_book.is_none());
351        assert!(snapshot.ticker.is_none());
352        assert!(snapshot.recent_trades.is_none());
353    }
354}