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