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