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    MarketSnapshot, OrderBook, OrderBookClient, Ticker, TickerClient, Trade, TradeHistoryClient,
9};
10
11/// Unified exchange client that wraps per-capability trait objects.
12///
13/// Created by [`VenueRegistry::create_exchange_client`]. Provides
14/// capability discovery and a convenience `fetch_market_snapshot` that
15/// fetches all available data in parallel.
16pub struct ExchangeClient {
17    venue_id: String,
18    venue_name: String,
19    descriptor: VenueDescriptor,
20    order_book: Option<Box<dyn OrderBookClient>>,
21    ticker: Option<Box<dyn TickerClient>>,
22    trade_history: Option<Box<dyn TradeHistoryClient>>,
23}
24
25impl ExchangeClient {
26    /// Build an ExchangeClient from a VenueDescriptor.
27    ///
28    /// Each capability that exists in the descriptor gets a
29    /// `ConfigurableExchangeClient` implementation wired in.
30    pub fn from_descriptor(desc: &VenueDescriptor) -> Self {
31        let client = ConfigurableExchangeClient::new(desc.clone());
32
33        let order_book: Option<Box<dyn OrderBookClient>> = if desc.has_order_book() {
34            Some(Box::new(client.clone()))
35        } else {
36            None
37        };
38        let ticker: Option<Box<dyn TickerClient>> = if desc.has_ticker() {
39            Some(Box::new(client.clone()))
40        } else {
41            None
42        };
43        let trade_history: Option<Box<dyn TradeHistoryClient>> = if desc.has_trades() {
44            Some(Box::new(client))
45        } else {
46            None
47        };
48
49        Self {
50            venue_id: desc.id.clone(),
51            venue_name: desc.name.clone(),
52            descriptor: desc.clone(),
53            order_book,
54            ticker,
55            trade_history,
56        }
57    }
58
59    // =========================================================================
60    // Metadata
61    // =========================================================================
62
63    /// Venue ID (e.g., "binance").
64    pub fn venue_id(&self) -> &str {
65        &self.venue_id
66    }
67
68    /// Venue display name (e.g., "Binance Spot").
69    pub fn venue_name(&self) -> &str {
70        &self.venue_name
71    }
72
73    /// Format a trading pair for this venue.
74    pub fn format_pair(&self, base: &str) -> String {
75        self.descriptor.format_pair(base, None)
76    }
77
78    /// Format a trading pair with explicit quote currency.
79    pub fn format_pair_with_quote(&self, base: &str, quote: &str) -> String {
80        self.descriptor.format_pair(base, Some(quote))
81    }
82
83    // =========================================================================
84    // Capability discovery
85    // =========================================================================
86
87    /// Whether this client supports order book fetching.
88    pub fn has_order_book(&self) -> bool {
89        self.order_book.is_some()
90    }
91
92    /// Whether this client supports ticker fetching.
93    pub fn has_ticker(&self) -> bool {
94        self.ticker.is_some()
95    }
96
97    /// Whether this client supports trade history fetching.
98    pub fn has_trade_history(&self) -> bool {
99        self.trade_history.is_some()
100    }
101
102    // =========================================================================
103    // Individual capability methods
104    // =========================================================================
105
106    /// Fetch order book (if supported).
107    pub async fn fetch_order_book(&self, pair: &str) -> Result<OrderBook> {
108        self.order_book
109            .as_ref()
110            .ok_or_else(|| {
111                ScopeError::Chain(format!("{} does not support order book", self.venue_name))
112            })?
113            .fetch_order_book(pair)
114            .await
115    }
116
117    /// Fetch ticker (if supported).
118    pub async fn fetch_ticker(&self, pair: &str) -> Result<Ticker> {
119        self.ticker
120            .as_ref()
121            .ok_or_else(|| {
122                ScopeError::Chain(format!("{} does not support ticker", self.venue_name))
123            })?
124            .fetch_ticker(pair)
125            .await
126    }
127
128    /// Fetch recent trades (if supported).
129    pub async fn fetch_recent_trades(&self, pair: &str, limit: u32) -> Result<Vec<Trade>> {
130        self.trade_history
131            .as_ref()
132            .ok_or_else(|| {
133                ScopeError::Chain(format!("{} does not support trades", self.venue_name))
134            })?
135            .fetch_recent_trades(pair, limit)
136            .await
137    }
138
139    // =========================================================================
140    // Combined fetch
141    // =========================================================================
142
143    /// Fetch all available market data for a pair in one call.
144    ///
145    /// Each capability is fetched independently; failures in one capability
146    /// do not prevent others from succeeding (they just produce `None`).
147    pub async fn fetch_market_snapshot(&self, pair: &str) -> MarketSnapshot {
148        let order_book = if self.has_order_book() {
149            self.fetch_order_book(pair).await.ok()
150        } else {
151            None
152        };
153
154        let ticker = if self.has_ticker() {
155            self.fetch_ticker(pair).await.ok()
156        } else {
157            None
158        };
159
160        let recent_trades = if self.has_trade_history() {
161            self.fetch_recent_trades(pair, 50).await.ok()
162        } else {
163            None
164        };
165
166        MarketSnapshot {
167            order_book,
168            ticker,
169            recent_trades,
170        }
171    }
172}
173
174impl std::fmt::Debug for ExchangeClient {
175    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176        f.debug_struct("ExchangeClient")
177            .field("venue_id", &self.venue_id)
178            .field("venue_name", &self.venue_name)
179            .field("has_order_book", &self.has_order_book())
180            .field("has_ticker", &self.has_ticker())
181            .field("has_trade_history", &self.has_trade_history())
182            .finish()
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use crate::market::registry::VenueRegistry;
190
191    #[test]
192    fn test_exchange_client_from_binance_descriptor() {
193        let registry = VenueRegistry::default();
194        let desc = registry.get("binance").unwrap();
195        let client = ExchangeClient::from_descriptor(desc);
196
197        assert_eq!(client.venue_id(), "binance");
198        assert_eq!(client.venue_name(), "Binance Spot");
199        assert!(client.has_order_book());
200        assert!(client.has_ticker());
201        assert!(client.has_trade_history());
202    }
203
204    #[test]
205    fn test_exchange_client_format_pair() {
206        let registry = VenueRegistry::default();
207        let desc = registry.get("binance").unwrap();
208        let client = ExchangeClient::from_descriptor(desc);
209        assert_eq!(client.format_pair("BTC"), "BTCUSDT");
210        assert_eq!(client.format_pair_with_quote("ETH", "USD"), "ETHUSD");
211    }
212
213    #[test]
214    fn test_exchange_client_all_venues() {
215        let registry = VenueRegistry::default();
216        for venue_id in registry.list() {
217            let desc = registry.get(venue_id).unwrap();
218            let client = ExchangeClient::from_descriptor(desc);
219            assert_eq!(client.venue_id(), venue_id);
220            // All built-in venues should have at least order_book
221            assert!(
222                client.has_order_book(),
223                "Venue {} missing order_book capability",
224                venue_id
225            );
226        }
227    }
228
229    #[test]
230    fn test_exchange_client_debug() {
231        let registry = VenueRegistry::default();
232        let desc = registry.get("okx").unwrap();
233        let client = ExchangeClient::from_descriptor(desc);
234        let debug = format!("{:?}", client);
235        assert!(debug.contains("okx"));
236        assert!(debug.contains("has_order_book: true"));
237    }
238}