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 pub response: ResponseMapping,
80}
81
82#[derive(Debug, Clone, Deserialize, Default)]
87pub struct ResponseMapping {
88 pub asks_key: Option<String>,
91 pub bids_key: Option<String>,
93 pub level_format: Option<String>,
96 pub level_price_field: Option<String>,
98 pub level_size_field: Option<String>,
100
101 pub last_price: Option<String>,
103 pub high_24h: Option<String>,
104 pub low_24h: Option<String>,
105 pub volume_24h: Option<String>,
106 pub quote_volume_24h: Option<String>,
107 pub price_change_pct_24h: Option<String>,
109 pub best_bid: Option<String>,
110 pub best_ask: Option<String>,
111
112 pub items_key: Option<String>,
115
116 pub filter: Option<FilterConfig>,
118
119 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 pub ohlc_format: Option<String>,
132 pub ohlc_fields: Option<Vec<String>>,
135 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 pub ohlc_volume: Option<String>,
144 pub close_time: Option<String>,
145
146 pub asset: Option<String>,
149 pub networks_key: Option<String>,
151 pub network: Option<String>,
153 pub contract: Option<String>,
156}
157
158#[derive(Debug, Clone, Deserialize)]
160pub struct SideMapping {
161 pub field: String,
163 pub mapping: HashMap<String, String>,
165}
166
167#[derive(Debug, Clone, Deserialize)]
169pub struct FilterConfig {
170 pub field: String,
172 pub value: String,
174}
175
176#[derive(Debug, Clone, Deserialize, Default)]
179pub struct CapabilitySet {
180 pub order_book: Option<EndpointDescriptor>,
182 pub ticker: Option<EndpointDescriptor>,
184 pub trades: Option<EndpointDescriptor>,
186 pub ohlc: Option<EndpointDescriptor>,
188 pub assets: Option<EndpointDescriptor>,
191}
192
193#[derive(Debug, Clone, Deserialize)]
199pub struct VenueDescriptor {
200 pub id: String,
202 pub name: String,
204 pub base_url: String,
206 pub timeout_secs: Option<u64>,
208 pub rate_limit_per_sec: Option<u32>,
210 pub symbol: SymbolConfig,
212 #[serde(default)]
214 pub headers: HashMap<String, String>,
215 #[serde(default)]
217 pub capabilities: CapabilitySet,
218 #[serde(default)]
220 pub error_envelope: Option<ErrorEnvelope>,
221}
222
223#[derive(Debug, Clone, Deserialize)]
229pub struct ErrorEnvelope {
230 #[serde(default)]
232 pub code_key: Option<String>,
233 #[serde(default)]
235 pub ok_codes: Vec<String>,
236 pub message_key: String,
238}
239
240impl ErrorEnvelope {
241 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 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 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 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 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 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 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 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 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 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 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 #[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}