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 pub response: ResponseMapping,
74}
75
76#[derive(Debug, Clone, Deserialize, Default)]
81pub struct ResponseMapping {
82 pub asks_key: Option<String>,
85 pub bids_key: Option<String>,
87 pub level_format: Option<String>,
90 pub level_price_field: Option<String>,
92 pub level_size_field: Option<String>,
94
95 pub last_price: Option<String>,
97 pub high_24h: Option<String>,
98 pub low_24h: Option<String>,
99 pub volume_24h: Option<String>,
100 pub quote_volume_24h: Option<String>,
101 pub best_bid: Option<String>,
102 pub best_ask: Option<String>,
103
104 pub items_key: Option<String>,
107
108 pub filter: Option<FilterConfig>,
110
111 pub price: Option<String>,
113 pub quantity: Option<String>,
114 pub quote_quantity: Option<String>,
115 pub timestamp_ms: Option<String>,
116 pub id: Option<String>,
117 pub side: Option<SideMapping>,
118}
119
120#[derive(Debug, Clone, Deserialize)]
122pub struct SideMapping {
123 pub field: String,
125 pub mapping: HashMap<String, String>,
127}
128
129#[derive(Debug, Clone, Deserialize)]
131pub struct FilterConfig {
132 pub field: String,
134 pub value: String,
136}
137
138#[derive(Debug, Clone, Deserialize, Default)]
141pub struct CapabilitySet {
142 pub order_book: Option<EndpointDescriptor>,
144 pub ticker: Option<EndpointDescriptor>,
146 pub trades: Option<EndpointDescriptor>,
148}
149
150#[derive(Debug, Clone, Deserialize)]
156pub struct VenueDescriptor {
157 pub id: String,
159 pub name: String,
161 pub base_url: String,
163 pub timeout_secs: Option<u64>,
165 pub rate_limit_per_sec: Option<u32>,
167 pub symbol: SymbolConfig,
169 #[serde(default)]
171 pub headers: HashMap<String, String>,
172 #[serde(default)]
174 pub capabilities: CapabilitySet,
175}
176
177impl VenueDescriptor {
178 pub fn format_pair(&self, base: &str, quote: Option<&str>) -> String {
182 let q = quote.unwrap_or(&self.symbol.default_quote);
183 let raw = self
184 .symbol
185 .template
186 .replace("{base}", base)
187 .replace("{quote}", q);
188 match self.symbol.case {
189 SymbolCase::Upper => raw.to_uppercase(),
190 SymbolCase::Lower => raw.to_lowercase(),
191 }
192 }
193
194 pub fn has_order_book(&self) -> bool {
196 self.capabilities.order_book.is_some()
197 }
198 pub fn has_ticker(&self) -> bool {
199 self.capabilities.ticker.is_some()
200 }
201 pub fn has_trades(&self) -> bool {
202 self.capabilities.trades.is_some()
203 }
204
205 pub fn capability_names(&self) -> Vec<&'static str> {
207 let mut caps = Vec::new();
208 if self.has_order_book() {
209 caps.push("order_book");
210 }
211 if self.has_ticker() {
212 caps.push("ticker");
213 }
214 if self.has_trades() {
215 caps.push("trades");
216 }
217 caps
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 #[test]
226 fn test_symbol_case_default_is_upper() {
227 let case = SymbolCase::default();
228 assert_eq!(case, SymbolCase::Upper);
229 }
230
231 #[test]
232 fn test_http_method_default_is_get() {
233 let method = HttpMethod::default();
234 assert_eq!(method, HttpMethod::GET);
235 }
236
237 #[test]
238 fn test_format_pair_upper() {
239 let desc = VenueDescriptor {
240 id: "test".to_string(),
241 name: "Test".to_string(),
242 base_url: "https://example.com".to_string(),
243 timeout_secs: None,
244 rate_limit_per_sec: None,
245 symbol: SymbolConfig {
246 template: "{base}{quote}".to_string(),
247 default_quote: "USDT".to_string(),
248 case: SymbolCase::Upper,
249 },
250 headers: HashMap::new(),
251 capabilities: CapabilitySet::default(),
252 };
253 assert_eq!(desc.format_pair("BTC", None), "BTCUSDT");
254 assert_eq!(desc.format_pair("btc", None), "BTCUSDT");
255 assert_eq!(desc.format_pair("ETH", Some("USD")), "ETHUSD");
256 }
257
258 #[test]
259 fn test_format_pair_lower() {
260 let desc = VenueDescriptor {
261 id: "htx".to_string(),
262 name: "HTX".to_string(),
263 base_url: "https://api.huobi.pro".to_string(),
264 timeout_secs: None,
265 rate_limit_per_sec: None,
266 symbol: SymbolConfig {
267 template: "{base}{quote}".to_string(),
268 default_quote: "USDT".to_string(),
269 case: SymbolCase::Lower,
270 },
271 headers: HashMap::new(),
272 capabilities: CapabilitySet::default(),
273 };
274 assert_eq!(desc.format_pair("BTC", None), "btcusdt");
275 }
276
277 #[test]
278 fn test_format_pair_underscore() {
279 let desc = VenueDescriptor {
280 id: "biconomy".to_string(),
281 name: "Biconomy".to_string(),
282 base_url: "https://api.biconomy.com".to_string(),
283 timeout_secs: None,
284 rate_limit_per_sec: None,
285 symbol: SymbolConfig {
286 template: "{base}_{quote}".to_string(),
287 default_quote: "USDT".to_string(),
288 case: SymbolCase::Upper,
289 },
290 headers: HashMap::new(),
291 capabilities: CapabilitySet::default(),
292 };
293 assert_eq!(desc.format_pair("PUSD", None), "PUSD_USDT");
294 }
295
296 #[test]
297 fn test_format_pair_dash() {
298 let desc = VenueDescriptor {
299 id: "okx".to_string(),
300 name: "OKX".to_string(),
301 base_url: "https://www.okx.com".to_string(),
302 timeout_secs: None,
303 rate_limit_per_sec: None,
304 symbol: SymbolConfig {
305 template: "{base}-{quote}".to_string(),
306 default_quote: "USDT".to_string(),
307 case: SymbolCase::Upper,
308 },
309 headers: HashMap::new(),
310 capabilities: CapabilitySet::default(),
311 };
312 assert_eq!(desc.format_pair("BTC", None), "BTC-USDT");
313 }
314
315 #[test]
316 fn test_capability_names_all() {
317 let desc = VenueDescriptor {
318 id: "test".to_string(),
319 name: "Test".to_string(),
320 base_url: "https://example.com".to_string(),
321 timeout_secs: None,
322 rate_limit_per_sec: None,
323 symbol: SymbolConfig {
324 template: "{base}{quote}".to_string(),
325 default_quote: "USDT".to_string(),
326 case: SymbolCase::Upper,
327 },
328 headers: HashMap::new(),
329 capabilities: CapabilitySet {
330 order_book: Some(EndpointDescriptor {
331 method: HttpMethod::GET,
332 path: "/depth".to_string(),
333 params: HashMap::new(),
334 request_body: None,
335 response_root: None,
336 response: ResponseMapping::default(),
337 }),
338 ticker: Some(EndpointDescriptor {
339 method: HttpMethod::GET,
340 path: "/ticker".to_string(),
341 params: HashMap::new(),
342 request_body: None,
343 response_root: None,
344 response: ResponseMapping::default(),
345 }),
346 trades: Some(EndpointDescriptor {
347 method: HttpMethod::GET,
348 path: "/trades".to_string(),
349 params: HashMap::new(),
350 request_body: None,
351 response_root: None,
352 response: ResponseMapping::default(),
353 }),
354 },
355 };
356 let caps = desc.capability_names();
357 assert_eq!(caps, vec!["order_book", "ticker", "trades"]);
358 }
359
360 #[test]
361 fn test_capability_names_partial() {
362 let desc = VenueDescriptor {
363 id: "test".to_string(),
364 name: "Test".to_string(),
365 base_url: "https://example.com".to_string(),
366 timeout_secs: None,
367 rate_limit_per_sec: None,
368 symbol: SymbolConfig {
369 template: "{base}{quote}".to_string(),
370 default_quote: "USDT".to_string(),
371 case: SymbolCase::Upper,
372 },
373 headers: HashMap::new(),
374 capabilities: CapabilitySet {
375 order_book: Some(EndpointDescriptor {
376 method: HttpMethod::GET,
377 path: "/depth".to_string(),
378 params: HashMap::new(),
379 request_body: None,
380 response_root: None,
381 response: ResponseMapping::default(),
382 }),
383 ticker: None,
384 trades: None,
385 },
386 };
387 assert_eq!(desc.capability_names(), vec!["order_book"]);
388 }
389
390 #[test]
391 fn test_deserialize_binance_yaml() {
392 let yaml = r#"
393id: binance
394name: Binance Spot
395base_url: https://api.binance.com
396timeout_secs: 15
397rate_limit_per_sec: 10
398
399symbol:
400 template: "{base}{quote}"
401 default_quote: USDT
402
403capabilities:
404 order_book:
405 path: /api/v3/depth
406 params:
407 symbol: "{pair}"
408 limit: "100"
409 response:
410 asks_key: asks
411 bids_key: bids
412 level_format: positional
413
414 ticker:
415 path: /api/v3/ticker/24hr
416 params:
417 symbol: "{pair}"
418 response:
419 last_price: lastPrice
420 high_24h: highPrice
421 low_24h: lowPrice
422 volume_24h: volume
423 quote_volume_24h: quoteVolume
424 best_bid: bidPrice
425 best_ask: askPrice
426
427 trades:
428 path: /api/v3/trades
429 params:
430 symbol: "{pair}"
431 limit: "{limit}"
432 response:
433 price: price
434 quantity: qty
435 quote_quantity: quoteQty
436 timestamp_ms: time
437 id: id
438 side:
439 field: isBuyerMaker
440 mapping:
441 "true": sell
442 "false": buy
443"#;
444 let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
445 assert_eq!(desc.id, "binance");
446 assert_eq!(desc.name, "Binance Spot");
447 assert_eq!(desc.base_url, "https://api.binance.com");
448 assert_eq!(desc.timeout_secs, Some(15));
449 assert_eq!(desc.symbol.template, "{base}{quote}");
450 assert_eq!(desc.symbol.default_quote, "USDT");
451 assert_eq!(desc.symbol.case, SymbolCase::Upper);
452
453 let ob = desc.capabilities.order_book.as_ref().unwrap();
455 assert_eq!(ob.path, "/api/v3/depth");
456 assert_eq!(ob.params.get("symbol"), Some(&"{pair}".to_string()));
457 assert_eq!(ob.response.asks_key, Some("asks".to_string()));
458 assert_eq!(ob.response.level_format, Some("positional".to_string()));
459
460 let ticker = desc.capabilities.ticker.as_ref().unwrap();
462 assert_eq!(ticker.response.last_price, Some("lastPrice".to_string()));
463 assert_eq!(ticker.response.volume_24h, Some("volume".to_string()));
464
465 let trades = desc.capabilities.trades.as_ref().unwrap();
467 assert_eq!(trades.response.price, Some("price".to_string()));
468 let side = trades.response.side.as_ref().unwrap();
469 assert_eq!(side.field, "isBuyerMaker");
470 assert_eq!(side.mapping.get("true"), Some(&"sell".to_string()));
471 }
472
473 #[test]
474 fn test_deserialize_htx_lowercase() {
475 let yaml = r#"
476id: htx
477name: HTX
478base_url: https://api.huobi.pro
479
480symbol:
481 template: "{base}{quote}"
482 default_quote: USDT
483 case: lower
484
485capabilities:
486 order_book:
487 path: /market/depth
488 params:
489 symbol: "{pair}"
490 type: step0
491 response_root: tick
492 response:
493 asks_key: asks
494 bids_key: bids
495 level_format: positional
496"#;
497 let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
498 assert_eq!(desc.symbol.case, SymbolCase::Lower);
499 assert_eq!(desc.format_pair("BTC", None), "btcusdt");
500 let ob = desc.capabilities.order_book.as_ref().unwrap();
501 assert_eq!(ob.response_root, Some("tick".to_string()));
502 }
503
504 #[test]
505 fn test_deserialize_post_method() {
506 let yaml = r#"
507id: crypto_com
508name: Crypto.com
509base_url: https://api.crypto.com/exchange/v1
510
511symbol:
512 template: "{base}_{quote}"
513 default_quote: USDT
514
515capabilities:
516 order_book:
517 method: POST
518 path: /public/get-book
519 request_body:
520 method: "public/get-book"
521 params:
522 instrument_name: "{pair}"
523 depth: "100"
524 response_root: "result.data.0"
525 response:
526 asks_key: asks
527 bids_key: bids
528 level_format: positional
529"#;
530 let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
531 let ob = desc.capabilities.order_book.as_ref().unwrap();
532 assert_eq!(ob.method, HttpMethod::POST);
533 assert!(ob.request_body.is_some());
534 assert_eq!(ob.response_root, Some("result.data.0".to_string()));
535 }
536
537 #[test]
538 fn test_deserialize_object_level_format() {
539 let yaml = r#"
540id: coinbase
541name: Coinbase
542base_url: https://api.coinbase.com
543
544symbol:
545 template: "{base}-{quote}"
546 default_quote: USD
547
548capabilities:
549 order_book:
550 path: /api/v3/brokerage/market/product_book
551 params:
552 product_id: "{pair}"
553 limit: "100"
554 response_root: pricebook
555 response:
556 asks_key: asks
557 bids_key: bids
558 level_format: object
559 level_price_field: price
560 level_size_field: size
561"#;
562 let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
563 let ob = desc.capabilities.order_book.as_ref().unwrap();
564 assert_eq!(ob.response.level_format, Some("object".to_string()));
565 assert_eq!(ob.response.level_price_field, Some("price".to_string()));
566 assert_eq!(ob.response.level_size_field, Some("size".to_string()));
567 assert_eq!(ob.response_root, Some("pricebook".to_string()));
568 }
569}