Skip to main content

scope/market/
descriptor.rs

1//! Venue descriptor schema for data-driven exchange integration.
2//!
3//! Each venue is described by a YAML file that defines HTTP mechanics and
4//! response field mappings. The [`VenueDescriptor`] struct is deserialized
5//! from these files and interpreted at runtime by `ConfigurableExchangeClient`.
6
7use serde::Deserialize;
8use std::collections::HashMap;
9
10/// How to format the trading pair symbol for a venue.
11#[derive(Debug, Clone, Deserialize)]
12pub struct SymbolConfig {
13    /// Template with `{base}` and `{quote}` placeholders.
14    /// Examples: `"{base}{quote}"` → `BTCUSDT`, `"{base}_{quote}"` → `BTC_USDT`.
15    pub template: String,
16
17    /// Default quote currency when the user doesn't specify one.
18    pub default_quote: String,
19
20    /// Case transformation for the final symbol. Defaults to `Upper`.
21    #[serde(default)]
22    pub case: SymbolCase,
23}
24
25/// Case transformation applied to the formatted symbol.
26#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
27#[serde(rename_all = "lowercase")]
28pub enum SymbolCase {
29    #[default]
30    Upper,
31    Lower,
32}
33
34/// HTTP method for an endpoint (defaults to GET).
35#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
36#[serde(rename_all = "UPPERCASE")]
37pub enum HttpMethod {
38    #[default]
39    #[serde(alias = "get")]
40    GET,
41    #[serde(alias = "post")]
42    POST,
43}
44
45/// Describes a single API endpoint (order_book, ticker, or trades).
46#[derive(Debug, Clone, Deserialize)]
47pub struct EndpointDescriptor {
48    /// HTTP method. Defaults to GET.
49    #[serde(default)]
50    pub method: HttpMethod,
51
52    /// URL path appended to `base_url` (e.g., `/api/v3/depth`).
53    pub path: String,
54
55    /// Query parameters for GET, or ignored for POST.
56    /// Values may contain `{pair}`, `{limit}`, `{base}`, `{quote}` placeholders.
57    #[serde(default)]
58    pub params: HashMap<String, String>,
59
60    /// JSON body template for POST requests. Values may contain placeholders.
61    pub request_body: Option<serde_json::Value>,
62
63    /// Dot-path to navigate from the JSON root to the data before field mapping.
64    ///
65    /// Special values:
66    /// - `""` or omitted: root is the data.
67    /// - `"result"`: `json["result"]`.
68    /// - `"data.0"`: `json["data"][0]`.
69    /// - `"result.*"`: first value under `json["result"]` regardless of key.
70    pub response_root: Option<String>,
71
72    /// Field mappings for parsing the response.
73    pub response: ResponseMapping,
74}
75
76/// Field mapping configuration for parsing venue API responses.
77///
78/// All fields are optional; omitting a field means the venue doesn't provide
79/// that data (the corresponding Rust `Option` will be `None`).
80#[derive(Debug, Clone, Deserialize, Default)]
81pub struct ResponseMapping {
82    // -- Order book fields --
83    /// JSON key for the asks array.
84    pub asks_key: Option<String>,
85    /// JSON key for the bids array.
86    pub bids_key: Option<String>,
87    /// Level format: `"positional"` (default) for `[price, qty]` arrays,
88    /// `"object"` for `{price: x, size: y}` objects.
89    pub level_format: Option<String>,
90    /// Field name for price when `level_format` is `"object"`.
91    pub level_price_field: Option<String>,
92    /// Field name for size/quantity when `level_format` is `"object"`.
93    pub level_size_field: Option<String>,
94
95    // -- Ticker fields (response key → JSON field name) --
96    pub last_price: Option<String>,
97    pub high_24h: Option<String>,
98    pub low_24h: Option<String>,
99    pub volume_24h: Option<String>,
100    pub quote_volume_24h: Option<String>,
101    pub best_bid: Option<String>,
102    pub best_ask: Option<String>,
103
104    // -- Trade / array fields --
105    /// JSON key holding the array of items. Empty or omitted = root is the array.
106    pub items_key: Option<String>,
107
108    /// Filter configuration for endpoints that return data for all pairs.
109    pub filter: Option<FilterConfig>,
110
111    /// Field mappings for individual trade items.
112    pub price: Option<String>,
113    pub quantity: Option<String>,
114    pub quote_quantity: Option<String>,
115    pub timestamp_ms: Option<String>,
116    pub id: Option<String>,
117    pub side: Option<SideMapping>,
118}
119
120/// Maps venue-specific side indicators to canonical buy/sell.
121#[derive(Debug, Clone, Deserialize)]
122pub struct SideMapping {
123    /// JSON field that contains the side indicator.
124    pub field: String,
125    /// Map from venue-specific values to `"buy"` or `"sell"`.
126    pub mapping: HashMap<String, String>,
127}
128
129/// Filter configuration for multi-pair endpoints.
130#[derive(Debug, Clone, Deserialize)]
131pub struct FilterConfig {
132    /// Response field to match against.
133    pub field: String,
134    /// Expected value (supports `{pair}` interpolation).
135    pub value: String,
136}
137
138/// Set of API capabilities a venue provides.
139/// Each capability is optional — omit if the venue doesn't support it.
140#[derive(Debug, Clone, Deserialize, Default)]
141pub struct CapabilitySet {
142    /// Order book / depth endpoint.
143    pub order_book: Option<EndpointDescriptor>,
144    /// 24h ticker endpoint.
145    pub ticker: Option<EndpointDescriptor>,
146    /// Recent trades endpoint.
147    pub trades: Option<EndpointDescriptor>,
148}
149
150/// Complete venue descriptor deserialized from a YAML file.
151///
152/// Defines everything needed to interact with an exchange venue:
153/// base URL, authentication headers, symbol formatting, rate limits,
154/// and per-capability endpoint configurations.
155#[derive(Debug, Clone, Deserialize)]
156pub struct VenueDescriptor {
157    /// Unique venue identifier (e.g., `"binance"`).
158    pub id: String,
159    /// Human-readable name (e.g., `"Binance Spot"`).
160    pub name: String,
161    /// API base URL (e.g., `"https://api.binance.com"`).
162    pub base_url: String,
163    /// Request timeout in seconds.
164    pub timeout_secs: Option<u64>,
165    /// Rate limit (requests per second).
166    pub rate_limit_per_sec: Option<u32>,
167    /// How to format the trading pair symbol.
168    pub symbol: SymbolConfig,
169    /// Headers added to all requests (e.g., `X-SITE-ID: "127"`).
170    #[serde(default)]
171    pub headers: HashMap<String, String>,
172    /// Available API capabilities.
173    #[serde(default)]
174    pub capabilities: CapabilitySet,
175}
176
177impl VenueDescriptor {
178    /// Format a trading pair symbol for this venue.
179    ///
180    /// Replaces `{base}` and `{quote}` in the template, then applies case.
181    pub fn format_pair(&self, base: &str, quote: Option<&str>) -> String {
182        let q = quote.unwrap_or(&self.symbol.default_quote);
183        let raw = self
184            .symbol
185            .template
186            .replace("{base}", base)
187            .replace("{quote}", q);
188        match self.symbol.case {
189            SymbolCase::Upper => raw.to_uppercase(),
190            SymbolCase::Lower => raw.to_lowercase(),
191        }
192    }
193
194    /// Check which capabilities this venue supports.
195    pub fn has_order_book(&self) -> bool {
196        self.capabilities.order_book.is_some()
197    }
198    pub fn has_ticker(&self) -> bool {
199        self.capabilities.ticker.is_some()
200    }
201    pub fn has_trades(&self) -> bool {
202        self.capabilities.trades.is_some()
203    }
204
205    /// Return a list of capability names this venue supports.
206    pub fn capability_names(&self) -> Vec<&'static str> {
207        let mut caps = Vec::new();
208        if self.has_order_book() {
209            caps.push("order_book");
210        }
211        if self.has_ticker() {
212            caps.push("ticker");
213        }
214        if self.has_trades() {
215            caps.push("trades");
216        }
217        caps
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn test_symbol_case_default_is_upper() {
227        let case = SymbolCase::default();
228        assert_eq!(case, SymbolCase::Upper);
229    }
230
231    #[test]
232    fn test_http_method_default_is_get() {
233        let method = HttpMethod::default();
234        assert_eq!(method, HttpMethod::GET);
235    }
236
237    #[test]
238    fn test_format_pair_upper() {
239        let desc = VenueDescriptor {
240            id: "test".to_string(),
241            name: "Test".to_string(),
242            base_url: "https://example.com".to_string(),
243            timeout_secs: None,
244            rate_limit_per_sec: None,
245            symbol: SymbolConfig {
246                template: "{base}{quote}".to_string(),
247                default_quote: "USDT".to_string(),
248                case: SymbolCase::Upper,
249            },
250            headers: HashMap::new(),
251            capabilities: CapabilitySet::default(),
252        };
253        assert_eq!(desc.format_pair("BTC", None), "BTCUSDT");
254        assert_eq!(desc.format_pair("btc", None), "BTCUSDT");
255        assert_eq!(desc.format_pair("ETH", Some("USD")), "ETHUSD");
256    }
257
258    #[test]
259    fn test_format_pair_lower() {
260        let desc = VenueDescriptor {
261            id: "htx".to_string(),
262            name: "HTX".to_string(),
263            base_url: "https://api.huobi.pro".to_string(),
264            timeout_secs: None,
265            rate_limit_per_sec: None,
266            symbol: SymbolConfig {
267                template: "{base}{quote}".to_string(),
268                default_quote: "USDT".to_string(),
269                case: SymbolCase::Lower,
270            },
271            headers: HashMap::new(),
272            capabilities: CapabilitySet::default(),
273        };
274        assert_eq!(desc.format_pair("BTC", None), "btcusdt");
275    }
276
277    #[test]
278    fn test_format_pair_underscore() {
279        let desc = VenueDescriptor {
280            id: "biconomy".to_string(),
281            name: "Biconomy".to_string(),
282            base_url: "https://api.biconomy.com".to_string(),
283            timeout_secs: None,
284            rate_limit_per_sec: None,
285            symbol: SymbolConfig {
286                template: "{base}_{quote}".to_string(),
287                default_quote: "USDT".to_string(),
288                case: SymbolCase::Upper,
289            },
290            headers: HashMap::new(),
291            capabilities: CapabilitySet::default(),
292        };
293        assert_eq!(desc.format_pair("PUSD", None), "PUSD_USDT");
294    }
295
296    #[test]
297    fn test_format_pair_dash() {
298        let desc = VenueDescriptor {
299            id: "okx".to_string(),
300            name: "OKX".to_string(),
301            base_url: "https://www.okx.com".to_string(),
302            timeout_secs: None,
303            rate_limit_per_sec: None,
304            symbol: SymbolConfig {
305                template: "{base}-{quote}".to_string(),
306                default_quote: "USDT".to_string(),
307                case: SymbolCase::Upper,
308            },
309            headers: HashMap::new(),
310            capabilities: CapabilitySet::default(),
311        };
312        assert_eq!(desc.format_pair("BTC", None), "BTC-USDT");
313    }
314
315    #[test]
316    fn test_capability_names_all() {
317        let desc = VenueDescriptor {
318            id: "test".to_string(),
319            name: "Test".to_string(),
320            base_url: "https://example.com".to_string(),
321            timeout_secs: None,
322            rate_limit_per_sec: None,
323            symbol: SymbolConfig {
324                template: "{base}{quote}".to_string(),
325                default_quote: "USDT".to_string(),
326                case: SymbolCase::Upper,
327            },
328            headers: HashMap::new(),
329            capabilities: CapabilitySet {
330                order_book: Some(EndpointDescriptor {
331                    method: HttpMethod::GET,
332                    path: "/depth".to_string(),
333                    params: HashMap::new(),
334                    request_body: None,
335                    response_root: None,
336                    response: ResponseMapping::default(),
337                }),
338                ticker: Some(EndpointDescriptor {
339                    method: HttpMethod::GET,
340                    path: "/ticker".to_string(),
341                    params: HashMap::new(),
342                    request_body: None,
343                    response_root: None,
344                    response: ResponseMapping::default(),
345                }),
346                trades: Some(EndpointDescriptor {
347                    method: HttpMethod::GET,
348                    path: "/trades".to_string(),
349                    params: HashMap::new(),
350                    request_body: None,
351                    response_root: None,
352                    response: ResponseMapping::default(),
353                }),
354            },
355        };
356        let caps = desc.capability_names();
357        assert_eq!(caps, vec!["order_book", "ticker", "trades"]);
358    }
359
360    #[test]
361    fn test_capability_names_partial() {
362        let desc = VenueDescriptor {
363            id: "test".to_string(),
364            name: "Test".to_string(),
365            base_url: "https://example.com".to_string(),
366            timeout_secs: None,
367            rate_limit_per_sec: None,
368            symbol: SymbolConfig {
369                template: "{base}{quote}".to_string(),
370                default_quote: "USDT".to_string(),
371                case: SymbolCase::Upper,
372            },
373            headers: HashMap::new(),
374            capabilities: CapabilitySet {
375                order_book: Some(EndpointDescriptor {
376                    method: HttpMethod::GET,
377                    path: "/depth".to_string(),
378                    params: HashMap::new(),
379                    request_body: None,
380                    response_root: None,
381                    response: ResponseMapping::default(),
382                }),
383                ticker: None,
384                trades: None,
385            },
386        };
387        assert_eq!(desc.capability_names(), vec!["order_book"]);
388    }
389
390    #[test]
391    fn test_deserialize_binance_yaml() {
392        let yaml = r#"
393id: binance
394name: Binance Spot
395base_url: https://api.binance.com
396timeout_secs: 15
397rate_limit_per_sec: 10
398
399symbol:
400  template: "{base}{quote}"
401  default_quote: USDT
402
403capabilities:
404  order_book:
405    path: /api/v3/depth
406    params:
407      symbol: "{pair}"
408      limit: "100"
409    response:
410      asks_key: asks
411      bids_key: bids
412      level_format: positional
413
414  ticker:
415    path: /api/v3/ticker/24hr
416    params:
417      symbol: "{pair}"
418    response:
419      last_price: lastPrice
420      high_24h: highPrice
421      low_24h: lowPrice
422      volume_24h: volume
423      quote_volume_24h: quoteVolume
424      best_bid: bidPrice
425      best_ask: askPrice
426
427  trades:
428    path: /api/v3/trades
429    params:
430      symbol: "{pair}"
431      limit: "{limit}"
432    response:
433      price: price
434      quantity: qty
435      quote_quantity: quoteQty
436      timestamp_ms: time
437      id: id
438      side:
439        field: isBuyerMaker
440        mapping:
441          "true": sell
442          "false": buy
443"#;
444        let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
445        assert_eq!(desc.id, "binance");
446        assert_eq!(desc.name, "Binance Spot");
447        assert_eq!(desc.base_url, "https://api.binance.com");
448        assert_eq!(desc.timeout_secs, Some(15));
449        assert_eq!(desc.symbol.template, "{base}{quote}");
450        assert_eq!(desc.symbol.default_quote, "USDT");
451        assert_eq!(desc.symbol.case, SymbolCase::Upper);
452
453        // Order book
454        let ob = desc.capabilities.order_book.as_ref().unwrap();
455        assert_eq!(ob.path, "/api/v3/depth");
456        assert_eq!(ob.params.get("symbol"), Some(&"{pair}".to_string()));
457        assert_eq!(ob.response.asks_key, Some("asks".to_string()));
458        assert_eq!(ob.response.level_format, Some("positional".to_string()));
459
460        // Ticker
461        let ticker = desc.capabilities.ticker.as_ref().unwrap();
462        assert_eq!(ticker.response.last_price, Some("lastPrice".to_string()));
463        assert_eq!(ticker.response.volume_24h, Some("volume".to_string()));
464
465        // Trades
466        let trades = desc.capabilities.trades.as_ref().unwrap();
467        assert_eq!(trades.response.price, Some("price".to_string()));
468        let side = trades.response.side.as_ref().unwrap();
469        assert_eq!(side.field, "isBuyerMaker");
470        assert_eq!(side.mapping.get("true"), Some(&"sell".to_string()));
471    }
472
473    #[test]
474    fn test_deserialize_htx_lowercase() {
475        let yaml = r#"
476id: htx
477name: HTX
478base_url: https://api.huobi.pro
479
480symbol:
481  template: "{base}{quote}"
482  default_quote: USDT
483  case: lower
484
485capabilities:
486  order_book:
487    path: /market/depth
488    params:
489      symbol: "{pair}"
490      type: step0
491    response_root: tick
492    response:
493      asks_key: asks
494      bids_key: bids
495      level_format: positional
496"#;
497        let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
498        assert_eq!(desc.symbol.case, SymbolCase::Lower);
499        assert_eq!(desc.format_pair("BTC", None), "btcusdt");
500        let ob = desc.capabilities.order_book.as_ref().unwrap();
501        assert_eq!(ob.response_root, Some("tick".to_string()));
502    }
503
504    #[test]
505    fn test_deserialize_post_method() {
506        let yaml = r#"
507id: crypto_com
508name: Crypto.com
509base_url: https://api.crypto.com/exchange/v1
510
511symbol:
512  template: "{base}_{quote}"
513  default_quote: USDT
514
515capabilities:
516  order_book:
517    method: POST
518    path: /public/get-book
519    request_body:
520      method: "public/get-book"
521      params:
522        instrument_name: "{pair}"
523        depth: "100"
524    response_root: "result.data.0"
525    response:
526      asks_key: asks
527      bids_key: bids
528      level_format: positional
529"#;
530        let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
531        let ob = desc.capabilities.order_book.as_ref().unwrap();
532        assert_eq!(ob.method, HttpMethod::POST);
533        assert!(ob.request_body.is_some());
534        assert_eq!(ob.response_root, Some("result.data.0".to_string()));
535    }
536
537    #[test]
538    fn test_deserialize_object_level_format() {
539        let yaml = r#"
540id: coinbase
541name: Coinbase
542base_url: https://api.coinbase.com
543
544symbol:
545  template: "{base}-{quote}"
546  default_quote: USD
547
548capabilities:
549  order_book:
550    path: /api/v3/brokerage/market/product_book
551    params:
552      product_id: "{pair}"
553      limit: "100"
554    response_root: pricebook
555    response:
556      asks_key: asks
557      bids_key: bids
558      level_format: object
559      level_price_field: price
560      level_size_field: size
561"#;
562        let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
563        let ob = desc.capabilities.order_book.as_ref().unwrap();
564        assert_eq!(ob.response.level_format, Some("object".to_string()));
565        assert_eq!(ob.response.level_price_field, Some("price".to_string()));
566        assert_eq!(ob.response.level_size_field, Some("size".to_string()));
567        assert_eq!(ob.response_root, Some("pricebook".to_string()));
568    }
569}