1use serde::Deserialize;
8use std::collections::HashMap;
9
10#[derive(Debug, Clone, Deserialize)]
12pub struct SymbolConfig {
13 pub template: String,
16
17 pub default_quote: String,
19
20 #[serde(default)]
22 pub case: SymbolCase,
23}
24
25#[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#[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#[derive(Debug, Clone, Deserialize)]
47pub struct EndpointDescriptor {
48 #[serde(default)]
50 pub method: HttpMethod,
51
52 pub path: String,
54
55 #[serde(default)]
58 pub params: HashMap<String, String>,
59
60 pub request_body: Option<serde_json::Value>,
62
63 pub response_root: Option<String>,
71
72 #[serde(default)]
76 pub interval_map: HashMap<String, String>,
77
78 #[serde(default)]
82 pub max_limit: Option<u32>,
83
84 pub response: ResponseMapping,
86}
87
88#[derive(Debug, Clone, Deserialize, Default)]
93pub struct ResponseMapping {
94 pub asks_key: Option<String>,
97 pub bids_key: Option<String>,
99 pub level_format: Option<String>,
102 pub level_price_field: Option<String>,
104 pub level_size_field: Option<String>,
106
107 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 pub price_change_pct_24h: Option<String>,
115 pub best_bid: Option<String>,
116 pub best_ask: Option<String>,
117
118 pub items_key: Option<String>,
121
122 pub nested_items_key: Option<String>,
125
126 pub filter: Option<FilterConfig>,
128
129 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 pub ohlc_format: Option<String>,
142 pub ohlc_fields: Option<Vec<String>>,
145 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 pub ohlc_volume: Option<String>,
154 pub close_time: Option<String>,
155
156 pub asset: Option<String>,
159 pub networks_key: Option<String>,
161 pub network: Option<String>,
163 pub contract: Option<String>,
166}
167
168#[derive(Debug, Clone, Deserialize)]
170pub struct SideMapping {
171 pub field: String,
173 pub mapping: HashMap<String, String>,
175}
176
177#[derive(Debug, Clone, Deserialize)]
179pub struct FilterConfig {
180 pub field: String,
182 pub value: String,
184}
185
186#[derive(Debug, Clone, Deserialize, Default)]
189pub struct CapabilitySet {
190 pub order_book: Option<EndpointDescriptor>,
192 pub ticker: Option<EndpointDescriptor>,
194 pub trades: Option<EndpointDescriptor>,
196 pub ohlc: Option<EndpointDescriptor>,
198 pub assets: Option<EndpointDescriptor>,
201}
202
203#[derive(Debug, Clone, Deserialize)]
209pub struct VenueDescriptor {
210 pub id: String,
212 pub name: String,
214 pub base_url: String,
216 pub timeout_secs: Option<u64>,
218 pub rate_limit_per_sec: Option<u32>,
220 pub symbol: SymbolConfig,
222 #[serde(default)]
224 pub headers: HashMap<String, String>,
225 #[serde(default)]
227 pub capabilities: CapabilitySet,
228 #[serde(default)]
230 pub error_envelope: Option<ErrorEnvelope>,
231}
232
233#[derive(Debug, Clone, Deserialize)]
239pub struct ErrorEnvelope {
240 #[serde(default)]
242 pub code_key: Option<String>,
243 #[serde(default)]
245 pub ok_codes: Vec<String>,
246 pub message_key: String,
248}
249
250impl ErrorEnvelope {
251 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 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 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 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 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 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 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 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 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 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 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 #[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}