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    /// 24h price change in percent (not a fraction).
108    pub price_change_pct_24h: Option<String>,
109    pub best_bid: Option<String>,
110    pub best_ask: Option<String>,
111
112    // -- Trade / array fields --
113    /// JSON key holding the array of items. Empty or omitted = root is the array.
114    pub items_key: Option<String>,
115
116    /// Filter configuration for endpoints that return data for all pairs.
117    pub filter: Option<FilterConfig>,
118
119    /// Field mappings for individual trade items.
120    pub price: Option<String>,
121    pub quantity: Option<String>,
122    pub quote_quantity: Option<String>,
123    pub timestamp_ms: Option<String>,
124    pub id: Option<String>,
125    pub side: Option<SideMapping>,
126
127    // -- OHLC / klines fields --
128    /// Response format: `"array_of_arrays"` (e.g., Binance) or `"objects"` (default).
129    /// When `"array_of_arrays"`, each candle is a positional array and the
130    /// `ohlc_fields` list determines field order.
131    pub ohlc_format: Option<String>,
132    /// Ordered field names for array-of-arrays format.
133    /// Default: `["open_time", "open", "high", "low", "close", "volume", "close_time"]`.
134    pub ohlc_fields: Option<Vec<String>>,
135    // For object format, reuse: open_time, open, high, low, close, volume, close_time
136    // mapped from explicit field names below.
137    pub open_time: Option<String>,
138    pub open: Option<String>,
139    pub high: Option<String>,
140    pub low: Option<String>,
141    pub close: Option<String>,
142    /// Base volume field name (for OHLC). Falls back to `volume_24h` if absent.
143    pub ohlc_volume: Option<String>,
144    pub close_time: Option<String>,
145
146    // -- Asset (coin list) fields --
147    /// Field with the asset symbol in each coin item (e.g. `coin`).
148    pub asset: Option<String>,
149    /// Key of the network array in each coin item (e.g. `networkList`).
150    pub networks_key: Option<String>,
151    /// Field with the network name in each network item (e.g. `network`).
152    pub network: Option<String>,
153    /// Field with the token contract in each network item (e.g.
154    /// `contractAddress`).
155    pub contract: Option<String>,
156}
157
158/// Maps venue-specific side indicators to canonical buy/sell.
159#[derive(Debug, Clone, Deserialize)]
160pub struct SideMapping {
161    /// JSON field that contains the side indicator.
162    pub field: String,
163    /// Map from venue-specific values to `"buy"` or `"sell"`.
164    pub mapping: HashMap<String, String>,
165}
166
167/// Filter configuration for multi-pair endpoints.
168#[derive(Debug, Clone, Deserialize)]
169pub struct FilterConfig {
170    /// Response field to match against.
171    pub field: String,
172    /// Expected value (supports `{pair}` interpolation).
173    pub value: String,
174}
175
176/// Set of API capabilities a venue provides.
177/// Each capability is optional — omit if the venue doesn't support it.
178#[derive(Debug, Clone, Deserialize, Default)]
179pub struct CapabilitySet {
180    /// Order book / depth endpoint.
181    pub order_book: Option<EndpointDescriptor>,
182    /// 24h ticker endpoint.
183    pub ticker: Option<EndpointDescriptor>,
184    /// Recent trades endpoint.
185    pub trades: Option<EndpointDescriptor>,
186    /// OHLC / klines / candlestick endpoint.
187    pub ohlc: Option<EndpointDescriptor>,
188    /// Coin list with each asset's networks and token contracts, so a
189    /// report can show which token a symbol is.
190    pub assets: Option<EndpointDescriptor>,
191}
192
193/// Complete venue descriptor deserialized from a YAML file.
194///
195/// Defines everything needed to interact with an exchange venue:
196/// base URL, authentication headers, symbol formatting, rate limits,
197/// and per-capability endpoint configurations.
198#[derive(Debug, Clone, Deserialize)]
199pub struct VenueDescriptor {
200    /// Unique venue identifier (e.g., `"binance"`).
201    pub id: String,
202    /// Human-readable name (e.g., `"Binance Spot"`).
203    pub name: String,
204    /// API base URL (e.g., `"https://api.binance.com"`).
205    pub base_url: String,
206    /// Request timeout in seconds.
207    pub timeout_secs: Option<u64>,
208    /// Rate limit (requests per second).
209    pub rate_limit_per_sec: Option<u32>,
210    /// How to format the trading pair symbol.
211    pub symbol: SymbolConfig,
212    /// Headers added to all requests (e.g., `X-SITE-ID: "127"`).
213    #[serde(default)]
214    pub headers: HashMap<String, String>,
215    /// Available API capabilities.
216    #[serde(default)]
217    pub capabilities: CapabilitySet,
218    /// How the venue reports an error inside an HTTP 200 reply (#69).
219    #[serde(default)]
220    pub error_envelope: Option<ErrorEnvelope>,
221}
222
223/// The fields that carry an error in a successful (HTTP 200) venue reply.
224///
225/// With `code_key`, the reply is an error when that field is present and its
226/// value is not in `ok_codes`. Without it, the reply is an error when
227/// `message_key` holds a non-empty string or array (Kraken's `error: [...]`).
228#[derive(Debug, Clone, Deserialize)]
229pub struct ErrorEnvelope {
230    /// Field with the status code (e.g. `retCode`). A missing field means success.
231    #[serde(default)]
232    pub code_key: Option<String>,
233    /// Code values that mean success, compared as strings (e.g. `"0"`).
234    #[serde(default)]
235    pub ok_codes: Vec<String>,
236    /// Field with the venue's error message (e.g. `retMsg`).
237    pub message_key: String,
238}
239
240impl ErrorEnvelope {
241    /// The venue's error message, or `None` when the reply is not an error.
242    pub fn error_message(&self, reply: &serde_json::Value) -> Option<String> {
243        let message = reply.get(&self.message_key);
244        let text = || match message {
245            Some(serde_json::Value::String(s)) => s.clone(),
246            Some(serde_json::Value::Array(items)) => items
247                .iter()
248                .map(|v| v.as_str().map_or_else(|| v.to_string(), str::to_string))
249                .collect::<Vec<_>>()
250                .join("; "),
251            Some(other) => other.to_string(),
252            None => String::new(),
253        };
254        match &self.code_key {
255            Some(key) => {
256                let code = reply.get(key)?;
257                let code = code
258                    .as_str()
259                    .map_or_else(|| code.to_string(), str::to_string);
260                if self.ok_codes.contains(&code) {
261                    return None;
262                }
263                let msg = text();
264                Some(if msg.is_empty() {
265                    format!("error code {code}")
266                } else {
267                    format!("{msg} (code {code})")
268                })
269            }
270            None => {
271                let failed = match message {
272                    Some(serde_json::Value::String(s)) => !s.is_empty(),
273                    Some(serde_json::Value::Array(items)) => !items.is_empty(),
274                    _ => false,
275                };
276                failed.then(text)
277            }
278        }
279    }
280}
281
282impl VenueDescriptor {
283    /// Format a trading pair symbol for this venue.
284    ///
285    /// Replaces `{base}` and `{quote}` in the template, then applies case.
286    pub fn format_pair(&self, base: &str, quote: Option<&str>) -> String {
287        let q = quote.unwrap_or(&self.symbol.default_quote);
288        let raw = self
289            .symbol
290            .template
291            .replace("{base}", base)
292            .replace("{quote}", q);
293        match self.symbol.case {
294            SymbolCase::Upper => raw.to_uppercase(),
295            SymbolCase::Lower => raw.to_lowercase(),
296        }
297    }
298
299    /// Check which capabilities this venue supports.
300    pub fn has_order_book(&self) -> bool {
301        self.capabilities.order_book.is_some()
302    }
303    pub fn has_ticker(&self) -> bool {
304        self.capabilities.ticker.is_some()
305    }
306    pub fn has_trades(&self) -> bool {
307        self.capabilities.trades.is_some()
308    }
309    pub fn has_ohlc(&self) -> bool {
310        self.capabilities.ohlc.is_some()
311    }
312
313    /// Return a list of capability names this venue supports.
314    pub fn capability_names(&self) -> Vec<&'static str> {
315        let mut caps = Vec::new();
316        if self.has_order_book() {
317            caps.push("order_book");
318        }
319        if self.has_ticker() {
320            caps.push("ticker");
321        }
322        if self.has_trades() {
323            caps.push("trades");
324        }
325        if self.has_ohlc() {
326            caps.push("ohlc");
327        }
328        caps
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn test_symbol_case_default_is_upper() {
338        let case = SymbolCase::default();
339        assert_eq!(case, SymbolCase::Upper);
340    }
341
342    #[test]
343    fn test_http_method_default_is_get() {
344        let method = HttpMethod::default();
345        assert_eq!(method, HttpMethod::GET);
346    }
347
348    #[test]
349    fn test_format_pair_upper() {
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            error_envelope: None,
363            capabilities: CapabilitySet::default(),
364        };
365        assert_eq!(desc.format_pair("BTC", None), "BTCUSDT");
366        assert_eq!(desc.format_pair("btc", None), "BTCUSDT");
367        assert_eq!(desc.format_pair("ETH", Some("USD")), "ETHUSD");
368    }
369
370    #[test]
371    fn test_format_pair_lower() {
372        let desc = VenueDescriptor {
373            id: "htx".to_string(),
374            name: "HTX".to_string(),
375            base_url: "https://api.huobi.pro".to_string(),
376            timeout_secs: None,
377            rate_limit_per_sec: None,
378            symbol: SymbolConfig {
379                template: "{base}{quote}".to_string(),
380                default_quote: "USDT".to_string(),
381                case: SymbolCase::Lower,
382            },
383            headers: HashMap::new(),
384            error_envelope: None,
385            capabilities: CapabilitySet::default(),
386        };
387        assert_eq!(desc.format_pair("BTC", None), "btcusdt");
388    }
389
390    #[test]
391    fn test_format_pair_underscore() {
392        let desc = VenueDescriptor {
393            id: "biconomy".to_string(),
394            name: "Biconomy".to_string(),
395            base_url: "https://api.biconomy.com".to_string(),
396            timeout_secs: None,
397            rate_limit_per_sec: None,
398            symbol: SymbolConfig {
399                template: "{base}_{quote}".to_string(),
400                default_quote: "USDT".to_string(),
401                case: SymbolCase::Upper,
402            },
403            headers: HashMap::new(),
404            error_envelope: None,
405            capabilities: CapabilitySet::default(),
406        };
407        assert_eq!(desc.format_pair("DAI", None), "DAI_USDT");
408    }
409
410    #[test]
411    fn test_format_pair_dash() {
412        let desc = VenueDescriptor {
413            id: "okx".to_string(),
414            name: "OKX".to_string(),
415            base_url: "https://www.okx.com".to_string(),
416            timeout_secs: None,
417            rate_limit_per_sec: None,
418            symbol: SymbolConfig {
419                template: "{base}-{quote}".to_string(),
420                default_quote: "USDT".to_string(),
421                case: SymbolCase::Upper,
422            },
423            headers: HashMap::new(),
424            error_envelope: None,
425            capabilities: CapabilitySet::default(),
426        };
427        assert_eq!(desc.format_pair("BTC", None), "BTC-USDT");
428    }
429
430    #[test]
431    fn test_capability_names_all() {
432        let desc = VenueDescriptor {
433            id: "test".to_string(),
434            name: "Test".to_string(),
435            base_url: "https://example.com".to_string(),
436            timeout_secs: None,
437            rate_limit_per_sec: None,
438            symbol: SymbolConfig {
439                template: "{base}{quote}".to_string(),
440                default_quote: "USDT".to_string(),
441                case: SymbolCase::Upper,
442            },
443            headers: HashMap::new(),
444            error_envelope: None,
445            capabilities: CapabilitySet {
446                assets: None,
447                order_book: Some(EndpointDescriptor {
448                    method: HttpMethod::GET,
449                    path: "/depth".to_string(),
450                    params: HashMap::new(),
451                    request_body: None,
452                    response_root: None,
453                    interval_map: HashMap::new(),
454                    response: ResponseMapping::default(),
455                }),
456                ticker: Some(EndpointDescriptor {
457                    method: HttpMethod::GET,
458                    path: "/ticker".to_string(),
459                    params: HashMap::new(),
460                    request_body: None,
461                    response_root: None,
462                    interval_map: HashMap::new(),
463                    response: ResponseMapping::default(),
464                }),
465                trades: Some(EndpointDescriptor {
466                    method: HttpMethod::GET,
467                    path: "/trades".to_string(),
468                    params: HashMap::new(),
469                    request_body: None,
470                    response_root: None,
471                    interval_map: HashMap::new(),
472                    response: ResponseMapping::default(),
473                }),
474                ohlc: None,
475            },
476        };
477        let caps = desc.capability_names();
478        assert_eq!(caps, vec!["order_book", "ticker", "trades"]);
479    }
480
481    #[test]
482    fn test_capability_names_partial() {
483        let desc = VenueDescriptor {
484            id: "test".to_string(),
485            name: "Test".to_string(),
486            base_url: "https://example.com".to_string(),
487            timeout_secs: None,
488            rate_limit_per_sec: None,
489            symbol: SymbolConfig {
490                template: "{base}{quote}".to_string(),
491                default_quote: "USDT".to_string(),
492                case: SymbolCase::Upper,
493            },
494            headers: HashMap::new(),
495            error_envelope: None,
496            capabilities: CapabilitySet {
497                assets: None,
498                order_book: Some(EndpointDescriptor {
499                    method: HttpMethod::GET,
500                    path: "/depth".to_string(),
501                    params: HashMap::new(),
502                    request_body: None,
503                    response_root: None,
504                    interval_map: HashMap::new(),
505                    response: ResponseMapping::default(),
506                }),
507                ticker: None,
508                trades: None,
509                ohlc: None,
510            },
511        };
512        assert_eq!(desc.capability_names(), vec!["order_book"]);
513    }
514
515    #[test]
516    fn test_deserialize_binance_yaml() {
517        let yaml = r#"
518id: binance
519name: Binance Spot
520base_url: https://api.binance.com
521timeout_secs: 15
522rate_limit_per_sec: 10
523
524symbol:
525  template: "{base}{quote}"
526  default_quote: USDT
527
528
529capabilities:
530  order_book:
531    path: /api/v3/depth
532    params:
533      symbol: "{pair}"
534      limit: "100"
535    response:
536      asks_key: asks
537      bids_key: bids
538      level_format: positional
539
540  ticker:
541    path: /api/v3/ticker/24hr
542    params:
543      symbol: "{pair}"
544    response:
545      last_price: lastPrice
546      high_24h: highPrice
547      low_24h: lowPrice
548      volume_24h: volume
549      quote_volume_24h: quoteVolume
550      best_bid: bidPrice
551      best_ask: askPrice
552
553  trades:
554    path: /api/v3/trades
555    params:
556      symbol: "{pair}"
557      limit: "{limit}"
558    response:
559      price: price
560      quantity: qty
561      quote_quantity: quoteQty
562      timestamp_ms: time
563      id: id
564      side:
565        field: isBuyerMaker
566        mapping:
567          "true": sell
568          "false": buy
569"#;
570        let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
571        assert_eq!(desc.id, "binance");
572        assert_eq!(desc.name, "Binance Spot");
573        assert_eq!(desc.base_url, "https://api.binance.com");
574        assert_eq!(desc.timeout_secs, Some(15));
575        assert_eq!(desc.symbol.template, "{base}{quote}");
576        assert_eq!(desc.symbol.default_quote, "USDT");
577        assert_eq!(desc.symbol.case, SymbolCase::Upper);
578
579        // Order book
580        let ob = desc.capabilities.order_book.as_ref().unwrap();
581        assert_eq!(ob.path, "/api/v3/depth");
582        assert_eq!(ob.params.get("symbol"), Some(&"{pair}".to_string()));
583        assert_eq!(ob.response.asks_key, Some("asks".to_string()));
584        assert_eq!(ob.response.level_format, Some("positional".to_string()));
585
586        // Ticker
587        let ticker = desc.capabilities.ticker.as_ref().unwrap();
588        assert_eq!(ticker.response.last_price, Some("lastPrice".to_string()));
589        assert_eq!(ticker.response.volume_24h, Some("volume".to_string()));
590
591        // Trades
592        let trades = desc.capabilities.trades.as_ref().unwrap();
593        assert_eq!(trades.response.price, Some("price".to_string()));
594        let side = trades.response.side.as_ref().unwrap();
595        assert_eq!(side.field, "isBuyerMaker");
596        assert_eq!(side.mapping.get("true"), Some(&"sell".to_string()));
597    }
598
599    #[test]
600    fn test_deserialize_htx_lowercase() {
601        let yaml = r#"
602id: htx
603name: HTX
604base_url: https://api.huobi.pro
605
606symbol:
607  template: "{base}{quote}"
608  default_quote: USDT
609  case: lower
610
611
612capabilities:
613  order_book:
614    path: /market/depth
615    params:
616      symbol: "{pair}"
617      type: step0
618    response_root: tick
619    response:
620      asks_key: asks
621      bids_key: bids
622      level_format: positional
623"#;
624        let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
625        assert_eq!(desc.symbol.case, SymbolCase::Lower);
626        assert_eq!(desc.format_pair("BTC", None), "btcusdt");
627        let ob = desc.capabilities.order_book.as_ref().unwrap();
628        assert_eq!(ob.response_root, Some("tick".to_string()));
629    }
630
631    #[test]
632    fn test_deserialize_post_method() {
633        let yaml = r#"
634id: crypto_com
635name: Crypto.com
636base_url: https://api.crypto.com/exchange/v1
637
638symbol:
639  template: "{base}_{quote}"
640  default_quote: USDT
641
642
643capabilities:
644  order_book:
645    method: POST
646    path: /public/get-book
647    request_body:
648      method: "public/get-book"
649      params:
650        instrument_name: "{pair}"
651        depth: "100"
652    response_root: "result.data.0"
653    response:
654      asks_key: asks
655      bids_key: bids
656      level_format: positional
657"#;
658        let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
659        let ob = desc.capabilities.order_book.as_ref().unwrap();
660        assert_eq!(ob.method, HttpMethod::POST);
661        assert!(ob.request_body.is_some());
662        assert_eq!(ob.response_root, Some("result.data.0".to_string()));
663    }
664
665    #[test]
666    fn test_deserialize_object_level_format() {
667        let yaml = r#"
668id: coinbase
669name: Coinbase
670base_url: https://api.coinbase.com
671
672symbol:
673  template: "{base}-{quote}"
674  default_quote: USD
675
676
677capabilities:
678  order_book:
679    path: /api/v3/brokerage/market/product_book
680    params:
681      product_id: "{pair}"
682      limit: "100"
683    response_root: pricebook
684    response:
685      asks_key: asks
686      bids_key: bids
687      level_format: object
688      level_price_field: price
689      level_size_field: size
690"#;
691        let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
692        let ob = desc.capabilities.order_book.as_ref().unwrap();
693        assert_eq!(ob.response.level_format, Some("object".to_string()));
694        assert_eq!(ob.response.level_price_field, Some("price".to_string()));
695        assert_eq!(ob.response.level_size_field, Some("size".to_string()));
696        assert_eq!(ob.response_root, Some("pricebook".to_string()));
697    }
698
699    #[test]
700    fn test_venue_descriptor_format_pair_upper() {
701        let desc = VenueDescriptor {
702            id: "test".to_string(),
703            name: "Test".to_string(),
704            base_url: "https://example.com".to_string(),
705            timeout_secs: None,
706            rate_limit_per_sec: None,
707            symbol: SymbolConfig {
708                template: "{base}{quote}".to_string(),
709                default_quote: "USDT".to_string(),
710                case: SymbolCase::Upper,
711            },
712            headers: HashMap::new(),
713            error_envelope: None,
714            capabilities: CapabilitySet::default(),
715        };
716        assert_eq!(desc.format_pair("btc", None), "BTCUSDT");
717        assert_eq!(desc.format_pair("ETH", Some("USD")), "ETHUSD");
718    }
719
720    #[test]
721    fn test_venue_descriptor_format_pair_lower() {
722        let desc = VenueDescriptor {
723            id: "test".to_string(),
724            name: "Test".to_string(),
725            base_url: "https://example.com".to_string(),
726            timeout_secs: None,
727            rate_limit_per_sec: None,
728            symbol: SymbolConfig {
729                template: "{base}{quote}".to_string(),
730                default_quote: "USDT".to_string(),
731                case: SymbolCase::Lower,
732            },
733            headers: HashMap::new(),
734            error_envelope: None,
735            capabilities: CapabilitySet::default(),
736        };
737        assert_eq!(desc.format_pair("BTC", None), "btcusdt");
738        assert_eq!(desc.format_pair("ETH", Some("usd")), "ethusd");
739    }
740
741    #[test]
742    fn test_venue_descriptor_format_pair_with_separator() {
743        let desc = VenueDescriptor {
744            id: "test".to_string(),
745            name: "Test".to_string(),
746            base_url: "https://example.com".to_string(),
747            timeout_secs: None,
748            rate_limit_per_sec: None,
749            symbol: SymbolConfig {
750                template: "{base}_{quote}".to_string(),
751                default_quote: "USDT".to_string(),
752                case: SymbolCase::Upper,
753            },
754            headers: HashMap::new(),
755            error_envelope: None,
756            capabilities: CapabilitySet::default(),
757        };
758        assert_eq!(desc.format_pair("BTC", None), "BTC_USDT");
759        assert_eq!(desc.format_pair("DAI", Some("USD")), "DAI_USD");
760    }
761
762    #[test]
763    fn test_capability_names() {
764        // Empty capabilities
765        let desc = VenueDescriptor {
766            id: "empty".to_string(),
767            name: "Empty".to_string(),
768            base_url: "https://example.com".to_string(),
769            timeout_secs: None,
770            rate_limit_per_sec: None,
771            symbol: SymbolConfig {
772                template: "{base}{quote}".to_string(),
773                default_quote: "USDT".to_string(),
774                case: SymbolCase::Upper,
775            },
776            headers: HashMap::new(),
777            error_envelope: None,
778            capabilities: CapabilitySet::default(),
779        };
780        assert!(desc.capability_names().is_empty());
781
782        // Only trades
783        let desc_trades = VenueDescriptor {
784            id: "trades_only".to_string(),
785            name: "Trades Only".to_string(),
786            base_url: "https://example.com".to_string(),
787            timeout_secs: None,
788            rate_limit_per_sec: None,
789            symbol: SymbolConfig {
790                template: "{base}{quote}".to_string(),
791                default_quote: "USDT".to_string(),
792                case: SymbolCase::Upper,
793            },
794            headers: HashMap::new(),
795            error_envelope: None,
796            capabilities: CapabilitySet {
797                assets: None,
798                order_book: None,
799                ticker: None,
800                trades: Some(EndpointDescriptor {
801                    method: HttpMethod::GET,
802                    path: "/trades".to_string(),
803                    params: HashMap::new(),
804                    request_body: None,
805                    response_root: None,
806                    interval_map: HashMap::new(),
807                    response: ResponseMapping::default(),
808                }),
809                ohlc: None,
810            },
811        };
812        assert_eq!(desc_trades.capability_names(), vec!["trades"]);
813    }
814
815    #[test]
816    fn test_has_order_book() {
817        let with_ob = VenueDescriptor {
818            id: "x".to_string(),
819            name: "X".to_string(),
820            base_url: "https://x.com".to_string(),
821            timeout_secs: None,
822            rate_limit_per_sec: None,
823            symbol: SymbolConfig {
824                template: "{base}{quote}".to_string(),
825                default_quote: "USDT".to_string(),
826                case: SymbolCase::Upper,
827            },
828            headers: HashMap::new(),
829            error_envelope: None,
830            capabilities: CapabilitySet {
831                assets: None,
832                order_book: Some(EndpointDescriptor {
833                    method: HttpMethod::GET,
834                    path: "/depth".to_string(),
835                    params: HashMap::new(),
836                    request_body: None,
837                    response_root: None,
838                    interval_map: HashMap::new(),
839                    response: ResponseMapping::default(),
840                }),
841                ticker: None,
842                trades: None,
843                ohlc: None,
844            },
845        };
846        assert!(with_ob.has_order_book());
847
848        let without = VenueDescriptor {
849            error_envelope: None,
850            capabilities: CapabilitySet::default(),
851            ..with_ob
852        };
853        assert!(!without.has_order_book());
854    }
855
856    #[test]
857    fn test_has_ticker() {
858        let with_ticker = VenueDescriptor {
859            id: "x".to_string(),
860            name: "X".to_string(),
861            base_url: "https://x.com".to_string(),
862            timeout_secs: None,
863            rate_limit_per_sec: None,
864            symbol: SymbolConfig {
865                template: "{base}{quote}".to_string(),
866                default_quote: "USDT".to_string(),
867                case: SymbolCase::Upper,
868            },
869            headers: HashMap::new(),
870            error_envelope: None,
871            capabilities: CapabilitySet {
872                assets: None,
873                order_book: None,
874                ticker: Some(EndpointDescriptor {
875                    method: HttpMethod::GET,
876                    path: "/ticker".to_string(),
877                    params: HashMap::new(),
878                    request_body: None,
879                    response_root: None,
880                    interval_map: HashMap::new(),
881                    response: ResponseMapping::default(),
882                }),
883                trades: None,
884                ohlc: None,
885            },
886        };
887        assert!(with_ticker.has_ticker());
888
889        let without = VenueDescriptor {
890            error_envelope: None,
891            capabilities: CapabilitySet::default(),
892            ..with_ticker
893        };
894        assert!(!without.has_ticker());
895    }
896
897    #[test]
898    fn test_has_trades() {
899        let with_trades = VenueDescriptor {
900            id: "x".to_string(),
901            name: "X".to_string(),
902            base_url: "https://x.com".to_string(),
903            timeout_secs: None,
904            rate_limit_per_sec: None,
905            symbol: SymbolConfig {
906                template: "{base}{quote}".to_string(),
907                default_quote: "USDT".to_string(),
908                case: SymbolCase::Upper,
909            },
910            headers: HashMap::new(),
911            error_envelope: None,
912            capabilities: CapabilitySet {
913                assets: None,
914                order_book: None,
915                ticker: None,
916                trades: Some(EndpointDescriptor {
917                    method: HttpMethod::GET,
918                    path: "/trades".to_string(),
919                    params: HashMap::new(),
920                    request_body: None,
921                    response_root: None,
922                    interval_map: HashMap::new(),
923                    response: ResponseMapping::default(),
924                }),
925                ohlc: None,
926            },
927        };
928        assert!(with_trades.has_trades());
929
930        let without = VenueDescriptor {
931            error_envelope: None,
932            capabilities: CapabilitySet::default(),
933            ..with_trades
934        };
935        assert!(!without.has_trades());
936    }
937
938    #[test]
939    fn test_has_ohlc() {
940        let with_ohlc = VenueDescriptor {
941            id: "x".to_string(),
942            name: "X".to_string(),
943            base_url: "https://x.com".to_string(),
944            timeout_secs: None,
945            rate_limit_per_sec: None,
946            symbol: SymbolConfig {
947                template: "{base}{quote}".to_string(),
948                default_quote: "USDT".to_string(),
949                case: SymbolCase::Upper,
950            },
951            headers: HashMap::new(),
952            error_envelope: None,
953            capabilities: CapabilitySet {
954                assets: None,
955                order_book: None,
956                ticker: None,
957                trades: None,
958                ohlc: Some(EndpointDescriptor {
959                    method: HttpMethod::GET,
960                    path: "/klines".to_string(),
961                    params: HashMap::new(),
962                    request_body: None,
963                    response_root: None,
964                    interval_map: HashMap::new(),
965                    response: ResponseMapping::default(),
966                }),
967            },
968        };
969        assert!(with_ohlc.has_ohlc());
970        assert!(with_ohlc.capability_names().contains(&"ohlc"));
971
972        let without = VenueDescriptor {
973            error_envelope: None,
974            capabilities: CapabilitySet::default(),
975            ..with_ohlc
976        };
977        assert!(!without.has_ohlc());
978        assert!(!without.capability_names().contains(&"ohlc"));
979    }
980
981    #[test]
982    fn test_response_mapping_default() {
983        let m = ResponseMapping::default();
984        assert!(m.asks_key.is_none());
985        assert!(m.bids_key.is_none());
986        assert!(m.level_format.is_none());
987        assert!(m.side.is_none());
988    }
989
990    #[test]
991    fn test_capability_set_default() {
992        let c = CapabilitySet::default();
993        assert!(c.order_book.is_none());
994        assert!(c.ticker.is_none());
995        assert!(c.trades.is_none());
996        assert!(c.ohlc.is_none());
997    }
998
999    #[test]
1000    fn test_deserialize_ohlc_capability() {
1001        let yaml = r#"
1002id: ohlc_venue
1003name: OHLC Venue Test
1004base_url: https://api.example.com
1005
1006symbol:
1007  template: "{base}{quote}"
1008  default_quote: USDT
1009
1010
1011capabilities:
1012  order_book:
1013    path: /depth
1014    params:
1015      symbol: "{pair}"
1016    response:
1017      asks_key: asks
1018      bids_key: bids
1019      level_format: positional
1020  ohlc:
1021    path: /api/v3/klines
1022    params:
1023      symbol: "{pair}"
1024      interval: "{interval}"
1025      limit: "{limit}"
1026    response:
1027      ohlc_format: array_of_arrays
1028      ohlc_fields: [open_time, open, high, low, close, volume, close_time]
1029"#;
1030        let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
1031        assert!(desc.has_ohlc());
1032        assert!(desc.capability_names().contains(&"ohlc"));
1033        let ohlc = desc.capabilities.ohlc.as_ref().unwrap();
1034        assert_eq!(ohlc.path, "/api/v3/klines");
1035        assert_eq!(ohlc.params.get("interval"), Some(&"{interval}".to_string()));
1036        // interval_map should default to empty when omitted from YAML
1037        assert!(ohlc.interval_map.is_empty());
1038    }
1039
1040    #[test]
1041    fn test_deserialize_ohlc_with_interval_map() {
1042        let yaml = r#"
1043id: biconomy_test
1044name: Biconomy Test
1045base_url: https://api.biconomy.com
1046
1047symbol:
1048  template: "{base}_{quote}"
1049  default_quote: USDT
1050
1051
1052capabilities:
1053  ohlc:
1054    path: /api/v1/kline
1055    params:
1056      symbol: "{pair}"
1057      type: "{interval}"
1058      size: "{limit}"
1059    interval_map:
1060      1m: 1min
1061      5m: 5min
1062      15m: 15min
1063      30m: 30min
1064      1h: hour
1065      4h: hour
1066      1d: day
1067    response:
1068      ohlc_format: array_of_arrays
1069      ohlc_fields: [open_time, open, high, low, close, volume]
1070"#;
1071        let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
1072        assert!(desc.has_ohlc());
1073        let ohlc = desc.capabilities.ohlc.as_ref().unwrap();
1074        assert_eq!(ohlc.interval_map.len(), 7);
1075        assert_eq!(ohlc.interval_map.get("1m"), Some(&"1min".to_string()));
1076        assert_eq!(ohlc.interval_map.get("1h"), Some(&"hour".to_string()));
1077        assert_eq!(ohlc.interval_map.get("4h"), Some(&"hour".to_string()));
1078        assert_eq!(ohlc.interval_map.get("1d"), Some(&"day".to_string()));
1079        // Unmapped keys should not be present
1080        assert!(!ohlc.interval_map.contains_key("1w"));
1081    }
1082
1083    #[test]
1084    fn test_deserialize_biconomy_venue_yaml() {
1085        let yaml = std::fs::read_to_string(
1086            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("venues/biconomy.yaml"),
1087        )
1088        .expect("biconomy.yaml should exist");
1089        let desc: VenueDescriptor = serde_yaml::from_str(&yaml).unwrap();
1090        assert_eq!(desc.id, "biconomy");
1091        assert_eq!(desc.name, "Biconomy");
1092        assert!(desc.has_order_book());
1093        assert!(desc.has_ticker());
1094        assert!(desc.has_trades());
1095        assert!(desc.has_ohlc());
1096        let ohlc = desc.capabilities.ohlc.as_ref().unwrap();
1097        assert!(
1098            !ohlc.interval_map.is_empty(),
1099            "biconomy should have interval_map"
1100        );
1101        assert_eq!(ohlc.interval_map.get("1h"), Some(&"hour".to_string()));
1102    }
1103
1104    #[test]
1105    fn test_endpoint_descriptor_interval_map_defaults_empty() {
1106        let ep = EndpointDescriptor {
1107            method: HttpMethod::GET,
1108            path: "/test".to_string(),
1109            params: HashMap::new(),
1110            request_body: None,
1111            response_root: None,
1112            interval_map: HashMap::new(),
1113            response: ResponseMapping::default(),
1114        };
1115        assert!(ep.interval_map.is_empty());
1116    }
1117
1118    #[test]
1119    fn test_deserialize_full_descriptor_yaml() {
1120        let yaml = r#"
1121id: test_venue
1122name: Test Venue Full
1123base_url: https://api.test.com
1124timeout_secs: 30
1125rate_limit_per_sec: 5
1126
1127symbol:
1128  template: "{base}-{quote}"
1129  default_quote: USD
1130  case: upper
1131
1132headers:
1133  X-API-KEY: "placeholder"
1134
1135
1136capabilities:
1137  order_book:
1138    path: /book
1139    params:
1140      pair: "{pair}"
1141    response:
1142      asks_key: asks
1143      bids_key: bids
1144  ticker:
1145    path: /ticker
1146    response:
1147      last_price: last
1148  trades:
1149    path: /trades
1150    response:
1151      price: p
1152      quantity: q
1153"#;
1154        let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
1155        assert_eq!(desc.id, "test_venue");
1156        assert_eq!(desc.name, "Test Venue Full");
1157        assert_eq!(desc.base_url, "https://api.test.com");
1158        assert_eq!(desc.timeout_secs, Some(30));
1159        assert_eq!(desc.rate_limit_per_sec, Some(5));
1160        assert_eq!(desc.symbol.template, "{base}-{quote}");
1161        assert_eq!(desc.symbol.default_quote, "USD");
1162        assert_eq!(
1163            desc.headers.get("X-API-KEY"),
1164            Some(&"placeholder".to_string())
1165        );
1166        assert!(desc.has_order_book());
1167        assert!(desc.has_ticker());
1168        assert!(desc.has_trades());
1169        assert_eq!(
1170            desc.capability_names(),
1171            vec!["order_book", "ticker", "trades"]
1172        );
1173    }
1174
1175    // #69: each built-in envelope must flag the venue's real error reply and
1176    // pass its real success reply. The replies were recorded on 2026-10-13
1177    // with an unknown symbol (error) and BTC (success).
1178    #[test]
1179    fn test_builtin_error_envelopes_match_recorded_replies() {
1180        let cases: [(&str, &str, &str, &str); 5] = [
1181            (
1182                include_str!("../../venues/bybit.yaml"),
1183                r#"{"retCode":10001,"retMsg":"Not supported symbols","result":{}}"#,
1184                r#"{"retCode":0,"retMsg":"OK","result":{"a":[],"b":[]}}"#,
1185                "Not supported symbols (code 10001)",
1186            ),
1187            (
1188                include_str!("../../venues/okx.yaml"),
1189                r#"{"code":"51001","msg":"Instrument ID does not exist."}"#,
1190                r#"{"code":"0","msg":"","data":[]}"#,
1191                "Instrument ID does not exist. (code 51001)",
1192            ),
1193            (
1194                include_str!("../../venues/htx.yaml"),
1195                r#"{"status":"error","err-code":"invalid-parameter","err-msg":"invalid symbol"}"#,
1196                r#"{"ch":"market.btcusdt.depth.step0","status":"ok","tick":{}}"#,
1197                "invalid symbol (code error)",
1198            ),
1199            (
1200                include_str!("../../venues/kraken.yaml"),
1201                r#"{"error":["EQuery:Unknown asset pair"]}"#,
1202                r#"{"error":[],"result":{"XXBTZUSD":{}}}"#,
1203                "EQuery:Unknown asset pair",
1204            ),
1205            (
1206                include_str!("../../venues/biconomy.yaml"),
1207                r#"{"code":1,"message":"Illegal parameter","result":null}"#,
1208                r#"{"asks":[["83528.01","0.18615"]],"bids":[["83528","0.17478"]]}"#,
1209                "Illegal parameter (code 1)",
1210            ),
1211        ];
1212        for (yaml, error_reply, ok_reply, expected) in cases {
1213            let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
1214            let env = desc.error_envelope.as_ref().expect("envelope");
1215            let error: serde_json::Value = serde_json::from_str(error_reply).unwrap();
1216            let ok: serde_json::Value = serde_json::from_str(ok_reply).unwrap();
1217            assert_eq!(
1218                env.error_message(&error).as_deref(),
1219                Some(expected),
1220                "{}",
1221                desc.id
1222            );
1223            assert_eq!(env.error_message(&ok), None, "{}", desc.id);
1224        }
1225    }
1226
1227    #[test]
1228    fn test_error_envelope_code_without_message() {
1229        let env = ErrorEnvelope {
1230            code_key: Some("code".into()),
1231            ok_codes: vec!["0".into()],
1232            message_key: "msg".into(),
1233        };
1234        let reply = serde_json::json!({"code": 7});
1235        assert_eq!(env.error_message(&reply).as_deref(), Some("error code 7"));
1236    }
1237}