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 best_bid: Option<String>,
108 pub best_ask: Option<String>,
109
110 pub items_key: Option<String>,
113
114 pub filter: Option<FilterConfig>,
116
117 pub price: Option<String>,
119 pub quantity: Option<String>,
120 pub quote_quantity: Option<String>,
121 pub timestamp_ms: Option<String>,
122 pub id: Option<String>,
123 pub side: Option<SideMapping>,
124
125 pub ohlc_format: Option<String>,
130 pub ohlc_fields: Option<Vec<String>>,
133 pub open_time: Option<String>,
136 pub open: Option<String>,
137 pub high: Option<String>,
138 pub low: Option<String>,
139 pub close: Option<String>,
140 pub ohlc_volume: Option<String>,
142 pub close_time: Option<String>,
143}
144
145#[derive(Debug, Clone, Deserialize)]
147pub struct SideMapping {
148 pub field: String,
150 pub mapping: HashMap<String, String>,
152}
153
154#[derive(Debug, Clone, Deserialize)]
156pub struct FilterConfig {
157 pub field: String,
159 pub value: String,
161}
162
163#[derive(Debug, Clone, Deserialize, Default)]
166pub struct CapabilitySet {
167 pub order_book: Option<EndpointDescriptor>,
169 pub ticker: Option<EndpointDescriptor>,
171 pub trades: Option<EndpointDescriptor>,
173 pub ohlc: Option<EndpointDescriptor>,
175}
176
177#[derive(Debug, Clone, Deserialize)]
183pub struct VenueDescriptor {
184 pub id: String,
186 pub name: String,
188 pub base_url: String,
190 pub timeout_secs: Option<u64>,
192 pub rate_limit_per_sec: Option<u32>,
194 pub symbol: SymbolConfig,
196 #[serde(default)]
198 pub headers: HashMap<String, String>,
199 #[serde(default)]
201 pub capabilities: CapabilitySet,
202}
203
204impl VenueDescriptor {
205 pub fn format_pair(&self, base: &str, quote: Option<&str>) -> String {
209 let q = quote.unwrap_or(&self.symbol.default_quote);
210 let raw = self
211 .symbol
212 .template
213 .replace("{base}", base)
214 .replace("{quote}", q);
215 match self.symbol.case {
216 SymbolCase::Upper => raw.to_uppercase(),
217 SymbolCase::Lower => raw.to_lowercase(),
218 }
219 }
220
221 pub fn has_order_book(&self) -> bool {
223 self.capabilities.order_book.is_some()
224 }
225 pub fn has_ticker(&self) -> bool {
226 self.capabilities.ticker.is_some()
227 }
228 pub fn has_trades(&self) -> bool {
229 self.capabilities.trades.is_some()
230 }
231 pub fn has_ohlc(&self) -> bool {
232 self.capabilities.ohlc.is_some()
233 }
234
235 pub fn capability_names(&self) -> Vec<&'static str> {
237 let mut caps = Vec::new();
238 if self.has_order_book() {
239 caps.push("order_book");
240 }
241 if self.has_ticker() {
242 caps.push("ticker");
243 }
244 if self.has_trades() {
245 caps.push("trades");
246 }
247 if self.has_ohlc() {
248 caps.push("ohlc");
249 }
250 caps
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 #[test]
259 fn test_symbol_case_default_is_upper() {
260 let case = SymbolCase::default();
261 assert_eq!(case, SymbolCase::Upper);
262 }
263
264 #[test]
265 fn test_http_method_default_is_get() {
266 let method = HttpMethod::default();
267 assert_eq!(method, HttpMethod::GET);
268 }
269
270 #[test]
271 fn test_format_pair_upper() {
272 let desc = VenueDescriptor {
273 id: "test".to_string(),
274 name: "Test".to_string(),
275 base_url: "https://example.com".to_string(),
276 timeout_secs: None,
277 rate_limit_per_sec: None,
278 symbol: SymbolConfig {
279 template: "{base}{quote}".to_string(),
280 default_quote: "USDT".to_string(),
281 case: SymbolCase::Upper,
282 },
283 headers: HashMap::new(),
284 capabilities: CapabilitySet::default(),
285 };
286 assert_eq!(desc.format_pair("BTC", None), "BTCUSDT");
287 assert_eq!(desc.format_pair("btc", None), "BTCUSDT");
288 assert_eq!(desc.format_pair("ETH", Some("USD")), "ETHUSD");
289 }
290
291 #[test]
292 fn test_format_pair_lower() {
293 let desc = VenueDescriptor {
294 id: "htx".to_string(),
295 name: "HTX".to_string(),
296 base_url: "https://api.huobi.pro".to_string(),
297 timeout_secs: None,
298 rate_limit_per_sec: None,
299 symbol: SymbolConfig {
300 template: "{base}{quote}".to_string(),
301 default_quote: "USDT".to_string(),
302 case: SymbolCase::Lower,
303 },
304 headers: HashMap::new(),
305 capabilities: CapabilitySet::default(),
306 };
307 assert_eq!(desc.format_pair("BTC", None), "btcusdt");
308 }
309
310 #[test]
311 fn test_format_pair_underscore() {
312 let desc = VenueDescriptor {
313 id: "biconomy".to_string(),
314 name: "Biconomy".to_string(),
315 base_url: "https://api.biconomy.com".to_string(),
316 timeout_secs: None,
317 rate_limit_per_sec: None,
318 symbol: SymbolConfig {
319 template: "{base}_{quote}".to_string(),
320 default_quote: "USDT".to_string(),
321 case: SymbolCase::Upper,
322 },
323 headers: HashMap::new(),
324 capabilities: CapabilitySet::default(),
325 };
326 assert_eq!(desc.format_pair("PUSD", None), "PUSD_USDT");
327 }
328
329 #[test]
330 fn test_format_pair_dash() {
331 let desc = VenueDescriptor {
332 id: "okx".to_string(),
333 name: "OKX".to_string(),
334 base_url: "https://www.okx.com".to_string(),
335 timeout_secs: None,
336 rate_limit_per_sec: None,
337 symbol: SymbolConfig {
338 template: "{base}-{quote}".to_string(),
339 default_quote: "USDT".to_string(),
340 case: SymbolCase::Upper,
341 },
342 headers: HashMap::new(),
343 capabilities: CapabilitySet::default(),
344 };
345 assert_eq!(desc.format_pair("BTC", None), "BTC-USDT");
346 }
347
348 #[test]
349 fn test_capability_names_all() {
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 capabilities: CapabilitySet {
363 order_book: Some(EndpointDescriptor {
364 method: HttpMethod::GET,
365 path: "/depth".to_string(),
366 params: HashMap::new(),
367 request_body: None,
368 response_root: None,
369 interval_map: HashMap::new(),
370 response: ResponseMapping::default(),
371 }),
372 ticker: Some(EndpointDescriptor {
373 method: HttpMethod::GET,
374 path: "/ticker".to_string(),
375 params: HashMap::new(),
376 request_body: None,
377 response_root: None,
378 interval_map: HashMap::new(),
379 response: ResponseMapping::default(),
380 }),
381 trades: Some(EndpointDescriptor {
382 method: HttpMethod::GET,
383 path: "/trades".to_string(),
384 params: HashMap::new(),
385 request_body: None,
386 response_root: None,
387 interval_map: HashMap::new(),
388 response: ResponseMapping::default(),
389 }),
390 ohlc: None,
391 },
392 };
393 let caps = desc.capability_names();
394 assert_eq!(caps, vec!["order_book", "ticker", "trades"]);
395 }
396
397 #[test]
398 fn test_capability_names_partial() {
399 let desc = VenueDescriptor {
400 id: "test".to_string(),
401 name: "Test".to_string(),
402 base_url: "https://example.com".to_string(),
403 timeout_secs: None,
404 rate_limit_per_sec: None,
405 symbol: SymbolConfig {
406 template: "{base}{quote}".to_string(),
407 default_quote: "USDT".to_string(),
408 case: SymbolCase::Upper,
409 },
410 headers: HashMap::new(),
411 capabilities: CapabilitySet {
412 order_book: Some(EndpointDescriptor {
413 method: HttpMethod::GET,
414 path: "/depth".to_string(),
415 params: HashMap::new(),
416 request_body: None,
417 response_root: None,
418 interval_map: HashMap::new(),
419 response: ResponseMapping::default(),
420 }),
421 ticker: None,
422 trades: None,
423 ohlc: None,
424 },
425 };
426 assert_eq!(desc.capability_names(), vec!["order_book"]);
427 }
428
429 #[test]
430 fn test_deserialize_binance_yaml() {
431 let yaml = r#"
432id: binance
433name: Binance Spot
434base_url: https://api.binance.com
435timeout_secs: 15
436rate_limit_per_sec: 10
437
438symbol:
439 template: "{base}{quote}"
440 default_quote: USDT
441
442capabilities:
443 order_book:
444 path: /api/v3/depth
445 params:
446 symbol: "{pair}"
447 limit: "100"
448 response:
449 asks_key: asks
450 bids_key: bids
451 level_format: positional
452
453 ticker:
454 path: /api/v3/ticker/24hr
455 params:
456 symbol: "{pair}"
457 response:
458 last_price: lastPrice
459 high_24h: highPrice
460 low_24h: lowPrice
461 volume_24h: volume
462 quote_volume_24h: quoteVolume
463 best_bid: bidPrice
464 best_ask: askPrice
465
466 trades:
467 path: /api/v3/trades
468 params:
469 symbol: "{pair}"
470 limit: "{limit}"
471 response:
472 price: price
473 quantity: qty
474 quote_quantity: quoteQty
475 timestamp_ms: time
476 id: id
477 side:
478 field: isBuyerMaker
479 mapping:
480 "true": sell
481 "false": buy
482"#;
483 let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
484 assert_eq!(desc.id, "binance");
485 assert_eq!(desc.name, "Binance Spot");
486 assert_eq!(desc.base_url, "https://api.binance.com");
487 assert_eq!(desc.timeout_secs, Some(15));
488 assert_eq!(desc.symbol.template, "{base}{quote}");
489 assert_eq!(desc.symbol.default_quote, "USDT");
490 assert_eq!(desc.symbol.case, SymbolCase::Upper);
491
492 let ob = desc.capabilities.order_book.as_ref().unwrap();
494 assert_eq!(ob.path, "/api/v3/depth");
495 assert_eq!(ob.params.get("symbol"), Some(&"{pair}".to_string()));
496 assert_eq!(ob.response.asks_key, Some("asks".to_string()));
497 assert_eq!(ob.response.level_format, Some("positional".to_string()));
498
499 let ticker = desc.capabilities.ticker.as_ref().unwrap();
501 assert_eq!(ticker.response.last_price, Some("lastPrice".to_string()));
502 assert_eq!(ticker.response.volume_24h, Some("volume".to_string()));
503
504 let trades = desc.capabilities.trades.as_ref().unwrap();
506 assert_eq!(trades.response.price, Some("price".to_string()));
507 let side = trades.response.side.as_ref().unwrap();
508 assert_eq!(side.field, "isBuyerMaker");
509 assert_eq!(side.mapping.get("true"), Some(&"sell".to_string()));
510 }
511
512 #[test]
513 fn test_deserialize_htx_lowercase() {
514 let yaml = r#"
515id: htx
516name: HTX
517base_url: https://api.huobi.pro
518
519symbol:
520 template: "{base}{quote}"
521 default_quote: USDT
522 case: lower
523
524capabilities:
525 order_book:
526 path: /market/depth
527 params:
528 symbol: "{pair}"
529 type: step0
530 response_root: tick
531 response:
532 asks_key: asks
533 bids_key: bids
534 level_format: positional
535"#;
536 let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
537 assert_eq!(desc.symbol.case, SymbolCase::Lower);
538 assert_eq!(desc.format_pair("BTC", None), "btcusdt");
539 let ob = desc.capabilities.order_book.as_ref().unwrap();
540 assert_eq!(ob.response_root, Some("tick".to_string()));
541 }
542
543 #[test]
544 fn test_deserialize_post_method() {
545 let yaml = r#"
546id: crypto_com
547name: Crypto.com
548base_url: https://api.crypto.com/exchange/v1
549
550symbol:
551 template: "{base}_{quote}"
552 default_quote: USDT
553
554capabilities:
555 order_book:
556 method: POST
557 path: /public/get-book
558 request_body:
559 method: "public/get-book"
560 params:
561 instrument_name: "{pair}"
562 depth: "100"
563 response_root: "result.data.0"
564 response:
565 asks_key: asks
566 bids_key: bids
567 level_format: positional
568"#;
569 let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
570 let ob = desc.capabilities.order_book.as_ref().unwrap();
571 assert_eq!(ob.method, HttpMethod::POST);
572 assert!(ob.request_body.is_some());
573 assert_eq!(ob.response_root, Some("result.data.0".to_string()));
574 }
575
576 #[test]
577 fn test_deserialize_object_level_format() {
578 let yaml = r#"
579id: coinbase
580name: Coinbase
581base_url: https://api.coinbase.com
582
583symbol:
584 template: "{base}-{quote}"
585 default_quote: USD
586
587capabilities:
588 order_book:
589 path: /api/v3/brokerage/market/product_book
590 params:
591 product_id: "{pair}"
592 limit: "100"
593 response_root: pricebook
594 response:
595 asks_key: asks
596 bids_key: bids
597 level_format: object
598 level_price_field: price
599 level_size_field: size
600"#;
601 let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
602 let ob = desc.capabilities.order_book.as_ref().unwrap();
603 assert_eq!(ob.response.level_format, Some("object".to_string()));
604 assert_eq!(ob.response.level_price_field, Some("price".to_string()));
605 assert_eq!(ob.response.level_size_field, Some("size".to_string()));
606 assert_eq!(ob.response_root, Some("pricebook".to_string()));
607 }
608
609 #[test]
610 fn test_venue_descriptor_format_pair_upper() {
611 let desc = VenueDescriptor {
612 id: "test".to_string(),
613 name: "Test".to_string(),
614 base_url: "https://example.com".to_string(),
615 timeout_secs: None,
616 rate_limit_per_sec: None,
617 symbol: SymbolConfig {
618 template: "{base}{quote}".to_string(),
619 default_quote: "USDT".to_string(),
620 case: SymbolCase::Upper,
621 },
622 headers: HashMap::new(),
623 capabilities: CapabilitySet::default(),
624 };
625 assert_eq!(desc.format_pair("btc", None), "BTCUSDT");
626 assert_eq!(desc.format_pair("ETH", Some("USD")), "ETHUSD");
627 }
628
629 #[test]
630 fn test_venue_descriptor_format_pair_lower() {
631 let desc = VenueDescriptor {
632 id: "test".to_string(),
633 name: "Test".to_string(),
634 base_url: "https://example.com".to_string(),
635 timeout_secs: None,
636 rate_limit_per_sec: None,
637 symbol: SymbolConfig {
638 template: "{base}{quote}".to_string(),
639 default_quote: "USDT".to_string(),
640 case: SymbolCase::Lower,
641 },
642 headers: HashMap::new(),
643 capabilities: CapabilitySet::default(),
644 };
645 assert_eq!(desc.format_pair("BTC", None), "btcusdt");
646 assert_eq!(desc.format_pair("ETH", Some("usd")), "ethusd");
647 }
648
649 #[test]
650 fn test_venue_descriptor_format_pair_with_separator() {
651 let desc = VenueDescriptor {
652 id: "test".to_string(),
653 name: "Test".to_string(),
654 base_url: "https://example.com".to_string(),
655 timeout_secs: None,
656 rate_limit_per_sec: None,
657 symbol: SymbolConfig {
658 template: "{base}_{quote}".to_string(),
659 default_quote: "USDT".to_string(),
660 case: SymbolCase::Upper,
661 },
662 headers: HashMap::new(),
663 capabilities: CapabilitySet::default(),
664 };
665 assert_eq!(desc.format_pair("BTC", None), "BTC_USDT");
666 assert_eq!(desc.format_pair("PUSD", Some("USD")), "PUSD_USD");
667 }
668
669 #[test]
670 fn test_capability_names() {
671 let desc = VenueDescriptor {
673 id: "empty".to_string(),
674 name: "Empty".to_string(),
675 base_url: "https://example.com".to_string(),
676 timeout_secs: None,
677 rate_limit_per_sec: None,
678 symbol: SymbolConfig {
679 template: "{base}{quote}".to_string(),
680 default_quote: "USDT".to_string(),
681 case: SymbolCase::Upper,
682 },
683 headers: HashMap::new(),
684 capabilities: CapabilitySet::default(),
685 };
686 assert!(desc.capability_names().is_empty());
687
688 let desc_trades = VenueDescriptor {
690 id: "trades_only".to_string(),
691 name: "Trades Only".to_string(),
692 base_url: "https://example.com".to_string(),
693 timeout_secs: None,
694 rate_limit_per_sec: None,
695 symbol: SymbolConfig {
696 template: "{base}{quote}".to_string(),
697 default_quote: "USDT".to_string(),
698 case: SymbolCase::Upper,
699 },
700 headers: HashMap::new(),
701 capabilities: CapabilitySet {
702 order_book: None,
703 ticker: None,
704 trades: Some(EndpointDescriptor {
705 method: HttpMethod::GET,
706 path: "/trades".to_string(),
707 params: HashMap::new(),
708 request_body: None,
709 response_root: None,
710 interval_map: HashMap::new(),
711 response: ResponseMapping::default(),
712 }),
713 ohlc: None,
714 },
715 };
716 assert_eq!(desc_trades.capability_names(), vec!["trades"]);
717 }
718
719 #[test]
720 fn test_has_order_book() {
721 let with_ob = VenueDescriptor {
722 id: "x".to_string(),
723 name: "X".to_string(),
724 base_url: "https://x.com".to_string(),
725 timeout_secs: None,
726 rate_limit_per_sec: None,
727 symbol: SymbolConfig {
728 template: "{base}{quote}".to_string(),
729 default_quote: "USDT".to_string(),
730 case: SymbolCase::Upper,
731 },
732 headers: HashMap::new(),
733 capabilities: CapabilitySet {
734 order_book: Some(EndpointDescriptor {
735 method: HttpMethod::GET,
736 path: "/depth".to_string(),
737 params: HashMap::new(),
738 request_body: None,
739 response_root: None,
740 interval_map: HashMap::new(),
741 response: ResponseMapping::default(),
742 }),
743 ticker: None,
744 trades: None,
745 ohlc: None,
746 },
747 };
748 assert!(with_ob.has_order_book());
749
750 let without = VenueDescriptor {
751 capabilities: CapabilitySet::default(),
752 ..with_ob
753 };
754 assert!(!without.has_order_book());
755 }
756
757 #[test]
758 fn test_has_ticker() {
759 let with_ticker = VenueDescriptor {
760 id: "x".to_string(),
761 name: "X".to_string(),
762 base_url: "https://x.com".to_string(),
763 timeout_secs: None,
764 rate_limit_per_sec: None,
765 symbol: SymbolConfig {
766 template: "{base}{quote}".to_string(),
767 default_quote: "USDT".to_string(),
768 case: SymbolCase::Upper,
769 },
770 headers: HashMap::new(),
771 capabilities: CapabilitySet {
772 order_book: None,
773 ticker: Some(EndpointDescriptor {
774 method: HttpMethod::GET,
775 path: "/ticker".to_string(),
776 params: HashMap::new(),
777 request_body: None,
778 response_root: None,
779 interval_map: HashMap::new(),
780 response: ResponseMapping::default(),
781 }),
782 trades: None,
783 ohlc: None,
784 },
785 };
786 assert!(with_ticker.has_ticker());
787
788 let without = VenueDescriptor {
789 capabilities: CapabilitySet::default(),
790 ..with_ticker
791 };
792 assert!(!without.has_ticker());
793 }
794
795 #[test]
796 fn test_has_trades() {
797 let with_trades = VenueDescriptor {
798 id: "x".to_string(),
799 name: "X".to_string(),
800 base_url: "https://x.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 capabilities: CapabilitySet {
810 order_book: None,
811 ticker: None,
812 trades: Some(EndpointDescriptor {
813 method: HttpMethod::GET,
814 path: "/trades".to_string(),
815 params: HashMap::new(),
816 request_body: None,
817 response_root: None,
818 interval_map: HashMap::new(),
819 response: ResponseMapping::default(),
820 }),
821 ohlc: None,
822 },
823 };
824 assert!(with_trades.has_trades());
825
826 let without = VenueDescriptor {
827 capabilities: CapabilitySet::default(),
828 ..with_trades
829 };
830 assert!(!without.has_trades());
831 }
832
833 #[test]
834 fn test_has_ohlc() {
835 let with_ohlc = VenueDescriptor {
836 id: "x".to_string(),
837 name: "X".to_string(),
838 base_url: "https://x.com".to_string(),
839 timeout_secs: None,
840 rate_limit_per_sec: None,
841 symbol: SymbolConfig {
842 template: "{base}{quote}".to_string(),
843 default_quote: "USDT".to_string(),
844 case: SymbolCase::Upper,
845 },
846 headers: HashMap::new(),
847 capabilities: CapabilitySet {
848 order_book: None,
849 ticker: None,
850 trades: None,
851 ohlc: Some(EndpointDescriptor {
852 method: HttpMethod::GET,
853 path: "/klines".to_string(),
854 params: HashMap::new(),
855 request_body: None,
856 response_root: None,
857 interval_map: HashMap::new(),
858 response: ResponseMapping::default(),
859 }),
860 },
861 };
862 assert!(with_ohlc.has_ohlc());
863 assert!(with_ohlc.capability_names().contains(&"ohlc"));
864
865 let without = VenueDescriptor {
866 capabilities: CapabilitySet::default(),
867 ..with_ohlc
868 };
869 assert!(!without.has_ohlc());
870 assert!(!without.capability_names().contains(&"ohlc"));
871 }
872
873 #[test]
874 fn test_response_mapping_default() {
875 let m = ResponseMapping::default();
876 assert!(m.asks_key.is_none());
877 assert!(m.bids_key.is_none());
878 assert!(m.level_format.is_none());
879 assert!(m.side.is_none());
880 }
881
882 #[test]
883 fn test_capability_set_default() {
884 let c = CapabilitySet::default();
885 assert!(c.order_book.is_none());
886 assert!(c.ticker.is_none());
887 assert!(c.trades.is_none());
888 assert!(c.ohlc.is_none());
889 }
890
891 #[test]
892 fn test_deserialize_ohlc_capability() {
893 let yaml = r#"
894id: ohlc_venue
895name: OHLC Venue Test
896base_url: https://api.example.com
897
898symbol:
899 template: "{base}{quote}"
900 default_quote: USDT
901
902capabilities:
903 order_book:
904 path: /depth
905 params:
906 symbol: "{pair}"
907 response:
908 asks_key: asks
909 bids_key: bids
910 level_format: positional
911 ohlc:
912 path: /api/v3/klines
913 params:
914 symbol: "{pair}"
915 interval: "{interval}"
916 limit: "{limit}"
917 response:
918 ohlc_format: array_of_arrays
919 ohlc_fields: [open_time, open, high, low, close, volume, close_time]
920"#;
921 let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
922 assert!(desc.has_ohlc());
923 assert!(desc.capability_names().contains(&"ohlc"));
924 let ohlc = desc.capabilities.ohlc.as_ref().unwrap();
925 assert_eq!(ohlc.path, "/api/v3/klines");
926 assert_eq!(ohlc.params.get("interval"), Some(&"{interval}".to_string()));
927 assert!(ohlc.interval_map.is_empty());
929 }
930
931 #[test]
932 fn test_deserialize_ohlc_with_interval_map() {
933 let yaml = r#"
934id: biconomy_test
935name: Biconomy Test
936base_url: https://api.biconomy.com
937
938symbol:
939 template: "{base}_{quote}"
940 default_quote: USDT
941
942capabilities:
943 ohlc:
944 path: /api/v1/kline
945 params:
946 symbol: "{pair}"
947 type: "{interval}"
948 size: "{limit}"
949 interval_map:
950 1m: 1min
951 5m: 5min
952 15m: 15min
953 30m: 30min
954 1h: hour
955 4h: hour
956 1d: day
957 response:
958 ohlc_format: array_of_arrays
959 ohlc_fields: [open_time, open, high, low, close, volume]
960"#;
961 let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
962 assert!(desc.has_ohlc());
963 let ohlc = desc.capabilities.ohlc.as_ref().unwrap();
964 assert_eq!(ohlc.interval_map.len(), 7);
965 assert_eq!(ohlc.interval_map.get("1m"), Some(&"1min".to_string()));
966 assert_eq!(ohlc.interval_map.get("1h"), Some(&"hour".to_string()));
967 assert_eq!(ohlc.interval_map.get("4h"), Some(&"hour".to_string()));
968 assert_eq!(ohlc.interval_map.get("1d"), Some(&"day".to_string()));
969 assert!(!ohlc.interval_map.contains_key("1w"));
971 }
972
973 #[test]
974 fn test_deserialize_biconomy_venue_yaml() {
975 let yaml =
976 std::fs::read_to_string("venues/biconomy.yaml").expect("biconomy.yaml should exist");
977 let desc: VenueDescriptor = serde_yaml::from_str(&yaml).unwrap();
978 assert_eq!(desc.id, "biconomy");
979 assert_eq!(desc.name, "Biconomy");
980 assert!(desc.has_order_book());
981 assert!(desc.has_ticker());
982 assert!(desc.has_trades());
983 assert!(desc.has_ohlc());
984 let ohlc = desc.capabilities.ohlc.as_ref().unwrap();
985 assert!(
986 !ohlc.interval_map.is_empty(),
987 "biconomy should have interval_map"
988 );
989 assert_eq!(ohlc.interval_map.get("1h"), Some(&"hour".to_string()));
990 }
991
992 #[test]
993 fn test_endpoint_descriptor_interval_map_defaults_empty() {
994 let ep = EndpointDescriptor {
995 method: HttpMethod::GET,
996 path: "/test".to_string(),
997 params: HashMap::new(),
998 request_body: None,
999 response_root: None,
1000 interval_map: HashMap::new(),
1001 response: ResponseMapping::default(),
1002 };
1003 assert!(ep.interval_map.is_empty());
1004 }
1005
1006 #[test]
1007 fn test_deserialize_full_descriptor_yaml() {
1008 let yaml = r#"
1009id: test_venue
1010name: Test Venue Full
1011base_url: https://api.test.com
1012timeout_secs: 30
1013rate_limit_per_sec: 5
1014
1015symbol:
1016 template: "{base}-{quote}"
1017 default_quote: USD
1018 case: upper
1019
1020headers:
1021 X-API-KEY: "placeholder"
1022
1023capabilities:
1024 order_book:
1025 path: /book
1026 params:
1027 pair: "{pair}"
1028 response:
1029 asks_key: asks
1030 bids_key: bids
1031 ticker:
1032 path: /ticker
1033 response:
1034 last_price: last
1035 trades:
1036 path: /trades
1037 response:
1038 price: p
1039 quantity: q
1040"#;
1041 let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
1042 assert_eq!(desc.id, "test_venue");
1043 assert_eq!(desc.name, "Test Venue Full");
1044 assert_eq!(desc.base_url, "https://api.test.com");
1045 assert_eq!(desc.timeout_secs, Some(30));
1046 assert_eq!(desc.rate_limit_per_sec, Some(5));
1047 assert_eq!(desc.symbol.template, "{base}-{quote}");
1048 assert_eq!(desc.symbol.default_quote, "USD");
1049 assert_eq!(
1050 desc.headers.get("X-API-KEY"),
1051 Some(&"placeholder".to_string())
1052 );
1053 assert!(desc.has_order_book());
1054 assert!(desc.has_ticker());
1055 assert!(desc.has_trades());
1056 assert_eq!(
1057 desc.capability_names(),
1058 vec!["order_book", "ticker", "trades"]
1059 );
1060 }
1061}