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    /// Maps canonical interval names (e.g., `1m`, `5m`, `1h`, `1d`) to the
73    /// venue-specific strings (e.g., `1min`, `5min`, `hour`, `day`).
74    /// Only used by the OHLC capability; omit when the venue accepts canonical names.
75    #[serde(default)]
76    pub interval_map: HashMap<String, String>,
77
78    /// Field mappings for parsing the response.
79    pub response: ResponseMapping,
80}
81
82/// Field mapping configuration for parsing venue API responses.
83///
84/// All fields are optional; omitting a field means the venue doesn't provide
85/// that data (the corresponding Rust `Option` will be `None`).
86#[derive(Debug, Clone, Deserialize, Default)]
87pub struct ResponseMapping {
88    // -- Order book fields --
89    /// JSON key for the asks array.
90    pub asks_key: Option<String>,
91    /// JSON key for the bids array.
92    pub bids_key: Option<String>,
93    /// Level format: `"positional"` (default) for `[price, qty]` arrays,
94    /// `"object"` for `{price: x, size: y}` objects.
95    pub level_format: Option<String>,
96    /// Field name for price when `level_format` is `"object"`.
97    pub level_price_field: Option<String>,
98    /// Field name for size/quantity when `level_format` is `"object"`.
99    pub level_size_field: Option<String>,
100
101    // -- Ticker fields (response key → JSON field name) --
102    pub last_price: Option<String>,
103    pub high_24h: Option<String>,
104    pub low_24h: Option<String>,
105    pub volume_24h: Option<String>,
106    pub quote_volume_24h: Option<String>,
107    pub best_bid: Option<String>,
108    pub best_ask: Option<String>,
109
110    // -- Trade / array fields --
111    /// JSON key holding the array of items. Empty or omitted = root is the array.
112    pub items_key: Option<String>,
113
114    /// Filter configuration for endpoints that return data for all pairs.
115    pub filter: Option<FilterConfig>,
116
117    /// Field mappings for individual trade items.
118    pub price: Option<String>,
119    pub quantity: Option<String>,
120    pub quote_quantity: Option<String>,
121    pub timestamp_ms: Option<String>,
122    pub id: Option<String>,
123    pub side: Option<SideMapping>,
124
125    // -- OHLC / klines fields --
126    /// Response format: `"array_of_arrays"` (e.g., Binance) or `"objects"` (default).
127    /// When `"array_of_arrays"`, each candle is a positional array and the
128    /// `ohlc_fields` list determines field order.
129    pub ohlc_format: Option<String>,
130    /// Ordered field names for array-of-arrays format.
131    /// Default: `["open_time", "open", "high", "low", "close", "volume", "close_time"]`.
132    pub ohlc_fields: Option<Vec<String>>,
133    // For object format, reuse: open_time, open, high, low, close, volume, close_time
134    // mapped from explicit field names below.
135    pub open_time: Option<String>,
136    pub open: Option<String>,
137    pub high: Option<String>,
138    pub low: Option<String>,
139    pub close: Option<String>,
140    /// Base volume field name (for OHLC). Falls back to `volume_24h` if absent.
141    pub ohlc_volume: Option<String>,
142    pub close_time: Option<String>,
143}
144
145/// Maps venue-specific side indicators to canonical buy/sell.
146#[derive(Debug, Clone, Deserialize)]
147pub struct SideMapping {
148    /// JSON field that contains the side indicator.
149    pub field: String,
150    /// Map from venue-specific values to `"buy"` or `"sell"`.
151    pub mapping: HashMap<String, String>,
152}
153
154/// Filter configuration for multi-pair endpoints.
155#[derive(Debug, Clone, Deserialize)]
156pub struct FilterConfig {
157    /// Response field to match against.
158    pub field: String,
159    /// Expected value (supports `{pair}` interpolation).
160    pub value: String,
161}
162
163/// Set of API capabilities a venue provides.
164/// Each capability is optional — omit if the venue doesn't support it.
165#[derive(Debug, Clone, Deserialize, Default)]
166pub struct CapabilitySet {
167    /// Order book / depth endpoint.
168    pub order_book: Option<EndpointDescriptor>,
169    /// 24h ticker endpoint.
170    pub ticker: Option<EndpointDescriptor>,
171    /// Recent trades endpoint.
172    pub trades: Option<EndpointDescriptor>,
173    /// OHLC / klines / candlestick endpoint.
174    pub ohlc: Option<EndpointDescriptor>,
175}
176
177/// Complete venue descriptor deserialized from a YAML file.
178///
179/// Defines everything needed to interact with an exchange venue:
180/// base URL, authentication headers, symbol formatting, rate limits,
181/// and per-capability endpoint configurations.
182#[derive(Debug, Clone, Deserialize)]
183pub struct VenueDescriptor {
184    /// Unique venue identifier (e.g., `"binance"`).
185    pub id: String,
186    /// Human-readable name (e.g., `"Binance Spot"`).
187    pub name: String,
188    /// API base URL (e.g., `"https://api.binance.com"`).
189    pub base_url: String,
190    /// Request timeout in seconds.
191    pub timeout_secs: Option<u64>,
192    /// Rate limit (requests per second).
193    pub rate_limit_per_sec: Option<u32>,
194    /// How to format the trading pair symbol.
195    pub symbol: SymbolConfig,
196    /// Headers added to all requests (e.g., `X-SITE-ID: "127"`).
197    #[serde(default)]
198    pub headers: HashMap<String, String>,
199    /// Available API capabilities.
200    #[serde(default)]
201    pub capabilities: CapabilitySet,
202}
203
204impl VenueDescriptor {
205    /// Format a trading pair symbol for this venue.
206    ///
207    /// Replaces `{base}` and `{quote}` in the template, then applies case.
208    pub fn format_pair(&self, base: &str, quote: Option<&str>) -> String {
209        let q = quote.unwrap_or(&self.symbol.default_quote);
210        let raw = self
211            .symbol
212            .template
213            .replace("{base}", base)
214            .replace("{quote}", q);
215        match self.symbol.case {
216            SymbolCase::Upper => raw.to_uppercase(),
217            SymbolCase::Lower => raw.to_lowercase(),
218        }
219    }
220
221    /// Check which capabilities this venue supports.
222    pub fn has_order_book(&self) -> bool {
223        self.capabilities.order_book.is_some()
224    }
225    pub fn has_ticker(&self) -> bool {
226        self.capabilities.ticker.is_some()
227    }
228    pub fn has_trades(&self) -> bool {
229        self.capabilities.trades.is_some()
230    }
231    pub fn has_ohlc(&self) -> bool {
232        self.capabilities.ohlc.is_some()
233    }
234
235    /// Return a list of capability names this venue supports.
236    pub fn capability_names(&self) -> Vec<&'static str> {
237        let mut caps = Vec::new();
238        if self.has_order_book() {
239            caps.push("order_book");
240        }
241        if self.has_ticker() {
242            caps.push("ticker");
243        }
244        if self.has_trades() {
245            caps.push("trades");
246        }
247        if self.has_ohlc() {
248            caps.push("ohlc");
249        }
250        caps
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn test_symbol_case_default_is_upper() {
260        let case = SymbolCase::default();
261        assert_eq!(case, SymbolCase::Upper);
262    }
263
264    #[test]
265    fn test_http_method_default_is_get() {
266        let method = HttpMethod::default();
267        assert_eq!(method, HttpMethod::GET);
268    }
269
270    #[test]
271    fn test_format_pair_upper() {
272        let desc = VenueDescriptor {
273            id: "test".to_string(),
274            name: "Test".to_string(),
275            base_url: "https://example.com".to_string(),
276            timeout_secs: None,
277            rate_limit_per_sec: None,
278            symbol: SymbolConfig {
279                template: "{base}{quote}".to_string(),
280                default_quote: "USDT".to_string(),
281                case: SymbolCase::Upper,
282            },
283            headers: HashMap::new(),
284            capabilities: CapabilitySet::default(),
285        };
286        assert_eq!(desc.format_pair("BTC", None), "BTCUSDT");
287        assert_eq!(desc.format_pair("btc", None), "BTCUSDT");
288        assert_eq!(desc.format_pair("ETH", Some("USD")), "ETHUSD");
289    }
290
291    #[test]
292    fn test_format_pair_lower() {
293        let desc = VenueDescriptor {
294            id: "htx".to_string(),
295            name: "HTX".to_string(),
296            base_url: "https://api.huobi.pro".to_string(),
297            timeout_secs: None,
298            rate_limit_per_sec: None,
299            symbol: SymbolConfig {
300                template: "{base}{quote}".to_string(),
301                default_quote: "USDT".to_string(),
302                case: SymbolCase::Lower,
303            },
304            headers: HashMap::new(),
305            capabilities: CapabilitySet::default(),
306        };
307        assert_eq!(desc.format_pair("BTC", None), "btcusdt");
308    }
309
310    #[test]
311    fn test_format_pair_underscore() {
312        let desc = VenueDescriptor {
313            id: "biconomy".to_string(),
314            name: "Biconomy".to_string(),
315            base_url: "https://api.biconomy.com".to_string(),
316            timeout_secs: None,
317            rate_limit_per_sec: None,
318            symbol: SymbolConfig {
319                template: "{base}_{quote}".to_string(),
320                default_quote: "USDT".to_string(),
321                case: SymbolCase::Upper,
322            },
323            headers: HashMap::new(),
324            capabilities: CapabilitySet::default(),
325        };
326        assert_eq!(desc.format_pair("PUSD", None), "PUSD_USDT");
327    }
328
329    #[test]
330    fn test_format_pair_dash() {
331        let desc = VenueDescriptor {
332            id: "okx".to_string(),
333            name: "OKX".to_string(),
334            base_url: "https://www.okx.com".to_string(),
335            timeout_secs: None,
336            rate_limit_per_sec: None,
337            symbol: SymbolConfig {
338                template: "{base}-{quote}".to_string(),
339                default_quote: "USDT".to_string(),
340                case: SymbolCase::Upper,
341            },
342            headers: HashMap::new(),
343            capabilities: CapabilitySet::default(),
344        };
345        assert_eq!(desc.format_pair("BTC", None), "BTC-USDT");
346    }
347
348    #[test]
349    fn test_capability_names_all() {
350        let desc = VenueDescriptor {
351            id: "test".to_string(),
352            name: "Test".to_string(),
353            base_url: "https://example.com".to_string(),
354            timeout_secs: None,
355            rate_limit_per_sec: None,
356            symbol: SymbolConfig {
357                template: "{base}{quote}".to_string(),
358                default_quote: "USDT".to_string(),
359                case: SymbolCase::Upper,
360            },
361            headers: HashMap::new(),
362            capabilities: CapabilitySet {
363                order_book: Some(EndpointDescriptor {
364                    method: HttpMethod::GET,
365                    path: "/depth".to_string(),
366                    params: HashMap::new(),
367                    request_body: None,
368                    response_root: None,
369                    interval_map: HashMap::new(),
370                    response: ResponseMapping::default(),
371                }),
372                ticker: Some(EndpointDescriptor {
373                    method: HttpMethod::GET,
374                    path: "/ticker".to_string(),
375                    params: HashMap::new(),
376                    request_body: None,
377                    response_root: None,
378                    interval_map: HashMap::new(),
379                    response: ResponseMapping::default(),
380                }),
381                trades: Some(EndpointDescriptor {
382                    method: HttpMethod::GET,
383                    path: "/trades".to_string(),
384                    params: HashMap::new(),
385                    request_body: None,
386                    response_root: None,
387                    interval_map: HashMap::new(),
388                    response: ResponseMapping::default(),
389                }),
390                ohlc: None,
391            },
392        };
393        let caps = desc.capability_names();
394        assert_eq!(caps, vec!["order_book", "ticker", "trades"]);
395    }
396
397    #[test]
398    fn test_capability_names_partial() {
399        let desc = VenueDescriptor {
400            id: "test".to_string(),
401            name: "Test".to_string(),
402            base_url: "https://example.com".to_string(),
403            timeout_secs: None,
404            rate_limit_per_sec: None,
405            symbol: SymbolConfig {
406                template: "{base}{quote}".to_string(),
407                default_quote: "USDT".to_string(),
408                case: SymbolCase::Upper,
409            },
410            headers: HashMap::new(),
411            capabilities: CapabilitySet {
412                order_book: Some(EndpointDescriptor {
413                    method: HttpMethod::GET,
414                    path: "/depth".to_string(),
415                    params: HashMap::new(),
416                    request_body: None,
417                    response_root: None,
418                    interval_map: HashMap::new(),
419                    response: ResponseMapping::default(),
420                }),
421                ticker: None,
422                trades: None,
423                ohlc: None,
424            },
425        };
426        assert_eq!(desc.capability_names(), vec!["order_book"]);
427    }
428
429    #[test]
430    fn test_deserialize_binance_yaml() {
431        let yaml = r#"
432id: binance
433name: Binance Spot
434base_url: https://api.binance.com
435timeout_secs: 15
436rate_limit_per_sec: 10
437
438symbol:
439  template: "{base}{quote}"
440  default_quote: USDT
441
442capabilities:
443  order_book:
444    path: /api/v3/depth
445    params:
446      symbol: "{pair}"
447      limit: "100"
448    response:
449      asks_key: asks
450      bids_key: bids
451      level_format: positional
452
453  ticker:
454    path: /api/v3/ticker/24hr
455    params:
456      symbol: "{pair}"
457    response:
458      last_price: lastPrice
459      high_24h: highPrice
460      low_24h: lowPrice
461      volume_24h: volume
462      quote_volume_24h: quoteVolume
463      best_bid: bidPrice
464      best_ask: askPrice
465
466  trades:
467    path: /api/v3/trades
468    params:
469      symbol: "{pair}"
470      limit: "{limit}"
471    response:
472      price: price
473      quantity: qty
474      quote_quantity: quoteQty
475      timestamp_ms: time
476      id: id
477      side:
478        field: isBuyerMaker
479        mapping:
480          "true": sell
481          "false": buy
482"#;
483        let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
484        assert_eq!(desc.id, "binance");
485        assert_eq!(desc.name, "Binance Spot");
486        assert_eq!(desc.base_url, "https://api.binance.com");
487        assert_eq!(desc.timeout_secs, Some(15));
488        assert_eq!(desc.symbol.template, "{base}{quote}");
489        assert_eq!(desc.symbol.default_quote, "USDT");
490        assert_eq!(desc.symbol.case, SymbolCase::Upper);
491
492        // Order book
493        let ob = desc.capabilities.order_book.as_ref().unwrap();
494        assert_eq!(ob.path, "/api/v3/depth");
495        assert_eq!(ob.params.get("symbol"), Some(&"{pair}".to_string()));
496        assert_eq!(ob.response.asks_key, Some("asks".to_string()));
497        assert_eq!(ob.response.level_format, Some("positional".to_string()));
498
499        // Ticker
500        let ticker = desc.capabilities.ticker.as_ref().unwrap();
501        assert_eq!(ticker.response.last_price, Some("lastPrice".to_string()));
502        assert_eq!(ticker.response.volume_24h, Some("volume".to_string()));
503
504        // Trades
505        let trades = desc.capabilities.trades.as_ref().unwrap();
506        assert_eq!(trades.response.price, Some("price".to_string()));
507        let side = trades.response.side.as_ref().unwrap();
508        assert_eq!(side.field, "isBuyerMaker");
509        assert_eq!(side.mapping.get("true"), Some(&"sell".to_string()));
510    }
511
512    #[test]
513    fn test_deserialize_htx_lowercase() {
514        let yaml = r#"
515id: htx
516name: HTX
517base_url: https://api.huobi.pro
518
519symbol:
520  template: "{base}{quote}"
521  default_quote: USDT
522  case: lower
523
524capabilities:
525  order_book:
526    path: /market/depth
527    params:
528      symbol: "{pair}"
529      type: step0
530    response_root: tick
531    response:
532      asks_key: asks
533      bids_key: bids
534      level_format: positional
535"#;
536        let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
537        assert_eq!(desc.symbol.case, SymbolCase::Lower);
538        assert_eq!(desc.format_pair("BTC", None), "btcusdt");
539        let ob = desc.capabilities.order_book.as_ref().unwrap();
540        assert_eq!(ob.response_root, Some("tick".to_string()));
541    }
542
543    #[test]
544    fn test_deserialize_post_method() {
545        let yaml = r#"
546id: crypto_com
547name: Crypto.com
548base_url: https://api.crypto.com/exchange/v1
549
550symbol:
551  template: "{base}_{quote}"
552  default_quote: USDT
553
554capabilities:
555  order_book:
556    method: POST
557    path: /public/get-book
558    request_body:
559      method: "public/get-book"
560      params:
561        instrument_name: "{pair}"
562        depth: "100"
563    response_root: "result.data.0"
564    response:
565      asks_key: asks
566      bids_key: bids
567      level_format: positional
568"#;
569        let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
570        let ob = desc.capabilities.order_book.as_ref().unwrap();
571        assert_eq!(ob.method, HttpMethod::POST);
572        assert!(ob.request_body.is_some());
573        assert_eq!(ob.response_root, Some("result.data.0".to_string()));
574    }
575
576    #[test]
577    fn test_deserialize_object_level_format() {
578        let yaml = r#"
579id: coinbase
580name: Coinbase
581base_url: https://api.coinbase.com
582
583symbol:
584  template: "{base}-{quote}"
585  default_quote: USD
586
587capabilities:
588  order_book:
589    path: /api/v3/brokerage/market/product_book
590    params:
591      product_id: "{pair}"
592      limit: "100"
593    response_root: pricebook
594    response:
595      asks_key: asks
596      bids_key: bids
597      level_format: object
598      level_price_field: price
599      level_size_field: size
600"#;
601        let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
602        let ob = desc.capabilities.order_book.as_ref().unwrap();
603        assert_eq!(ob.response.level_format, Some("object".to_string()));
604        assert_eq!(ob.response.level_price_field, Some("price".to_string()));
605        assert_eq!(ob.response.level_size_field, Some("size".to_string()));
606        assert_eq!(ob.response_root, Some("pricebook".to_string()));
607    }
608
609    #[test]
610    fn test_venue_descriptor_format_pair_upper() {
611        let desc = VenueDescriptor {
612            id: "test".to_string(),
613            name: "Test".to_string(),
614            base_url: "https://example.com".to_string(),
615            timeout_secs: None,
616            rate_limit_per_sec: None,
617            symbol: SymbolConfig {
618                template: "{base}{quote}".to_string(),
619                default_quote: "USDT".to_string(),
620                case: SymbolCase::Upper,
621            },
622            headers: HashMap::new(),
623            capabilities: CapabilitySet::default(),
624        };
625        assert_eq!(desc.format_pair("btc", None), "BTCUSDT");
626        assert_eq!(desc.format_pair("ETH", Some("USD")), "ETHUSD");
627    }
628
629    #[test]
630    fn test_venue_descriptor_format_pair_lower() {
631        let desc = VenueDescriptor {
632            id: "test".to_string(),
633            name: "Test".to_string(),
634            base_url: "https://example.com".to_string(),
635            timeout_secs: None,
636            rate_limit_per_sec: None,
637            symbol: SymbolConfig {
638                template: "{base}{quote}".to_string(),
639                default_quote: "USDT".to_string(),
640                case: SymbolCase::Lower,
641            },
642            headers: HashMap::new(),
643            capabilities: CapabilitySet::default(),
644        };
645        assert_eq!(desc.format_pair("BTC", None), "btcusdt");
646        assert_eq!(desc.format_pair("ETH", Some("usd")), "ethusd");
647    }
648
649    #[test]
650    fn test_venue_descriptor_format_pair_with_separator() {
651        let desc = VenueDescriptor {
652            id: "test".to_string(),
653            name: "Test".to_string(),
654            base_url: "https://example.com".to_string(),
655            timeout_secs: None,
656            rate_limit_per_sec: None,
657            symbol: SymbolConfig {
658                template: "{base}_{quote}".to_string(),
659                default_quote: "USDT".to_string(),
660                case: SymbolCase::Upper,
661            },
662            headers: HashMap::new(),
663            capabilities: CapabilitySet::default(),
664        };
665        assert_eq!(desc.format_pair("BTC", None), "BTC_USDT");
666        assert_eq!(desc.format_pair("PUSD", Some("USD")), "PUSD_USD");
667    }
668
669    #[test]
670    fn test_capability_names() {
671        // Empty capabilities
672        let desc = VenueDescriptor {
673            id: "empty".to_string(),
674            name: "Empty".to_string(),
675            base_url: "https://example.com".to_string(),
676            timeout_secs: None,
677            rate_limit_per_sec: None,
678            symbol: SymbolConfig {
679                template: "{base}{quote}".to_string(),
680                default_quote: "USDT".to_string(),
681                case: SymbolCase::Upper,
682            },
683            headers: HashMap::new(),
684            capabilities: CapabilitySet::default(),
685        };
686        assert!(desc.capability_names().is_empty());
687
688        // Only trades
689        let desc_trades = VenueDescriptor {
690            id: "trades_only".to_string(),
691            name: "Trades Only".to_string(),
692            base_url: "https://example.com".to_string(),
693            timeout_secs: None,
694            rate_limit_per_sec: None,
695            symbol: SymbolConfig {
696                template: "{base}{quote}".to_string(),
697                default_quote: "USDT".to_string(),
698                case: SymbolCase::Upper,
699            },
700            headers: HashMap::new(),
701            capabilities: CapabilitySet {
702                order_book: None,
703                ticker: None,
704                trades: Some(EndpointDescriptor {
705                    method: HttpMethod::GET,
706                    path: "/trades".to_string(),
707                    params: HashMap::new(),
708                    request_body: None,
709                    response_root: None,
710                    interval_map: HashMap::new(),
711                    response: ResponseMapping::default(),
712                }),
713                ohlc: None,
714            },
715        };
716        assert_eq!(desc_trades.capability_names(), vec!["trades"]);
717    }
718
719    #[test]
720    fn test_has_order_book() {
721        let with_ob = VenueDescriptor {
722            id: "x".to_string(),
723            name: "X".to_string(),
724            base_url: "https://x.com".to_string(),
725            timeout_secs: None,
726            rate_limit_per_sec: None,
727            symbol: SymbolConfig {
728                template: "{base}{quote}".to_string(),
729                default_quote: "USDT".to_string(),
730                case: SymbolCase::Upper,
731            },
732            headers: HashMap::new(),
733            capabilities: CapabilitySet {
734                order_book: Some(EndpointDescriptor {
735                    method: HttpMethod::GET,
736                    path: "/depth".to_string(),
737                    params: HashMap::new(),
738                    request_body: None,
739                    response_root: None,
740                    interval_map: HashMap::new(),
741                    response: ResponseMapping::default(),
742                }),
743                ticker: None,
744                trades: None,
745                ohlc: None,
746            },
747        };
748        assert!(with_ob.has_order_book());
749
750        let without = VenueDescriptor {
751            capabilities: CapabilitySet::default(),
752            ..with_ob
753        };
754        assert!(!without.has_order_book());
755    }
756
757    #[test]
758    fn test_has_ticker() {
759        let with_ticker = VenueDescriptor {
760            id: "x".to_string(),
761            name: "X".to_string(),
762            base_url: "https://x.com".to_string(),
763            timeout_secs: None,
764            rate_limit_per_sec: None,
765            symbol: SymbolConfig {
766                template: "{base}{quote}".to_string(),
767                default_quote: "USDT".to_string(),
768                case: SymbolCase::Upper,
769            },
770            headers: HashMap::new(),
771            capabilities: CapabilitySet {
772                order_book: None,
773                ticker: Some(EndpointDescriptor {
774                    method: HttpMethod::GET,
775                    path: "/ticker".to_string(),
776                    params: HashMap::new(),
777                    request_body: None,
778                    response_root: None,
779                    interval_map: HashMap::new(),
780                    response: ResponseMapping::default(),
781                }),
782                trades: None,
783                ohlc: None,
784            },
785        };
786        assert!(with_ticker.has_ticker());
787
788        let without = VenueDescriptor {
789            capabilities: CapabilitySet::default(),
790            ..with_ticker
791        };
792        assert!(!without.has_ticker());
793    }
794
795    #[test]
796    fn test_has_trades() {
797        let with_trades = VenueDescriptor {
798            id: "x".to_string(),
799            name: "X".to_string(),
800            base_url: "https://x.com".to_string(),
801            timeout_secs: None,
802            rate_limit_per_sec: None,
803            symbol: SymbolConfig {
804                template: "{base}{quote}".to_string(),
805                default_quote: "USDT".to_string(),
806                case: SymbolCase::Upper,
807            },
808            headers: HashMap::new(),
809            capabilities: CapabilitySet {
810                order_book: None,
811                ticker: None,
812                trades: Some(EndpointDescriptor {
813                    method: HttpMethod::GET,
814                    path: "/trades".to_string(),
815                    params: HashMap::new(),
816                    request_body: None,
817                    response_root: None,
818                    interval_map: HashMap::new(),
819                    response: ResponseMapping::default(),
820                }),
821                ohlc: None,
822            },
823        };
824        assert!(with_trades.has_trades());
825
826        let without = VenueDescriptor {
827            capabilities: CapabilitySet::default(),
828            ..with_trades
829        };
830        assert!(!without.has_trades());
831    }
832
833    #[test]
834    fn test_has_ohlc() {
835        let with_ohlc = VenueDescriptor {
836            id: "x".to_string(),
837            name: "X".to_string(),
838            base_url: "https://x.com".to_string(),
839            timeout_secs: None,
840            rate_limit_per_sec: None,
841            symbol: SymbolConfig {
842                template: "{base}{quote}".to_string(),
843                default_quote: "USDT".to_string(),
844                case: SymbolCase::Upper,
845            },
846            headers: HashMap::new(),
847            capabilities: CapabilitySet {
848                order_book: None,
849                ticker: None,
850                trades: None,
851                ohlc: Some(EndpointDescriptor {
852                    method: HttpMethod::GET,
853                    path: "/klines".to_string(),
854                    params: HashMap::new(),
855                    request_body: None,
856                    response_root: None,
857                    interval_map: HashMap::new(),
858                    response: ResponseMapping::default(),
859                }),
860            },
861        };
862        assert!(with_ohlc.has_ohlc());
863        assert!(with_ohlc.capability_names().contains(&"ohlc"));
864
865        let without = VenueDescriptor {
866            capabilities: CapabilitySet::default(),
867            ..with_ohlc
868        };
869        assert!(!without.has_ohlc());
870        assert!(!without.capability_names().contains(&"ohlc"));
871    }
872
873    #[test]
874    fn test_response_mapping_default() {
875        let m = ResponseMapping::default();
876        assert!(m.asks_key.is_none());
877        assert!(m.bids_key.is_none());
878        assert!(m.level_format.is_none());
879        assert!(m.side.is_none());
880    }
881
882    #[test]
883    fn test_capability_set_default() {
884        let c = CapabilitySet::default();
885        assert!(c.order_book.is_none());
886        assert!(c.ticker.is_none());
887        assert!(c.trades.is_none());
888        assert!(c.ohlc.is_none());
889    }
890
891    #[test]
892    fn test_deserialize_ohlc_capability() {
893        let yaml = r#"
894id: ohlc_venue
895name: OHLC Venue Test
896base_url: https://api.example.com
897
898symbol:
899  template: "{base}{quote}"
900  default_quote: USDT
901
902capabilities:
903  order_book:
904    path: /depth
905    params:
906      symbol: "{pair}"
907    response:
908      asks_key: asks
909      bids_key: bids
910      level_format: positional
911  ohlc:
912    path: /api/v3/klines
913    params:
914      symbol: "{pair}"
915      interval: "{interval}"
916      limit: "{limit}"
917    response:
918      ohlc_format: array_of_arrays
919      ohlc_fields: [open_time, open, high, low, close, volume, close_time]
920"#;
921        let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
922        assert!(desc.has_ohlc());
923        assert!(desc.capability_names().contains(&"ohlc"));
924        let ohlc = desc.capabilities.ohlc.as_ref().unwrap();
925        assert_eq!(ohlc.path, "/api/v3/klines");
926        assert_eq!(ohlc.params.get("interval"), Some(&"{interval}".to_string()));
927        // interval_map should default to empty when omitted from YAML
928        assert!(ohlc.interval_map.is_empty());
929    }
930
931    #[test]
932    fn test_deserialize_ohlc_with_interval_map() {
933        let yaml = r#"
934id: biconomy_test
935name: Biconomy Test
936base_url: https://api.biconomy.com
937
938symbol:
939  template: "{base}_{quote}"
940  default_quote: USDT
941
942capabilities:
943  ohlc:
944    path: /api/v1/kline
945    params:
946      symbol: "{pair}"
947      type: "{interval}"
948      size: "{limit}"
949    interval_map:
950      1m: 1min
951      5m: 5min
952      15m: 15min
953      30m: 30min
954      1h: hour
955      4h: hour
956      1d: day
957    response:
958      ohlc_format: array_of_arrays
959      ohlc_fields: [open_time, open, high, low, close, volume]
960"#;
961        let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
962        assert!(desc.has_ohlc());
963        let ohlc = desc.capabilities.ohlc.as_ref().unwrap();
964        assert_eq!(ohlc.interval_map.len(), 7);
965        assert_eq!(ohlc.interval_map.get("1m"), Some(&"1min".to_string()));
966        assert_eq!(ohlc.interval_map.get("1h"), Some(&"hour".to_string()));
967        assert_eq!(ohlc.interval_map.get("4h"), Some(&"hour".to_string()));
968        assert_eq!(ohlc.interval_map.get("1d"), Some(&"day".to_string()));
969        // Unmapped keys should not be present
970        assert!(!ohlc.interval_map.contains_key("1w"));
971    }
972
973    #[test]
974    fn test_deserialize_biconomy_venue_yaml() {
975        let yaml =
976            std::fs::read_to_string("venues/biconomy.yaml").expect("biconomy.yaml should exist");
977        let desc: VenueDescriptor = serde_yaml::from_str(&yaml).unwrap();
978        assert_eq!(desc.id, "biconomy");
979        assert_eq!(desc.name, "Biconomy");
980        assert!(desc.has_order_book());
981        assert!(desc.has_ticker());
982        assert!(desc.has_trades());
983        assert!(desc.has_ohlc());
984        let ohlc = desc.capabilities.ohlc.as_ref().unwrap();
985        assert!(
986            !ohlc.interval_map.is_empty(),
987            "biconomy should have interval_map"
988        );
989        assert_eq!(ohlc.interval_map.get("1h"), Some(&"hour".to_string()));
990    }
991
992    #[test]
993    fn test_endpoint_descriptor_interval_map_defaults_empty() {
994        let ep = EndpointDescriptor {
995            method: HttpMethod::GET,
996            path: "/test".to_string(),
997            params: HashMap::new(),
998            request_body: None,
999            response_root: None,
1000            interval_map: HashMap::new(),
1001            response: ResponseMapping::default(),
1002        };
1003        assert!(ep.interval_map.is_empty());
1004    }
1005
1006    #[test]
1007    fn test_deserialize_full_descriptor_yaml() {
1008        let yaml = r#"
1009id: test_venue
1010name: Test Venue Full
1011base_url: https://api.test.com
1012timeout_secs: 30
1013rate_limit_per_sec: 5
1014
1015symbol:
1016  template: "{base}-{quote}"
1017  default_quote: USD
1018  case: upper
1019
1020headers:
1021  X-API-KEY: "placeholder"
1022
1023capabilities:
1024  order_book:
1025    path: /book
1026    params:
1027      pair: "{pair}"
1028    response:
1029      asks_key: asks
1030      bids_key: bids
1031  ticker:
1032    path: /ticker
1033    response:
1034      last_price: last
1035  trades:
1036    path: /trades
1037    response:
1038      price: p
1039      quantity: q
1040"#;
1041        let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
1042        assert_eq!(desc.id, "test_venue");
1043        assert_eq!(desc.name, "Test Venue Full");
1044        assert_eq!(desc.base_url, "https://api.test.com");
1045        assert_eq!(desc.timeout_secs, Some(30));
1046        assert_eq!(desc.rate_limit_per_sec, Some(5));
1047        assert_eq!(desc.symbol.template, "{base}-{quote}");
1048        assert_eq!(desc.symbol.default_quote, "USD");
1049        assert_eq!(
1050            desc.headers.get("X-API-KEY"),
1051            Some(&"placeholder".to_string())
1052        );
1053        assert!(desc.has_order_book());
1054        assert!(desc.has_ticker());
1055        assert!(desc.has_trades());
1056        assert_eq!(
1057            desc.capability_names(),
1058            vec!["order_book", "ticker", "trades"]
1059        );
1060    }
1061}