1use crate::error::{Result, ScopeError};
8use crate::market::descriptor::{EndpointDescriptor, HttpMethod, ResponseMapping, VenueDescriptor};
9use crate::market::orderbook::{
10 Candle, OhlcClient, OrderBook, OrderBookClient, OrderBookLevel, Ticker, TickerClient, Trade,
11 TradeHistoryClient, TradeSide,
12};
13use async_trait::async_trait;
14use reqwest::Client;
15use serde_json::Value;
16use std::time::Duration;
17
18#[derive(Debug, Clone)]
22pub struct ConfigurableExchangeClient {
23 descriptor: VenueDescriptor,
24 http: Client,
25}
26
27impl ConfigurableExchangeClient {
28 pub fn new(descriptor: VenueDescriptor) -> Self {
30 let timeout = descriptor.timeout_secs.unwrap_or(15);
31 let http = Client::builder()
32 .timeout(Duration::from_secs(timeout))
33 .build()
34 .expect("reqwest client build");
35 Self { descriptor, http }
36 }
37
38 pub fn descriptor(&self) -> &VenueDescriptor {
40 &self.descriptor
41 }
42
43 pub fn format_pair(&self, base: &str, quote: Option<&str>) -> String {
45 self.descriptor.format_pair(base, quote)
46 }
47
48 async fn fetch_endpoint(
54 &self,
55 endpoint: &EndpointDescriptor,
56 pair: &str,
57 limit: Option<u32>,
58 ) -> Result<Value> {
59 let url = format!(
60 "{}{}",
61 self.descriptor.base_url,
62 self.interpolate_path(&endpoint.path, pair)
63 );
64
65 let limit_str = limit.unwrap_or(100).to_string();
66
67 match endpoint.method {
68 HttpMethod::GET => {
69 let mut req = self.http.get(&url);
70 for (k, v) in &self.descriptor.headers {
72 req = req.header(k, v);
73 }
74 let params: Vec<(String, String)> = endpoint
76 .params
77 .iter()
78 .map(|(k, v)| (k.clone(), self.interpolate_value(v, pair, &limit_str)))
79 .collect();
80 if !params.is_empty() {
81 req = req.query(¶ms);
82 }
83
84 let resp = req.send().await?;
85 if !resp.status().is_success() {
86 return Err(ScopeError::Chain(format!(
87 "{} API error: HTTP {}",
88 self.descriptor.name,
89 resp.status()
90 )));
91 }
92 resp.json::<Value>().await.map_err(|e| {
93 ScopeError::Chain(format!("{} JSON parse error: {}", self.descriptor.name, e))
94 })
95 }
96 HttpMethod::POST => {
97 let mut req = self.http.post(&url);
98 for (k, v) in &self.descriptor.headers {
99 req = req.header(k, v);
100 }
101 if let Some(body_template) = &endpoint.request_body {
103 let body = self.interpolate_json(body_template, pair, &limit_str);
104 req = req.json(&body);
105 }
106
107 let resp = req.send().await?;
108 if !resp.status().is_success() {
109 return Err(ScopeError::Chain(format!(
110 "{} API error: HTTP {}",
111 self.descriptor.name,
112 resp.status()
113 )));
114 }
115 resp.json::<Value>().await.map_err(|e| {
116 ScopeError::Chain(format!("{} JSON parse error: {}", self.descriptor.name, e))
117 })
118 }
119 }
120 }
121
122 fn interpolate_path(&self, path: &str, pair: &str) -> String {
124 path.replace("{pair}", pair)
125 }
126
127 fn interpolate_value(&self, template: &str, pair: &str, limit: &str) -> String {
129 self.interpolate_value_full(template, pair, limit, "")
130 }
131
132 fn interpolate_value_full(
134 &self,
135 template: &str,
136 pair: &str,
137 limit: &str,
138 interval: &str,
139 ) -> String {
140 template
141 .replace("{pair}", pair)
142 .replace("{limit}", limit)
143 .replace("{interval}", interval)
144 }
145
146 fn interpolate_json(&self, value: &Value, pair: &str, limit: &str) -> Value {
148 self.interpolate_json_full(value, pair, limit, "")
149 }
150
151 fn interpolate_json_full(
152 &self,
153 value: &Value,
154 pair: &str,
155 limit: &str,
156 interval: &str,
157 ) -> Value {
158 match value {
159 Value::String(s) => {
160 Value::String(self.interpolate_value_full(s, pair, limit, interval))
161 }
162 Value::Object(map) => {
163 let mut new_map = serde_json::Map::new();
164 for (k, v) in map {
165 new_map.insert(
166 k.clone(),
167 self.interpolate_json_full(v, pair, limit, interval),
168 );
169 }
170 Value::Object(new_map)
171 }
172 Value::Array(arr) => Value::Array(
173 arr.iter()
174 .map(|v| self.interpolate_json_full(v, pair, limit, interval))
175 .collect(),
176 ),
177 other => other.clone(),
178 }
179 }
180
181 async fn fetch_endpoint_with_interval(
183 &self,
184 endpoint: &EndpointDescriptor,
185 pair: &str,
186 limit: Option<u32>,
187 interval: &str,
188 ) -> Result<Value> {
189 let url = format!(
190 "{}{}",
191 self.descriptor.base_url,
192 self.interpolate_path(&endpoint.path, pair)
193 );
194 let limit_str = limit.unwrap_or(100).to_string();
195
196 match endpoint.method {
197 HttpMethod::GET => {
198 let mut req = self.http.get(&url);
199 for (k, v) in &self.descriptor.headers {
200 req = req.header(k, v);
201 }
202 let params: Vec<(String, String)> = endpoint
203 .params
204 .iter()
205 .map(|(k, v)| {
206 (
207 k.clone(),
208 self.interpolate_value_full(v, pair, &limit_str, interval),
209 )
210 })
211 .collect();
212 if !params.is_empty() {
213 req = req.query(¶ms);
214 }
215 let resp = req.send().await?;
216 if !resp.status().is_success() {
217 return Err(ScopeError::Chain(format!(
218 "{} API error: HTTP {}",
219 self.descriptor.name,
220 resp.status()
221 )));
222 }
223 resp.json::<Value>().await.map_err(|e| {
224 ScopeError::Chain(format!("{} JSON parse error: {}", self.descriptor.name, e))
225 })
226 }
227 HttpMethod::POST => {
228 let mut req = self.http.post(&url);
229 for (k, v) in &self.descriptor.headers {
230 req = req.header(k, v);
231 }
232 if let Some(body_template) = &endpoint.request_body {
233 let body =
234 self.interpolate_json_full(body_template, pair, &limit_str, interval);
235 req = req.json(&body);
236 }
237 let resp = req.send().await?;
238 if !resp.status().is_success() {
239 return Err(ScopeError::Chain(format!(
240 "{} API error: HTTP {}",
241 self.descriptor.name,
242 resp.status()
243 )));
244 }
245 resp.json::<Value>().await.map_err(|e| {
246 ScopeError::Chain(format!("{} JSON parse error: {}", self.descriptor.name, e))
247 })
248 }
249 }
250 }
251
252 fn navigate_root<'a>(&self, root: &'a Value, path: Option<&str>) -> Result<&'a Value> {
265 let path = match path {
266 Some(p) if !p.is_empty() => p,
267 _ => return Ok(root),
268 };
269
270 let mut current = root;
271 for segment in path.split('.') {
272 if segment == "*" {
273 current = match current {
275 Value::Object(map) => map.values().next().ok_or_else(|| {
276 ScopeError::Chain(format!(
277 "{}: empty object at wildcard '*'",
278 self.descriptor.name
279 ))
280 })?,
281 _ => {
282 return Err(ScopeError::Chain(format!(
283 "{}: expected object at wildcard '*', got {:?}",
284 self.descriptor.name,
285 current_type(current)
286 )));
287 }
288 };
289 } else if let Ok(idx) = segment.parse::<usize>() {
290 current = current.get(idx).ok_or_else(|| {
292 ScopeError::Chain(format!(
293 "{}: index {} out of bounds",
294 self.descriptor.name, idx
295 ))
296 })?;
297 } else {
298 current = current.get(segment).ok_or_else(|| {
300 ScopeError::Chain(format!(
301 "{}: missing key '{}' in response",
302 self.descriptor.name, segment
303 ))
304 })?;
305 }
306 }
307 Ok(current)
308 }
309
310 fn extract_f64(&self, data: &Value, field_path: &str) -> Option<f64> {
314 let val = self.navigate_field(data, field_path)?;
315 value_to_f64(val)
316 }
317
318 fn navigate_field<'a>(&self, data: &'a Value, path: &str) -> Option<&'a Value> {
320 let mut current = data;
321 for segment in path.split('.') {
322 if let Ok(idx) = segment.parse::<usize>() {
323 current = current.get(idx)?;
324 } else {
325 current = current.get(segment)?;
326 }
327 }
328 Some(current)
329 }
330
331 fn extract_string(&self, data: &Value, field_path: &str) -> Option<String> {
333 let val = self.navigate_field(data, field_path)?;
334 match val {
335 Value::String(s) => Some(s.clone()),
336 Value::Number(n) => Some(n.to_string()),
337 _ => None,
338 }
339 }
340
341 fn parse_levels(&self, arr: &Value, mapping: &ResponseMapping) -> Result<Vec<OrderBookLevel>> {
347 let items = arr.as_array().ok_or_else(|| {
348 ScopeError::Chain(format!(
349 "{}: expected array for levels",
350 self.descriptor.name
351 ))
352 })?;
353
354 let level_format = mapping.level_format.as_deref().unwrap_or("positional");
355 let mut levels = Vec::with_capacity(items.len());
356
357 for item in items {
358 let (price, quantity) = match level_format {
359 "object" => {
360 let price_field = mapping.level_price_field.as_deref().unwrap_or("price");
361 let size_field = mapping.level_size_field.as_deref().unwrap_or("size");
362 let p = self
363 .navigate_field(item, price_field)
364 .and_then(value_to_f64);
365 let q = self.navigate_field(item, size_field).and_then(value_to_f64);
366 (p, q)
367 }
368 _ => {
369 let p = item.get(0).and_then(value_to_f64);
371 let q = item.get(1).and_then(value_to_f64);
372 (p, q)
373 }
374 };
375
376 if let (Some(price), Some(quantity)) = (price, quantity)
377 && price > 0.0
378 && quantity > 0.0
379 {
380 levels.push(OrderBookLevel { price, quantity });
381 }
382 }
383
384 Ok(levels)
385 }
386
387 fn parse_side(&self, data: &Value, mapping: &ResponseMapping) -> TradeSide {
393 if let Some(side_mapping) = &mapping.side
394 && let Some(val) = self.navigate_field(data, &side_mapping.field)
395 {
396 let val_str = match val {
397 Value::String(s) => s.clone(),
398 Value::Bool(b) => b.to_string(),
399 Value::Number(n) => n.to_string(),
400 _ => return TradeSide::Buy,
401 };
402 if let Some(canonical) = side_mapping.mapping.get(&val_str) {
403 return match canonical.as_str() {
404 "sell" => TradeSide::Sell,
405 _ => TradeSide::Buy,
406 };
407 }
408 }
409 TradeSide::Buy
410 }
411}
412
413#[async_trait]
418impl OrderBookClient for ConfigurableExchangeClient {
419 async fn fetch_order_book(&self, pair_symbol: &str) -> Result<OrderBook> {
420 let endpoint = self
421 .descriptor
422 .capabilities
423 .order_book
424 .as_ref()
425 .ok_or_else(|| {
426 ScopeError::Chain(format!(
427 "{} does not support order book",
428 self.descriptor.name
429 ))
430 })?;
431
432 let json = self.fetch_endpoint(endpoint, pair_symbol, None).await?;
433 let data = self.navigate_root(&json, endpoint.response_root.as_deref())?;
434
435 let asks_key = endpoint.response.asks_key.as_deref().unwrap_or("asks");
436 let bids_key = endpoint.response.bids_key.as_deref().unwrap_or("bids");
437
438 let asks_arr = data.get(asks_key).ok_or_else(|| {
439 ScopeError::Chain(format!(
440 "{}: missing '{}' in order book response",
441 self.descriptor.name, asks_key
442 ))
443 })?;
444 let bids_arr = data.get(bids_key).ok_or_else(|| {
445 ScopeError::Chain(format!(
446 "{}: missing '{}' in order book response",
447 self.descriptor.name, bids_key
448 ))
449 })?;
450
451 let mut asks = self.parse_levels(asks_arr, &endpoint.response)?;
452 let mut bids = self.parse_levels(bids_arr, &endpoint.response)?;
453
454 asks.sort_by(|a, b| {
456 a.price
457 .partial_cmp(&b.price)
458 .unwrap_or(std::cmp::Ordering::Equal)
459 });
460 bids.sort_by(|a, b| {
461 b.price
462 .partial_cmp(&a.price)
463 .unwrap_or(std::cmp::Ordering::Equal)
464 });
465
466 let pair = format_display_pair(pair_symbol, &self.descriptor.symbol.template);
468
469 Ok(OrderBook { pair, bids, asks })
470 }
471}
472
473#[async_trait]
474impl TickerClient for ConfigurableExchangeClient {
475 async fn fetch_ticker(&self, pair_symbol: &str) -> Result<Ticker> {
476 let endpoint = self
477 .descriptor
478 .capabilities
479 .ticker
480 .as_ref()
481 .ok_or_else(|| {
482 ScopeError::Chain(format!("{} does not support ticker", self.descriptor.name))
483 })?;
484
485 let json = self.fetch_endpoint(endpoint, pair_symbol, None).await?;
486
487 let data = if let Some(filter) = &endpoint.response.filter {
489 let root = self.navigate_root(&json, endpoint.response_root.as_deref())?;
490 let items_key = endpoint.response.items_key.as_deref().unwrap_or("");
491 let items = if items_key.is_empty() {
492 root
493 } else {
494 root.get(items_key).unwrap_or(root)
495 };
496 let arr = items.as_array().ok_or_else(|| {
497 ScopeError::Chain(format!(
498 "{}: expected array for ticker filter",
499 self.descriptor.name
500 ))
501 })?;
502 let filter_value = filter.value.replace("{pair}", pair_symbol);
503 arr.iter()
504 .find(|item| {
505 item.get(&filter.field)
506 .and_then(|v| v.as_str())
507 .is_some_and(|s| s == filter_value)
508 })
509 .ok_or_else(|| {
510 ScopeError::Chain(format!(
511 "{}: no ticker found for pair {}",
512 self.descriptor.name, pair_symbol
513 ))
514 })?
515 .clone()
516 } else {
517 self.navigate_root(&json, endpoint.response_root.as_deref())?
518 .clone()
519 };
520
521 let r = &endpoint.response;
522 let pair = format_display_pair(pair_symbol, &self.descriptor.symbol.template);
523
524 Ok(Ticker {
525 pair,
526 last_price: r
527 .last_price
528 .as_ref()
529 .and_then(|f| self.extract_f64(&data, f)),
530 high_24h: r.high_24h.as_ref().and_then(|f| self.extract_f64(&data, f)),
531 low_24h: r.low_24h.as_ref().and_then(|f| self.extract_f64(&data, f)),
532 volume_24h: r
533 .volume_24h
534 .as_ref()
535 .and_then(|f| self.extract_f64(&data, f)),
536 quote_volume_24h: r
537 .quote_volume_24h
538 .as_ref()
539 .and_then(|f| self.extract_f64(&data, f)),
540 best_bid: r.best_bid.as_ref().and_then(|f| self.extract_f64(&data, f)),
541 best_ask: r.best_ask.as_ref().and_then(|f| self.extract_f64(&data, f)),
542 })
543 }
544}
545
546#[async_trait]
547impl TradeHistoryClient for ConfigurableExchangeClient {
548 async fn fetch_recent_trades(&self, pair_symbol: &str, limit: u32) -> Result<Vec<Trade>> {
549 let endpoint = self
550 .descriptor
551 .capabilities
552 .trades
553 .as_ref()
554 .ok_or_else(|| {
555 ScopeError::Chain(format!("{} does not support trades", self.descriptor.name))
556 })?;
557
558 let json = self
559 .fetch_endpoint(endpoint, pair_symbol, Some(limit))
560 .await?;
561 let data = self.navigate_root(&json, endpoint.response_root.as_deref())?;
562
563 let items_key = endpoint.response.items_key.as_deref().unwrap_or("");
565 let arr = if items_key.is_empty() {
566 data
567 } else {
568 data.get(items_key).unwrap_or(data)
569 };
570
571 let items = arr.as_array().ok_or_else(|| {
572 ScopeError::Chain(format!(
573 "{}: expected array for trades",
574 self.descriptor.name
575 ))
576 })?;
577
578 let r = &endpoint.response;
579 let mut trades = Vec::with_capacity(items.len());
580
581 for item in items {
582 let price = r.price.as_ref().and_then(|f| self.extract_f64(item, f));
583 let quantity = r.quantity.as_ref().and_then(|f| self.extract_f64(item, f));
584
585 if let (Some(price), Some(quantity)) = (price, quantity) {
586 let quote_quantity = r
587 .quote_quantity
588 .as_ref()
589 .and_then(|f| self.extract_f64(item, f));
590 let timestamp_ms = r
591 .timestamp_ms
592 .as_ref()
593 .and_then(|f| self.extract_f64(item, f))
594 .map(|v| v as u64)
595 .unwrap_or(0);
596 let id = r.id.as_ref().and_then(|f| self.extract_string(item, f));
597 let side = self.parse_side(item, r);
598
599 trades.push(Trade {
600 price,
601 quantity,
602 quote_quantity,
603 timestamp_ms,
604 side,
605 id,
606 });
607 }
608 }
609
610 Ok(trades)
611 }
612}
613
614#[async_trait]
615impl OhlcClient for ConfigurableExchangeClient {
616 async fn fetch_ohlc(
617 &self,
618 pair_symbol: &str,
619 interval: &str,
620 limit: u32,
621 ) -> Result<Vec<Candle>> {
622 let endpoint = self.descriptor.capabilities.ohlc.as_ref().ok_or_else(|| {
623 ScopeError::Chain(format!("{} does not support OHLC", self.descriptor.name))
624 })?;
625
626 let mapped_interval = endpoint
628 .interval_map
629 .get(interval)
630 .map(|s| s.as_str())
631 .unwrap_or(interval);
632
633 let json = self
634 .fetch_endpoint_with_interval(endpoint, pair_symbol, Some(limit), mapped_interval)
635 .await?;
636 let data = self.navigate_root(&json, endpoint.response_root.as_deref())?;
637
638 let items_key = endpoint.response.items_key.as_deref().unwrap_or("");
640 let arr = if items_key.is_empty() {
641 data
642 } else {
643 data.get(items_key).unwrap_or(data)
644 };
645
646 let items = arr.as_array().ok_or_else(|| {
647 ScopeError::Chain(format!(
648 "{}: expected array for OHLC data",
649 self.descriptor.name
650 ))
651 })?;
652
653 let r = &endpoint.response;
654 let format = r.ohlc_format.as_deref().unwrap_or("objects");
655 let mut candles = Vec::with_capacity(items.len());
656
657 if format == "array_of_arrays" {
658 let default_fields = vec![
661 "open_time".to_string(),
662 "open".to_string(),
663 "high".to_string(),
664 "low".to_string(),
665 "close".to_string(),
666 "volume".to_string(),
667 "close_time".to_string(),
668 ];
669 let fields = r.ohlc_fields.as_ref().unwrap_or(&default_fields);
670 let idx = |name: &str| -> Option<usize> { fields.iter().position(|f| f == name) };
671
672 for item in items {
673 let arr = match item.as_array() {
674 Some(a) => a,
675 None => continue,
676 };
677 let get_f64 = |i: Option<usize>| -> Option<f64> {
678 i.and_then(|idx| arr.get(idx)).and_then(value_to_f64)
679 };
680 let get_u64 = |i: Option<usize>| -> Option<u64> { get_f64(i).map(|v| v as u64) };
681
682 if let (Some(open), Some(high), Some(low), Some(close)) = (
683 get_f64(idx("open")),
684 get_f64(idx("high")),
685 get_f64(idx("low")),
686 get_f64(idx("close")),
687 ) {
688 candles.push(Candle {
689 open_time: get_u64(idx("open_time")).unwrap_or(0),
690 open,
691 high,
692 low,
693 close,
694 volume: get_f64(idx("volume")).unwrap_or(0.0),
695 close_time: get_u64(idx("close_time")).unwrap_or(0),
696 });
697 }
698 }
699 } else {
700 for item in items {
702 let open = r.open.as_ref().and_then(|f| self.extract_f64(item, f));
703 let high = r.high.as_ref().and_then(|f| self.extract_f64(item, f));
704 let low = r.low.as_ref().and_then(|f| self.extract_f64(item, f));
705 let close = r.close.as_ref().and_then(|f| self.extract_f64(item, f));
706
707 if let (Some(open), Some(high), Some(low), Some(close)) = (open, high, low, close) {
708 let open_time = r
709 .open_time
710 .as_ref()
711 .and_then(|f| self.extract_f64(item, f))
712 .map(|v| v as u64)
713 .unwrap_or(0);
714 let volume = r
715 .ohlc_volume
716 .as_ref()
717 .and_then(|f| self.extract_f64(item, f))
718 .unwrap_or(0.0);
719 let close_time = r
720 .close_time
721 .as_ref()
722 .and_then(|f| self.extract_f64(item, f))
723 .map(|v| v as u64)
724 .unwrap_or(0);
725
726 candles.push(Candle {
727 open_time,
728 open,
729 high,
730 low,
731 close,
732 volume,
733 close_time,
734 });
735 }
736 }
737 }
738
739 Ok(candles)
740 }
741}
742
743fn value_to_f64(val: &Value) -> Option<f64> {
749 match val {
750 Value::Number(n) => n.as_f64(),
751 Value::String(s) => s.parse::<f64>().ok(),
752 _ => None,
753 }
754}
755
756fn current_type(val: &Value) -> &'static str {
758 match val {
759 Value::Null => "null",
760 Value::Bool(_) => "bool",
761 Value::Number(_) => "number",
762 Value::String(_) => "string",
763 Value::Array(_) => "array",
764 Value::Object(_) => "object",
765 }
766}
767
768fn format_display_pair(raw: &str, template: &str) -> String {
770 let sep = if template.contains('_') {
772 "_"
773 } else if template.contains('-') {
774 "-"
775 } else {
776 ""
777 };
778
779 if !sep.is_empty() {
780 raw.replace(sep, "/")
781 } else {
782 let upper = raw.to_uppercase();
784 for quote in &["USDT", "USD", "USDC", "BTC", "ETH", "EUR", "GBP"] {
785 if upper.ends_with(quote) {
786 let base_end = raw.len() - quote.len();
787 if base_end > 0 {
788 return format!("{}/{}", &raw[..base_end], &raw[base_end..]);
789 }
790 }
791 }
792 raw.to_string()
793 }
794}
795
796#[cfg(test)]
797mod tests {
798 use super::*;
799
800 #[test]
801 fn test_format_display_pair_underscore() {
802 assert_eq!(
803 format_display_pair("BTC_USDT", "{base}_{quote}"),
804 "BTC/USDT"
805 );
806 }
807
808 #[test]
809 fn test_format_display_pair_dash() {
810 assert_eq!(
811 format_display_pair("BTC-USDT", "{base}-{quote}"),
812 "BTC/USDT"
813 );
814 }
815
816 #[test]
817 fn test_format_display_pair_concatenated() {
818 assert_eq!(format_display_pair("BTCUSDT", "{base}{quote}"), "BTC/USDT");
819 assert_eq!(format_display_pair("ETHUSD", "{base}{quote}"), "ETH/USD");
820 }
821
822 #[test]
823 fn test_value_to_f64_number() {
824 let val = serde_json::json!(42.5);
825 assert_eq!(value_to_f64(&val), Some(42.5));
826 }
827
828 #[test]
829 fn test_value_to_f64_string() {
830 let val = serde_json::json!("42.5");
831 assert_eq!(value_to_f64(&val), Some(42.5));
832 }
833
834 #[test]
835 fn test_value_to_f64_invalid() {
836 let val = serde_json::json!(null);
837 assert_eq!(value_to_f64(&val), None);
838 }
839
840 #[test]
841 fn test_navigate_root_empty() {
842 let desc = make_test_descriptor();
843 let client = ConfigurableExchangeClient::new(desc);
844 let json = serde_json::json!({"price": 42});
845 let result = client.navigate_root(&json, None).unwrap();
846 assert_eq!(result, &json);
847 }
848
849 #[test]
850 fn test_navigate_root_single_key() {
851 let desc = make_test_descriptor();
852 let client = ConfigurableExchangeClient::new(desc);
853 let json = serde_json::json!({"result": {"price": 42}});
854 let result = client.navigate_root(&json, Some("result")).unwrap();
855 assert_eq!(result, &serde_json::json!({"price": 42}));
856 }
857
858 #[test]
859 fn test_navigate_root_nested_with_index() {
860 let desc = make_test_descriptor();
861 let client = ConfigurableExchangeClient::new(desc);
862 let json = serde_json::json!({"data": [{"price": 42}, {"price": 43}]});
863 let result = client.navigate_root(&json, Some("data.0")).unwrap();
864 assert_eq!(result, &serde_json::json!({"price": 42}));
865 }
866
867 #[test]
868 fn test_navigate_root_wildcard() {
869 let desc = make_test_descriptor();
870 let client = ConfigurableExchangeClient::new(desc);
871 let json = serde_json::json!({"result": {"XXBTZUSD": {"a": ["42000.0"]}}});
872 let result = client.navigate_root(&json, Some("result.*")).unwrap();
873 assert_eq!(result, &serde_json::json!({"a": ["42000.0"]}));
874 }
875
876 #[test]
877 fn test_extract_f64_nested() {
878 let desc = make_test_descriptor();
879 let client = ConfigurableExchangeClient::new(desc);
880 let data = serde_json::json!({"c": ["42000.5", "1.5"]});
881 assert_eq!(client.extract_f64(&data, "c.0"), Some(42000.5));
882 assert_eq!(client.extract_f64(&data, "c.1"), Some(1.5));
883 }
884
885 #[test]
886 fn test_parse_positional_levels() {
887 let desc = make_test_descriptor();
888 let client = ConfigurableExchangeClient::new(desc);
889 let arr = serde_json::json!([["42000.0", "1.5"], ["42001.0", "2.0"]]);
890 let mapping = ResponseMapping {
891 level_format: Some("positional".to_string()),
892 ..Default::default()
893 };
894 let levels = client.parse_levels(&arr, &mapping).unwrap();
895 assert_eq!(levels.len(), 2);
896 assert_eq!(levels[0].price, 42000.0);
897 assert_eq!(levels[0].quantity, 1.5);
898 }
899
900 #[test]
901 fn test_parse_object_levels() {
902 let desc = make_test_descriptor();
903 let client = ConfigurableExchangeClient::new(desc);
904 let arr = serde_json::json!([
905 {"price": "42000.0", "size": "1.5"},
906 {"price": "42001.0", "size": "2.0"}
907 ]);
908 let mapping = ResponseMapping {
909 level_format: Some("object".to_string()),
910 level_price_field: Some("price".to_string()),
911 level_size_field: Some("size".to_string()),
912 ..Default::default()
913 };
914 let levels = client.parse_levels(&arr, &mapping).unwrap();
915 assert_eq!(levels.len(), 2);
916 assert_eq!(levels[0].price, 42000.0);
917 assert_eq!(levels[0].quantity, 1.5);
918 }
919
920 #[test]
921 fn test_parse_side_mapping() {
922 let desc = make_test_descriptor();
923 let client = ConfigurableExchangeClient::new(desc);
924 let data = serde_json::json!({"isBuyerMaker": true});
925 let mapping = ResponseMapping {
926 side: Some(crate::market::descriptor::SideMapping {
927 field: "isBuyerMaker".to_string(),
928 mapping: [
929 ("true".to_string(), "sell".to_string()),
930 ("false".to_string(), "buy".to_string()),
931 ]
932 .into_iter()
933 .collect(),
934 }),
935 ..Default::default()
936 };
937 assert_eq!(client.parse_side(&data, &mapping), TradeSide::Sell);
938 }
939
940 #[test]
941 fn test_interpolate_json() {
942 let desc = make_test_descriptor();
943 let client = ConfigurableExchangeClient::new(desc);
944 let template = serde_json::json!({
945 "method": "get-book",
946 "params": {"instrument": "{pair}", "depth": "{limit}"}
947 });
948 let result = client.interpolate_json(&template, "BTC_USDT", "100");
949 assert_eq!(
950 result,
951 serde_json::json!({
952 "method": "get-book",
953 "params": {"instrument": "BTC_USDT", "depth": "100"}
954 })
955 );
956 }
957
958 fn make_test_descriptor() -> VenueDescriptor {
959 use crate::market::descriptor::*;
960 VenueDescriptor {
961 id: "test".to_string(),
962 name: "Test".to_string(),
963 base_url: "https://example.com".to_string(),
964 timeout_secs: Some(5),
965 rate_limit_per_sec: None,
966 symbol: SymbolConfig {
967 template: "{base}{quote}".to_string(),
968 default_quote: "USDT".to_string(),
969 case: SymbolCase::Upper,
970 },
971 headers: std::collections::HashMap::new(),
972 capabilities: CapabilitySet::default(),
973 }
974 }
975
976 #[test]
982 fn test_descriptor_accessor() {
983 let desc = make_test_descriptor();
984 let client = ConfigurableExchangeClient::new(desc.clone());
985 assert_eq!(client.descriptor().id, "test");
986 assert_eq!(client.descriptor().name, "Test");
987 }
988
989 #[test]
990 fn test_format_pair_accessor() {
991 let desc = make_test_descriptor();
992 let client = ConfigurableExchangeClient::new(desc);
993 assert_eq!(client.format_pair("BTC", None), "BTCUSDT");
994 assert_eq!(client.format_pair("ETH", Some("USD")), "ETHUSD");
995 }
996
997 #[test]
998 fn test_navigate_root_empty_string_path() {
999 let desc = make_test_descriptor();
1000 let client = ConfigurableExchangeClient::new(desc);
1001 let json = serde_json::json!({"price": 42});
1002 let result = client.navigate_root(&json, Some("")).unwrap();
1003 assert_eq!(result, &json);
1004 }
1005
1006 #[test]
1007 fn test_navigate_root_wildcard_on_non_object_null() {
1008 let desc = make_test_descriptor();
1009 let client = ConfigurableExchangeClient::new(desc);
1010 let json = serde_json::json!({"result": null});
1011 let result = client.navigate_root(&json, Some("result.*"));
1012 assert!(result.is_err());
1013 let err_msg = format!("{:?}", result.unwrap_err());
1014 assert!(err_msg.contains("null"), "error should mention null type");
1015 }
1016
1017 #[test]
1018 fn test_navigate_root_wildcard_on_non_object_bool() {
1019 let desc = make_test_descriptor();
1020 let client = ConfigurableExchangeClient::new(desc);
1021 let json = serde_json::json!({"result": true});
1022 let result = client.navigate_root(&json, Some("result.*"));
1023 assert!(result.is_err());
1024 let err_msg = format!("{:?}", result.unwrap_err());
1025 assert!(err_msg.contains("bool"), "error should mention bool type");
1026 }
1027
1028 #[test]
1029 fn test_navigate_root_wildcard_on_non_object_number() {
1030 let desc = make_test_descriptor();
1031 let client = ConfigurableExchangeClient::new(desc);
1032 let json = serde_json::json!({"result": 42});
1033 let result = client.navigate_root(&json, Some("result.*"));
1034 assert!(result.is_err());
1035 let err_msg = format!("{:?}", result.unwrap_err());
1036 assert!(
1037 err_msg.contains("number"),
1038 "error should mention number type"
1039 );
1040 }
1041
1042 #[test]
1043 fn test_navigate_root_wildcard_on_non_object_string() {
1044 let desc = make_test_descriptor();
1045 let client = ConfigurableExchangeClient::new(desc);
1046 let json = serde_json::json!({"result": "not_an_object"});
1047 let result = client.navigate_root(&json, Some("result.*"));
1048 assert!(result.is_err());
1049 let err_msg = format!("{:?}", result.unwrap_err());
1050 assert!(
1051 err_msg.contains("string"),
1052 "error should mention string type"
1053 );
1054 }
1055
1056 #[test]
1057 fn test_navigate_root_wildcard_on_non_object_array() {
1058 let desc = make_test_descriptor();
1059 let client = ConfigurableExchangeClient::new(desc);
1060 let json = serde_json::json!({"result": [1, 2, 3]});
1061 let result = client.navigate_root(&json, Some("result.*"));
1062 assert!(result.is_err());
1063 let err_msg = format!("{:?}", result.unwrap_err());
1064 assert!(err_msg.contains("array"), "error should mention array type");
1065 }
1066
1067 #[test]
1068 fn test_navigate_root_wildcard_empty_object() {
1069 let desc = make_test_descriptor();
1070 let client = ConfigurableExchangeClient::new(desc);
1071 let json = serde_json::json!({"result": {}});
1072 let result = client.navigate_root(&json, Some("result.*"));
1073 assert!(result.is_err());
1074 let err_msg = format!("{:?}", result.unwrap_err());
1075 assert!(
1076 err_msg.contains("empty object"),
1077 "error should mention empty object"
1078 );
1079 }
1080
1081 #[test]
1082 fn test_extract_string_from_string() {
1083 let desc = make_test_descriptor();
1084 let client = ConfigurableExchangeClient::new(desc);
1085 let data = serde_json::json!({"id": "abc123"});
1086 assert_eq!(
1087 client.extract_string(&data, "id").as_deref(),
1088 Some("abc123")
1089 );
1090 }
1091
1092 #[test]
1093 fn test_extract_string_from_number() {
1094 let desc = make_test_descriptor();
1095 let client = ConfigurableExchangeClient::new(desc);
1096 let data = serde_json::json!({"id": 12345});
1097 assert_eq!(client.extract_string(&data, "id").as_deref(), Some("12345"));
1098 }
1099
1100 #[test]
1101 fn test_extract_string_from_nested_path() {
1102 let desc = make_test_descriptor();
1103 let client = ConfigurableExchangeClient::new(desc);
1104 let data = serde_json::json!({"a": {"b": {"c": ["x", "value"]}}});
1105 assert_eq!(
1106 client.extract_string(&data, "a.b.c.1").as_deref(),
1107 Some("value")
1108 );
1109 }
1110
1111 #[test]
1112 fn test_extract_string_returns_none_for_object() {
1113 let desc = make_test_descriptor();
1114 let client = ConfigurableExchangeClient::new(desc);
1115 let data = serde_json::json!({"id": {"nested": "obj"}});
1116 assert_eq!(client.extract_string(&data, "id"), None);
1117 }
1118
1119 #[test]
1120 fn test_extract_string_returns_none_for_array() {
1121 let desc = make_test_descriptor();
1122 let client = ConfigurableExchangeClient::new(desc);
1123 let data = serde_json::json!({"id": [1, 2, 3]});
1124 assert_eq!(client.extract_string(&data, "id"), None);
1125 }
1126
1127 #[test]
1128 fn test_navigate_field_deeper_path() {
1129 let desc = make_test_descriptor();
1130 let client = ConfigurableExchangeClient::new(desc);
1131 let data = serde_json::json!({"level1": {"level2": {"level3": [0, 99.5]}}});
1132 assert_eq!(
1133 client.extract_f64(&data, "level1.level2.level3.1"),
1134 Some(99.5)
1135 );
1136 }
1137
1138 #[test]
1139 fn test_parse_levels_empty_array() {
1140 let desc = make_test_descriptor();
1141 let client = ConfigurableExchangeClient::new(desc);
1142 let arr = serde_json::json!([]);
1143 let mapping = ResponseMapping::default();
1144 let levels = client.parse_levels(&arr, &mapping).unwrap();
1145 assert!(levels.is_empty());
1146 }
1147
1148 #[test]
1149 fn test_parse_levels_not_array_err() {
1150 let desc = make_test_descriptor();
1151 let client = ConfigurableExchangeClient::new(desc);
1152 let not_arr = serde_json::json!({"not": "array"});
1153 let mapping = ResponseMapping::default();
1154 let result = client.parse_levels(¬_arr, &mapping);
1155 assert!(result.is_err());
1156 let err_msg = format!("{:?}", result.unwrap_err());
1157 assert!(err_msg.contains("expected array"));
1158 }
1159
1160 #[test]
1161 fn test_parse_levels_filters_zero_price_and_quantity() {
1162 let desc = make_test_descriptor();
1163 let client = ConfigurableExchangeClient::new(desc);
1164 let arr = serde_json::json!([
1165 ["42000.0", "1.5"],
1166 ["0.0", "1.0"],
1167 ["42001.0", "0.0"],
1168 ["42002.0", ""]
1169 ]);
1170 let mapping = ResponseMapping {
1171 level_format: Some("positional".to_string()),
1172 ..Default::default()
1173 };
1174 let levels = client.parse_levels(&arr, &mapping).unwrap();
1175 assert_eq!(levels.len(), 1);
1176 assert_eq!(levels[0].price, 42000.0);
1177 assert_eq!(levels[0].quantity, 1.5);
1178 }
1179
1180 #[test]
1181 fn test_parse_levels_object_format_default_fields() {
1182 let desc = make_test_descriptor();
1183 let client = ConfigurableExchangeClient::new(desc);
1184 let arr = serde_json::json!([
1185 {"price": "100.0", "size": "2.0"},
1186 {"price": "101.0", "size": "3.0"}
1187 ]);
1188 let mapping = ResponseMapping {
1189 level_format: Some("object".to_string()),
1190 level_price_field: None,
1191 level_size_field: None,
1192 ..Default::default()
1193 };
1194 let levels = client.parse_levels(&arr, &mapping).unwrap();
1195 assert_eq!(levels.len(), 2);
1196 assert_eq!(levels[0].price, 100.0);
1197 assert_eq!(levels[0].quantity, 2.0);
1198 }
1199
1200 #[test]
1201 fn test_parse_side_no_mapping_returns_buy() {
1202 let desc = make_test_descriptor();
1203 let client = ConfigurableExchangeClient::new(desc);
1204 let data = serde_json::json!({"side": "sell"});
1205 let mapping = ResponseMapping {
1206 side: None,
1207 ..Default::default()
1208 };
1209 assert_eq!(client.parse_side(&data, &mapping), TradeSide::Buy);
1210 }
1211
1212 #[test]
1213 fn test_parse_side_field_missing_returns_buy() {
1214 let desc = make_test_descriptor();
1215 let client = ConfigurableExchangeClient::new(desc);
1216 let data = serde_json::json!({});
1217 let mapping = ResponseMapping {
1218 side: Some(crate::market::descriptor::SideMapping {
1219 field: "nonexistent".to_string(),
1220 mapping: [("sell".to_string(), "sell".to_string())]
1221 .into_iter()
1222 .collect(),
1223 }),
1224 ..Default::default()
1225 };
1226 assert_eq!(client.parse_side(&data, &mapping), TradeSide::Buy);
1227 }
1228
1229 #[test]
1230 fn test_parse_side_string_mapped_to_sell() {
1231 let desc = make_test_descriptor();
1232 let client = ConfigurableExchangeClient::new(desc);
1233 let data = serde_json::json!({"side": "ask"});
1234 let mapping = ResponseMapping {
1235 side: Some(crate::market::descriptor::SideMapping {
1236 field: "side".to_string(),
1237 mapping: [
1238 ("ask".to_string(), "sell".to_string()),
1239 ("bid".to_string(), "buy".to_string()),
1240 ]
1241 .into_iter()
1242 .collect(),
1243 }),
1244 ..Default::default()
1245 };
1246 assert_eq!(client.parse_side(&data, &mapping), TradeSide::Sell);
1247 }
1248
1249 #[test]
1250 fn test_parse_side_string_mapped_to_buy() {
1251 let desc = make_test_descriptor();
1252 let client = ConfigurableExchangeClient::new(desc);
1253 let data = serde_json::json!({"side": "bid"});
1254 let mapping = ResponseMapping {
1255 side: Some(crate::market::descriptor::SideMapping {
1256 field: "side".to_string(),
1257 mapping: [
1258 ("ask".to_string(), "sell".to_string()),
1259 ("bid".to_string(), "buy".to_string()),
1260 ]
1261 .into_iter()
1262 .collect(),
1263 }),
1264 ..Default::default()
1265 };
1266 assert_eq!(client.parse_side(&data, &mapping), TradeSide::Buy);
1267 }
1268
1269 #[test]
1270 fn test_parse_side_numeric_value() {
1271 let desc = make_test_descriptor();
1272 let client = ConfigurableExchangeClient::new(desc);
1273 let data = serde_json::json!({"side": 1});
1274 let mapping = ResponseMapping {
1275 side: Some(crate::market::descriptor::SideMapping {
1276 field: "side".to_string(),
1277 mapping: [
1278 ("1".to_string(), "sell".to_string()),
1279 ("0".to_string(), "buy".to_string()),
1280 ]
1281 .into_iter()
1282 .collect(),
1283 }),
1284 ..Default::default()
1285 };
1286 assert_eq!(client.parse_side(&data, &mapping), TradeSide::Sell);
1287 }
1288
1289 #[test]
1290 fn test_parse_side_unknown_value_returns_buy() {
1291 let desc = make_test_descriptor();
1292 let client = ConfigurableExchangeClient::new(desc);
1293 let data = serde_json::json!({"side": "unknown"});
1294 let mapping = ResponseMapping {
1295 side: Some(crate::market::descriptor::SideMapping {
1296 field: "side".to_string(),
1297 mapping: [
1298 ("ask".to_string(), "sell".to_string()),
1299 ("bid".to_string(), "buy".to_string()),
1300 ]
1301 .into_iter()
1302 .collect(),
1303 }),
1304 ..Default::default()
1305 };
1306 assert_eq!(client.parse_side(&data, &mapping), TradeSide::Buy);
1307 }
1308
1309 #[test]
1310 fn test_parse_side_non_string_number_bool_returns_buy() {
1311 let desc = make_test_descriptor();
1312 let client = ConfigurableExchangeClient::new(desc);
1313 let data = serde_json::json!({"side": [1, 2, 3]});
1314 let mapping = ResponseMapping {
1315 side: Some(crate::market::descriptor::SideMapping {
1316 field: "side".to_string(),
1317 mapping: [("ask".to_string(), "sell".to_string())]
1318 .into_iter()
1319 .collect(),
1320 }),
1321 ..Default::default()
1322 };
1323 assert_eq!(client.parse_side(&data, &mapping), TradeSide::Buy);
1324 }
1325
1326 #[test]
1327 fn test_format_display_pair_eur() {
1328 assert_eq!(format_display_pair("BTCEUR", "{base}{quote}"), "BTC/EUR");
1329 assert_eq!(format_display_pair("etheur", "{base}{quote}"), "eth/eur");
1330 }
1331
1332 #[test]
1333 fn test_format_display_pair_gbp() {
1334 assert_eq!(format_display_pair("BTCGBP", "{base}{quote}"), "BTC/GBP");
1335 }
1336
1337 #[test]
1338 fn test_format_display_pair_unknown_quote() {
1339 assert_eq!(format_display_pair("XYZABC", "{base}{quote}"), "XYZABC");
1340 }
1341
1342 #[test]
1343 fn test_format_display_pair_single_char() {
1344 assert_eq!(format_display_pair("A", "{base}{quote}"), "A");
1345 }
1346
1347 #[test]
1348 fn test_format_display_pair_quote_only_no_split() {
1349 assert_eq!(format_display_pair("USDT", "{base}{quote}"), "USDT");
1350 }
1351
1352 fn make_http_test_descriptor(base_url: &str) -> VenueDescriptor {
1357 use crate::market::descriptor::*;
1358 VenueDescriptor {
1359 id: "mock_test".to_string(),
1360 name: "Mock Test".to_string(),
1361 base_url: base_url.to_string(),
1362 timeout_secs: Some(5),
1363 rate_limit_per_sec: None,
1364 symbol: SymbolConfig {
1365 template: "{base}{quote}".to_string(),
1366 default_quote: "USDT".to_string(),
1367 case: SymbolCase::Upper,
1368 },
1369 headers: std::collections::HashMap::new(),
1370 capabilities: CapabilitySet {
1371 order_book: Some(EndpointDescriptor {
1372 path: "/api/v1/depth".to_string(),
1373 method: HttpMethod::GET,
1374 params: [("symbol".to_string(), "{pair}".to_string())]
1375 .into_iter()
1376 .collect(),
1377 request_body: None,
1378 response_root: None,
1379 interval_map: std::collections::HashMap::new(),
1380 response: ResponseMapping {
1381 asks_key: Some("asks".to_string()),
1382 bids_key: Some("bids".to_string()),
1383 level_format: Some("positional".to_string()),
1384 ..Default::default()
1385 },
1386 }),
1387 ticker: Some(EndpointDescriptor {
1388 path: "/api/v1/ticker".to_string(),
1389 method: HttpMethod::GET,
1390 params: [("symbol".to_string(), "{pair}".to_string())]
1391 .into_iter()
1392 .collect(),
1393 request_body: None,
1394 response_root: None,
1395 interval_map: std::collections::HashMap::new(),
1396 response: ResponseMapping {
1397 last_price: Some("lastPrice".to_string()),
1398 high_24h: Some("highPrice".to_string()),
1399 low_24h: Some("lowPrice".to_string()),
1400 volume_24h: Some("volume".to_string()),
1401 quote_volume_24h: Some("quoteVolume".to_string()),
1402 best_bid: Some("bidPrice".to_string()),
1403 best_ask: Some("askPrice".to_string()),
1404 ..Default::default()
1405 },
1406 }),
1407 trades: Some(EndpointDescriptor {
1408 path: "/api/v1/trades".to_string(),
1409 method: HttpMethod::GET,
1410 params: [
1411 ("symbol".to_string(), "{pair}".to_string()),
1412 ("limit".to_string(), "{limit}".to_string()),
1413 ]
1414 .into_iter()
1415 .collect(),
1416 request_body: None,
1417 response_root: None,
1418 interval_map: std::collections::HashMap::new(),
1419 response: ResponseMapping {
1420 price: Some("price".to_string()),
1421 quantity: Some("qty".to_string()),
1422 quote_quantity: Some("quoteQty".to_string()),
1423 timestamp_ms: Some("time".to_string()),
1424 id: Some("id".to_string()),
1425 side: Some(SideMapping {
1426 field: "isBuyerMaker".to_string(),
1427 mapping: [
1428 ("true".to_string(), "sell".to_string()),
1429 ("false".to_string(), "buy".to_string()),
1430 ]
1431 .into_iter()
1432 .collect(),
1433 }),
1434 ..Default::default()
1435 },
1436 }),
1437 ohlc: None,
1438 },
1439 }
1440 }
1441
1442 #[tokio::test]
1443 async fn test_fetch_order_book_via_http() {
1444 let mut server = mockito::Server::new_async().await;
1445 let mock = server
1446 .mock("GET", "/api/v1/depth")
1447 .match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded(
1448 "symbol".into(),
1449 "BTCUSDT".into(),
1450 )]))
1451 .with_status(200)
1452 .with_body(
1453 serde_json::json!({
1454 "asks": [["50010.0", "1.5"], ["50020.0", "2.0"]],
1455 "bids": [["50000.0", "1.0"], ["49990.0", "3.0"]]
1456 })
1457 .to_string(),
1458 )
1459 .create_async()
1460 .await;
1461
1462 let desc = make_http_test_descriptor(&server.url());
1463 let client = ConfigurableExchangeClient::new(desc);
1464 let book = client.fetch_order_book("BTCUSDT").await.unwrap();
1465 assert_eq!(book.asks.len(), 2);
1466 assert_eq!(book.bids.len(), 2);
1467 assert_eq!(book.asks[0].price, 50010.0);
1468 assert_eq!(book.bids[0].price, 50000.0);
1469 mock.assert_async().await;
1470 }
1471
1472 #[tokio::test]
1473 async fn test_fetch_ticker_via_http() {
1474 let mut server = mockito::Server::new_async().await;
1475 let mock = server
1476 .mock("GET", "/api/v1/ticker")
1477 .match_query(mockito::Matcher::UrlEncoded(
1478 "symbol".into(),
1479 "BTCUSDT".into(),
1480 ))
1481 .with_status(200)
1482 .with_body(
1483 serde_json::json!({
1484 "lastPrice": "50100.5",
1485 "highPrice": "51200.0",
1486 "lowPrice": "48800.0",
1487 "volume": "1234.56",
1488 "quoteVolume": "62000000.0",
1489 "bidPrice": "50095.0",
1490 "askPrice": "50105.0"
1491 })
1492 .to_string(),
1493 )
1494 .create_async()
1495 .await;
1496
1497 let desc = make_http_test_descriptor(&server.url());
1498 let client = ConfigurableExchangeClient::new(desc);
1499 let ticker = client.fetch_ticker("BTCUSDT").await.unwrap();
1500 assert_eq!(ticker.pair, "BTC/USDT");
1501 assert_eq!(ticker.last_price, Some(50100.5));
1502 assert_eq!(ticker.high_24h, Some(51200.0));
1503 assert_eq!(ticker.low_24h, Some(48800.0));
1504 assert_eq!(ticker.volume_24h, Some(1234.56));
1505 assert_eq!(ticker.quote_volume_24h, Some(62000000.0));
1506 assert_eq!(ticker.best_bid, Some(50095.0));
1507 assert_eq!(ticker.best_ask, Some(50105.0));
1508 mock.assert_async().await;
1509 }
1510
1511 #[tokio::test]
1512 async fn test_fetch_recent_trades_via_http() {
1513 let mut server = mockito::Server::new_async().await;
1514 let mock = server
1515 .mock("GET", "/api/v1/trades")
1516 .match_query(mockito::Matcher::AllOf(vec![
1517 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
1518 mockito::Matcher::UrlEncoded("limit".into(), "10".into()),
1519 ]))
1520 .with_status(200)
1521 .with_body(
1522 serde_json::json!([
1523 {
1524 "id": "trade-1",
1525 "price": "50000.0",
1526 "qty": "0.5",
1527 "quoteQty": "25000.0",
1528 "time": "1700000000000",
1529 "isBuyerMaker": true
1530 },
1531 {
1532 "id": "trade-2",
1533 "price": "50001.0",
1534 "qty": "1.0",
1535 "quoteQty": "50001.0",
1536 "time": "1700000001000",
1537 "isBuyerMaker": false
1538 }
1539 ])
1540 .to_string(),
1541 )
1542 .create_async()
1543 .await;
1544
1545 let desc = make_http_test_descriptor(&server.url());
1546 let client = ConfigurableExchangeClient::new(desc);
1547 let trades = client.fetch_recent_trades("BTCUSDT", 10).await.unwrap();
1548 assert_eq!(trades.len(), 2);
1549 assert_eq!(trades[0].price, 50000.0);
1550 assert_eq!(trades[0].quantity, 0.5);
1551 assert_eq!(trades[0].quote_quantity, Some(25000.0));
1552 assert_eq!(trades[0].timestamp_ms, 1700000000000);
1553 assert_eq!(trades[0].id.as_deref(), Some("trade-1"));
1554 assert_eq!(trades[0].side, TradeSide::Sell);
1555 assert_eq!(trades[1].price, 50001.0);
1556 assert_eq!(trades[1].quantity, 1.0);
1557 assert_eq!(trades[1].side, TradeSide::Buy);
1558 mock.assert_async().await;
1559 }
1560
1561 #[tokio::test]
1562 async fn test_fetch_order_book_http_error() {
1563 let mut server = mockito::Server::new_async().await;
1564 let mock = server
1565 .mock("GET", "/api/v1/depth")
1566 .match_query(mockito::Matcher::UrlEncoded(
1567 "symbol".into(),
1568 "BTCUSDT".into(),
1569 ))
1570 .with_status(500)
1571 .with_body("Internal Server Error")
1572 .create_async()
1573 .await;
1574
1575 let desc = make_http_test_descriptor(&server.url());
1576 let client = ConfigurableExchangeClient::new(desc);
1577 let err = client.fetch_order_book("BTCUSDT").await.unwrap_err();
1578 let err_msg = err.to_string();
1579 assert!(
1580 err_msg.contains("API error: HTTP 500"),
1581 "expected error message to contain 'API error: HTTP 500', got: {}",
1582 err_msg
1583 );
1584 mock.assert_async().await;
1585 }
1586
1587 #[tokio::test]
1588 async fn test_fetch_order_book_no_capability() {
1589 let desc = make_test_descriptor();
1590 let client = ConfigurableExchangeClient::new(desc);
1591 let err = client.fetch_order_book("BTCUSDT").await.unwrap_err();
1592 let err_msg = err.to_string();
1593 assert!(
1594 err_msg.contains("does not support order book"),
1595 "expected error message to contain 'does not support order book', got: {}",
1596 err_msg
1597 );
1598 }
1599
1600 #[tokio::test]
1601 async fn test_fetch_order_book_via_post() {
1602 use crate::market::descriptor::*;
1603 let mut server = mockito::Server::new_async().await;
1604 let mock = server
1605 .mock("POST", "/api/v1/depth")
1606 .match_body(mockito::Matcher::Json(serde_json::json!({
1607 "symbol": "BTCUSDT"
1608 })))
1609 .with_status(200)
1610 .with_body(
1611 serde_json::json!({
1612 "asks": [["50100.0", "2.0"]],
1613 "bids": [["50000.0", "1.0"]]
1614 })
1615 .to_string(),
1616 )
1617 .create_async()
1618 .await;
1619
1620 let mut desc = make_http_test_descriptor(&server.url());
1621 desc.capabilities.order_book = Some(EndpointDescriptor {
1622 path: "/api/v1/depth".to_string(),
1623 method: HttpMethod::POST,
1624 params: std::collections::HashMap::new(),
1625 request_body: Some(serde_json::json!({
1626 "symbol": "{pair}"
1627 })),
1628 response_root: None,
1629 interval_map: std::collections::HashMap::new(),
1630 response: ResponseMapping {
1631 asks_key: Some("asks".to_string()),
1632 bids_key: Some("bids".to_string()),
1633 level_format: Some("positional".to_string()),
1634 ..Default::default()
1635 },
1636 });
1637
1638 let client = ConfigurableExchangeClient::new(desc);
1639 let book = client.fetch_order_book("BTCUSDT").await.unwrap();
1640 assert_eq!(book.asks.len(), 1);
1641 assert_eq!(book.bids.len(), 1);
1642 assert_eq!(book.asks[0].price, 50100.0);
1643 assert_eq!(book.bids[0].price, 50000.0);
1644 mock.assert_async().await;
1645 }
1646
1647 #[tokio::test]
1648 async fn test_fetch_order_book_missing_asks_key() {
1649 let mut server = mockito::Server::new_async().await;
1650 let mock = server
1651 .mock("GET", "/api/v1/depth")
1652 .match_query(mockito::Matcher::UrlEncoded(
1653 "symbol".into(),
1654 "BTCUSDT".into(),
1655 ))
1656 .with_status(200)
1657 .with_body(
1658 serde_json::json!({
1659 "bids": [["50000.0", "1.0"]]
1660 })
1661 .to_string(),
1662 )
1663 .create_async()
1664 .await;
1665
1666 let desc = make_http_test_descriptor(&server.url());
1667 let client = ConfigurableExchangeClient::new(desc);
1668 let err = client.fetch_order_book("BTCUSDT").await.unwrap_err();
1669 let err_msg = err.to_string();
1670 assert!(
1671 err_msg.contains("missing 'asks'"),
1672 "expected error message to contain 'missing \\'asks\\'', got: {}",
1673 err_msg
1674 );
1675 mock.assert_async().await;
1676 }
1677
1678 #[tokio::test]
1679 async fn test_fetch_order_book_missing_bids_key() {
1680 let mut server = mockito::Server::new_async().await;
1681 let mock = server
1682 .mock("GET", "/api/v1/depth")
1683 .match_query(mockito::Matcher::UrlEncoded(
1684 "symbol".into(),
1685 "BTCUSDT".into(),
1686 ))
1687 .with_status(200)
1688 .with_body(
1689 serde_json::json!({
1690 "asks": [["50010.0", "1.5"]]
1691 })
1692 .to_string(),
1693 )
1694 .create_async()
1695 .await;
1696
1697 let desc = make_http_test_descriptor(&server.url());
1698 let client = ConfigurableExchangeClient::new(desc);
1699 let err = client.fetch_order_book("BTCUSDT").await.unwrap_err();
1700 let err_msg = err.to_string();
1701 assert!(
1702 err_msg.contains("missing 'bids'"),
1703 "expected error message to contain 'missing \\'bids\\'', got: {}",
1704 err_msg
1705 );
1706 mock.assert_async().await;
1707 }
1708
1709 #[tokio::test]
1710 async fn test_fetch_ticker_with_filter() {
1711 use crate::market::descriptor::*;
1712 let mut server = mockito::Server::new_async().await;
1713 let mock = server
1714 .mock("GET", "/api/v1/tickers")
1715 .match_query(mockito::Matcher::UrlEncoded(
1716 "symbol".into(),
1717 "BTCUSDT".into(),
1718 ))
1719 .with_status(200)
1720 .with_body(
1721 serde_json::json!([
1722 {"symbol": "ETHUSDT", "lastPrice": "3000.0"},
1723 {"symbol": "BTCUSDT", "lastPrice": "50100.5", "highPrice": "51200.0"},
1724 {"symbol": "BNBUSDT", "lastPrice": "400.0"}
1725 ])
1726 .to_string(),
1727 )
1728 .create_async()
1729 .await;
1730
1731 let mut desc = make_http_test_descriptor(&server.url());
1732 desc.capabilities.ticker = Some(EndpointDescriptor {
1733 path: "/api/v1/tickers".to_string(),
1734 method: HttpMethod::GET,
1735 params: [("symbol".to_string(), "{pair}".to_string())]
1736 .into_iter()
1737 .collect(),
1738 request_body: None,
1739 response_root: None,
1740 interval_map: std::collections::HashMap::new(),
1741 response: ResponseMapping {
1742 filter: Some(FilterConfig {
1743 field: "symbol".to_string(),
1744 value: "{pair}".to_string(),
1745 }),
1746 last_price: Some("lastPrice".to_string()),
1747 high_24h: Some("highPrice".to_string()),
1748 ..Default::default()
1749 },
1750 });
1751
1752 let client = ConfigurableExchangeClient::new(desc);
1753 let ticker = client.fetch_ticker("BTCUSDT").await.unwrap();
1754 assert_eq!(ticker.pair, "BTC/USDT");
1755 assert_eq!(ticker.last_price, Some(50100.5));
1756 assert_eq!(ticker.high_24h, Some(51200.0));
1757 mock.assert_async().await;
1758 }
1759
1760 #[tokio::test]
1761 async fn test_fetch_ticker_filter_no_match() {
1762 use crate::market::descriptor::*;
1763 let mut server = mockito::Server::new_async().await;
1764 let mock = server
1765 .mock("GET", "/api/v1/tickers")
1766 .match_query(mockito::Matcher::UrlEncoded(
1767 "symbol".into(),
1768 "BTCUSDT".into(),
1769 ))
1770 .with_status(200)
1771 .with_body(
1772 serde_json::json!([
1773 {"symbol": "ETHUSDT", "lastPrice": "3000.0"},
1774 {"symbol": "BNBUSDT", "lastPrice": "400.0"}
1775 ])
1776 .to_string(),
1777 )
1778 .create_async()
1779 .await;
1780
1781 let mut desc = make_http_test_descriptor(&server.url());
1782 desc.capabilities.ticker = Some(EndpointDescriptor {
1783 path: "/api/v1/tickers".to_string(),
1784 method: HttpMethod::GET,
1785 params: [("symbol".to_string(), "{pair}".to_string())]
1786 .into_iter()
1787 .collect(),
1788 request_body: None,
1789 response_root: None,
1790 interval_map: std::collections::HashMap::new(),
1791 response: ResponseMapping {
1792 filter: Some(FilterConfig {
1793 field: "symbol".to_string(),
1794 value: "{pair}".to_string(),
1795 }),
1796 last_price: Some("lastPrice".to_string()),
1797 ..Default::default()
1798 },
1799 });
1800
1801 let client = ConfigurableExchangeClient::new(desc);
1802 let err = client.fetch_ticker("BTCUSDT").await.unwrap_err();
1803 let err_msg = err.to_string();
1804 assert!(
1805 err_msg.contains("no ticker found for pair"),
1806 "expected error message to contain 'no ticker found for pair', got: {}",
1807 err_msg
1808 );
1809 mock.assert_async().await;
1810 }
1811
1812 #[tokio::test]
1813 async fn test_fetch_trades_non_array_response() {
1814 let mut server = mockito::Server::new_async().await;
1815 let mock = server
1816 .mock("GET", "/api/v1/trades")
1817 .match_query(mockito::Matcher::AllOf(vec![
1818 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
1819 mockito::Matcher::UrlEncoded("limit".into(), "10".into()),
1820 ]))
1821 .with_status(200)
1822 .with_body(
1823 serde_json::json!({
1824 "trades": [{"price": "50000", "qty": "1"}]
1825 })
1826 .to_string(),
1827 )
1828 .create_async()
1829 .await;
1830
1831 let desc = make_http_test_descriptor(&server.url());
1832 let client = ConfigurableExchangeClient::new(desc);
1833 let err = client.fetch_recent_trades("BTCUSDT", 10).await.unwrap_err();
1834 let err_msg = err.to_string();
1835 assert!(
1836 err_msg.contains("expected array for trades"),
1837 "expected error message to contain 'expected array for trades', got: {}",
1838 err_msg
1839 );
1840 mock.assert_async().await;
1841 }
1842
1843 #[tokio::test]
1844 async fn test_fetch_with_custom_headers() {
1845 let mut server = mockito::Server::new_async().await;
1846 let mut headers = std::collections::HashMap::new();
1847 headers.insert("X-Api-Key".to_string(), "test123".to_string());
1848 let mock = server
1849 .mock("GET", "/api/v1/ticker")
1850 .match_header("x-api-key", "test123")
1851 .match_query(mockito::Matcher::UrlEncoded(
1852 "symbol".into(),
1853 "BTCUSDT".into(),
1854 ))
1855 .with_status(200)
1856 .with_body(
1857 serde_json::json!({
1858 "lastPrice": "50100.5",
1859 "highPrice": "51200.0",
1860 "lowPrice": "48800.0",
1861 "volume": "1234.56",
1862 "quoteVolume": "62000000.0",
1863 "bidPrice": "50095.0",
1864 "askPrice": "50105.0"
1865 })
1866 .to_string(),
1867 )
1868 .create_async()
1869 .await;
1870
1871 let mut desc = make_http_test_descriptor(&server.url());
1872 desc.headers = headers;
1873 desc.capabilities.ticker.as_mut().unwrap().response = ResponseMapping {
1874 last_price: Some("lastPrice".to_string()),
1875 high_24h: Some("highPrice".to_string()),
1876 low_24h: Some("lowPrice".to_string()),
1877 volume_24h: Some("volume".to_string()),
1878 quote_volume_24h: Some("quoteVolume".to_string()),
1879 best_bid: Some("bidPrice".to_string()),
1880 best_ask: Some("askPrice".to_string()),
1881 ..Default::default()
1882 };
1883
1884 let client = ConfigurableExchangeClient::new(desc);
1885 let ticker = client.fetch_ticker("BTCUSDT").await.unwrap();
1886 assert_eq!(ticker.last_price, Some(50100.5));
1887 mock.assert_async().await;
1888 }
1889
1890 #[tokio::test]
1891 async fn test_fetch_trades_no_capability() {
1892 let desc = make_test_descriptor();
1893 let client = ConfigurableExchangeClient::new(desc);
1894 let err = client.fetch_recent_trades("BTCUSDT", 10).await.unwrap_err();
1895 let err_msg = err.to_string();
1896 assert!(
1897 err_msg.contains("does not support trades"),
1898 "expected error message to contain 'does not support trades', got: {}",
1899 err_msg
1900 );
1901 }
1902
1903 #[test]
1904 fn test_navigate_root_index_out_of_bounds() {
1905 let desc = make_test_descriptor();
1906 let client = ConfigurableExchangeClient::new(desc);
1907 let json = serde_json::json!({"data": [1, 2]});
1908 let result = client.navigate_root(&json, Some("data.5"));
1909 assert!(result.is_err());
1910 assert!(result.unwrap_err().to_string().contains("out of bounds"));
1911 }
1912
1913 #[test]
1914 fn test_navigate_root_missing_key() {
1915 let desc = make_test_descriptor();
1916 let client = ConfigurableExchangeClient::new(desc);
1917 let json = serde_json::json!({"data": {"nested": 1}});
1918 let result = client.navigate_root(&json, Some("data.missing_key"));
1919 assert!(result.is_err());
1920 assert!(result.unwrap_err().to_string().contains("missing key"));
1921 }
1922
1923 #[test]
1924 fn test_interpolate_json_array() {
1925 let desc = make_test_descriptor();
1926 let client = ConfigurableExchangeClient::new(desc);
1927 let template = serde_json::json!(["{pair}", "{limit}", 42]);
1928 let result = client.interpolate_json(&template, "BTC_USDT", "100");
1929 assert_eq!(result, serde_json::json!(["BTC_USDT", "100", 42]));
1930 }
1931
1932 #[test]
1933 fn test_interpolate_json_passthrough() {
1934 let desc = make_test_descriptor();
1935 let client = ConfigurableExchangeClient::new(desc);
1936 assert_eq!(
1937 client.interpolate_json(&serde_json::json!(42), "BTC", "100"),
1938 serde_json::json!(42)
1939 );
1940 assert_eq!(
1941 client.interpolate_json(&serde_json::json!(true), "BTC", "100"),
1942 serde_json::json!(true)
1943 );
1944 assert_eq!(
1945 client.interpolate_json(&serde_json::json!(null), "BTC", "100"),
1946 serde_json::json!(null)
1947 );
1948 }
1949
1950 fn make_ohlc_test_descriptor(base_url: &str) -> VenueDescriptor {
1955 use crate::market::descriptor::*;
1956 VenueDescriptor {
1957 id: "ohlc_mock".to_string(),
1958 name: "OHLC Mock".to_string(),
1959 base_url: base_url.to_string(),
1960 timeout_secs: Some(5),
1961 rate_limit_per_sec: None,
1962 symbol: SymbolConfig {
1963 template: "{base}{quote}".to_string(),
1964 default_quote: "USDT".to_string(),
1965 case: SymbolCase::Upper,
1966 },
1967 headers: std::collections::HashMap::new(),
1968 capabilities: CapabilitySet {
1969 order_book: None,
1970 ticker: None,
1971 trades: None,
1972 ohlc: Some(EndpointDescriptor {
1973 path: "/api/v1/klines".to_string(),
1974 method: HttpMethod::GET,
1975 params: [
1976 ("symbol".to_string(), "{pair}".to_string()),
1977 ("interval".to_string(), "{interval}".to_string()),
1978 ("limit".to_string(), "{limit}".to_string()),
1979 ]
1980 .into_iter()
1981 .collect(),
1982 request_body: None,
1983 response_root: None,
1984 interval_map: std::collections::HashMap::new(),
1985 response: ResponseMapping {
1986 ohlc_format: Some("array_of_arrays".to_string()),
1987 ohlc_fields: Some(vec![
1988 "open_time".to_string(),
1989 "open".to_string(),
1990 "high".to_string(),
1991 "low".to_string(),
1992 "close".to_string(),
1993 "volume".to_string(),
1994 "close_time".to_string(),
1995 ]),
1996 ..Default::default()
1997 },
1998 }),
1999 },
2000 }
2001 }
2002
2003 #[tokio::test]
2004 async fn test_fetch_ohlc_array_of_arrays() {
2005 let mut server = mockito::Server::new_async().await;
2006 let mock = server
2007 .mock("GET", "/api/v1/klines")
2008 .match_query(mockito::Matcher::AllOf(vec![
2009 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2010 mockito::Matcher::UrlEncoded("interval".into(), "1h".into()),
2011 mockito::Matcher::UrlEncoded("limit".into(), "3".into()),
2012 ]))
2013 .with_status(200)
2014 .with_body(
2015 serde_json::json!([
2016 [
2017 1700000000000u64,
2018 "50000.0",
2019 "50500.0",
2020 "49800.0",
2021 "50200.0",
2022 "100.5",
2023 1700003599999u64
2024 ],
2025 [
2026 1700003600000u64,
2027 "50200.0",
2028 "50800.0",
2029 "50100.0",
2030 "50700.0",
2031 "120.3",
2032 1700007199999u64
2033 ],
2034 [
2035 1700007200000u64,
2036 "50700.0",
2037 "51000.0",
2038 "50600.0",
2039 "50900.0",
2040 "95.7",
2041 1700010799999u64
2042 ]
2043 ])
2044 .to_string(),
2045 )
2046 .create_async()
2047 .await;
2048
2049 let desc = make_ohlc_test_descriptor(&server.url());
2050 let client = ConfigurableExchangeClient::new(desc);
2051 let candles = client.fetch_ohlc("BTCUSDT", "1h", 3).await.unwrap();
2052
2053 assert_eq!(candles.len(), 3);
2054 assert_eq!(candles[0].open_time, 1700000000000);
2055 assert_eq!(candles[0].open, 50000.0);
2056 assert_eq!(candles[0].high, 50500.0);
2057 assert_eq!(candles[0].low, 49800.0);
2058 assert_eq!(candles[0].close, 50200.0);
2059 assert_eq!(candles[0].volume, 100.5);
2060 assert_eq!(candles[0].close_time, 1700003599999);
2061 assert_eq!(candles[2].open, 50700.0);
2062 mock.assert_async().await;
2063 }
2064
2065 #[tokio::test]
2066 async fn test_fetch_ohlc_object_format() {
2067 use crate::market::descriptor::*;
2068 let mut server = mockito::Server::new_async().await;
2069 let mock = server
2070 .mock("GET", "/api/v1/candles")
2071 .match_query(mockito::Matcher::AllOf(vec![
2072 mockito::Matcher::UrlEncoded("symbol".into(), "ETHUSDT".into()),
2073 mockito::Matcher::UrlEncoded("interval".into(), "15m".into()),
2074 mockito::Matcher::UrlEncoded("limit".into(), "2".into()),
2075 ]))
2076 .with_status(200)
2077 .with_body(
2078 serde_json::json!([
2079 {
2080 "ts": 1700000000000u64,
2081 "o": "3000.0",
2082 "h": "3050.0",
2083 "l": "2980.0",
2084 "c": "3020.0",
2085 "vol": "500.0",
2086 "ct": 1700000899999u64
2087 },
2088 {
2089 "ts": 1700000900000u64,
2090 "o": "3020.0",
2091 "h": "3080.0",
2092 "l": "3010.0",
2093 "c": "3060.0",
2094 "vol": "420.0",
2095 "ct": 1700001799999u64
2096 }
2097 ])
2098 .to_string(),
2099 )
2100 .create_async()
2101 .await;
2102
2103 let desc = VenueDescriptor {
2104 id: "ohlc_obj_mock".to_string(),
2105 name: "OHLC Obj Mock".to_string(),
2106 base_url: server.url(),
2107 timeout_secs: Some(5),
2108 rate_limit_per_sec: None,
2109 symbol: SymbolConfig {
2110 template: "{base}{quote}".to_string(),
2111 default_quote: "USDT".to_string(),
2112 case: SymbolCase::Upper,
2113 },
2114 headers: std::collections::HashMap::new(),
2115 capabilities: CapabilitySet {
2116 order_book: None,
2117 ticker: None,
2118 trades: None,
2119 ohlc: Some(EndpointDescriptor {
2120 path: "/api/v1/candles".to_string(),
2121 method: HttpMethod::GET,
2122 params: [
2123 ("symbol".to_string(), "{pair}".to_string()),
2124 ("interval".to_string(), "{interval}".to_string()),
2125 ("limit".to_string(), "{limit}".to_string()),
2126 ]
2127 .into_iter()
2128 .collect(),
2129 request_body: None,
2130 response_root: None,
2131 interval_map: std::collections::HashMap::new(),
2132 response: ResponseMapping {
2133 ohlc_format: Some("objects".to_string()),
2134 open_time: Some("ts".to_string()),
2135 open: Some("o".to_string()),
2136 high: Some("h".to_string()),
2137 low: Some("l".to_string()),
2138 close: Some("c".to_string()),
2139 ohlc_volume: Some("vol".to_string()),
2140 close_time: Some("ct".to_string()),
2141 ..Default::default()
2142 },
2143 }),
2144 },
2145 };
2146
2147 let client = ConfigurableExchangeClient::new(desc);
2148 let candles = client.fetch_ohlc("ETHUSDT", "15m", 2).await.unwrap();
2149
2150 assert_eq!(candles.len(), 2);
2151 assert_eq!(candles[0].open_time, 1700000000000);
2152 assert_eq!(candles[0].open, 3000.0);
2153 assert_eq!(candles[0].high, 3050.0);
2154 assert_eq!(candles[0].low, 2980.0);
2155 assert_eq!(candles[0].close, 3020.0);
2156 assert_eq!(candles[0].volume, 500.0);
2157 assert_eq!(candles[1].close, 3060.0);
2158 mock.assert_async().await;
2159 }
2160
2161 #[tokio::test]
2162 async fn test_fetch_ohlc_no_capability() {
2163 let desc = make_test_descriptor();
2164 let client = ConfigurableExchangeClient::new(desc);
2165 let err = client.fetch_ohlc("BTCUSDT", "1h", 100).await.unwrap_err();
2166 let msg = err.to_string();
2167 assert!(
2168 msg.contains("does not support OHLC"),
2169 "expected OHLC error, got: {}",
2170 msg
2171 );
2172 }
2173
2174 #[tokio::test]
2177 async fn test_fetch_ohlc_interval_map() {
2178 use crate::market::descriptor::*;
2179 let mut server = mockito::Server::new_async().await;
2180 let mock = server
2182 .mock("GET", "/api/v1/kline")
2183 .match_query(mockito::Matcher::AllOf(vec![
2184 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2185 mockito::Matcher::UrlEncoded("type".into(), "hour".into()),
2186 mockito::Matcher::UrlEncoded("size".into(), "2".into()),
2187 ]))
2188 .with_status(200)
2189 .with_body(
2190 serde_json::json!([
2191 [
2192 1700000000000u64,
2193 "50000.0",
2194 "50500.0",
2195 "49800.0",
2196 "50200.0",
2197 "100.5"
2198 ],
2199 [
2200 1700003600000u64,
2201 "50200.0",
2202 "50800.0",
2203 "50100.0",
2204 "50700.0",
2205 "120.3"
2206 ]
2207 ])
2208 .to_string(),
2209 )
2210 .create_async()
2211 .await;
2212
2213 let desc = VenueDescriptor {
2214 id: "interval_map_test".to_string(),
2215 name: "Interval Map Test".to_string(),
2216 base_url: server.url(),
2217 timeout_secs: Some(5),
2218 rate_limit_per_sec: None,
2219 symbol: SymbolConfig {
2220 template: "{base}{quote}".to_string(),
2221 default_quote: "USDT".to_string(),
2222 case: SymbolCase::Upper,
2223 },
2224 headers: std::collections::HashMap::new(),
2225 capabilities: CapabilitySet {
2226 order_book: None,
2227 ticker: None,
2228 trades: None,
2229 ohlc: Some(EndpointDescriptor {
2230 path: "/api/v1/kline".to_string(),
2231 method: HttpMethod::GET,
2232 params: [
2233 ("symbol".to_string(), "{pair}".to_string()),
2234 ("type".to_string(), "{interval}".to_string()),
2235 ("size".to_string(), "{limit}".to_string()),
2236 ]
2237 .into_iter()
2238 .collect(),
2239 request_body: None,
2240 response_root: None,
2241 interval_map: [
2242 ("1m".to_string(), "1min".to_string()),
2243 ("5m".to_string(), "5min".to_string()),
2244 ("1h".to_string(), "hour".to_string()),
2245 ("1d".to_string(), "day".to_string()),
2246 ]
2247 .into_iter()
2248 .collect(),
2249 response: ResponseMapping {
2250 ohlc_format: Some("array_of_arrays".to_string()),
2251 ohlc_fields: Some(vec![
2252 "open_time".to_string(),
2253 "open".to_string(),
2254 "high".to_string(),
2255 "low".to_string(),
2256 "close".to_string(),
2257 "volume".to_string(),
2258 ]),
2259 ..Default::default()
2260 },
2261 }),
2262 },
2263 };
2264
2265 let client = ConfigurableExchangeClient::new(desc);
2266 let candles = client.fetch_ohlc("BTCUSDT", "1h", 2).await.unwrap();
2267 assert_eq!(candles.len(), 2);
2268 assert_eq!(candles[0].open, 50000.0);
2269 assert_eq!(candles[1].close, 50700.0);
2270 mock.assert_async().await;
2271 }
2272
2273 #[tokio::test]
2275 async fn test_fetch_ohlc_interval_map_passthrough() {
2276 use crate::market::descriptor::*;
2277 let mut server = mockito::Server::new_async().await;
2278 let mock = server
2280 .mock("GET", "/api/v1/kline")
2281 .match_query(mockito::Matcher::AllOf(vec![
2282 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2283 mockito::Matcher::UrlEncoded("type".into(), "15m".into()),
2284 mockito::Matcher::UrlEncoded("size".into(), "1".into()),
2285 ]))
2286 .with_status(200)
2287 .with_body(
2288 serde_json::json!([[
2289 1700000000000u64,
2290 "50000.0",
2291 "50500.0",
2292 "49800.0",
2293 "50200.0",
2294 "100.5"
2295 ]])
2296 .to_string(),
2297 )
2298 .create_async()
2299 .await;
2300
2301 let desc = VenueDescriptor {
2302 id: "passthrough_test".to_string(),
2303 name: "Passthrough Test".to_string(),
2304 base_url: server.url(),
2305 timeout_secs: Some(5),
2306 rate_limit_per_sec: None,
2307 symbol: SymbolConfig {
2308 template: "{base}{quote}".to_string(),
2309 default_quote: "USDT".to_string(),
2310 case: SymbolCase::Upper,
2311 },
2312 headers: std::collections::HashMap::new(),
2313 capabilities: CapabilitySet {
2314 order_book: None,
2315 ticker: None,
2316 trades: None,
2317 ohlc: Some(EndpointDescriptor {
2318 path: "/api/v1/kline".to_string(),
2319 method: HttpMethod::GET,
2320 params: [
2321 ("symbol".to_string(), "{pair}".to_string()),
2322 ("type".to_string(), "{interval}".to_string()),
2323 ("size".to_string(), "{limit}".to_string()),
2324 ]
2325 .into_iter()
2326 .collect(),
2327 request_body: None,
2328 response_root: None,
2329 interval_map: [("1h".to_string(), "hour".to_string())]
2331 .into_iter()
2332 .collect(),
2333 response: ResponseMapping {
2334 ohlc_format: Some("array_of_arrays".to_string()),
2335 ohlc_fields: Some(vec![
2336 "open_time".to_string(),
2337 "open".to_string(),
2338 "high".to_string(),
2339 "low".to_string(),
2340 "close".to_string(),
2341 "volume".to_string(),
2342 ]),
2343 ..Default::default()
2344 },
2345 }),
2346 },
2347 };
2348
2349 let client = ConfigurableExchangeClient::new(desc);
2350 let candles = client.fetch_ohlc("BTCUSDT", "15m", 1).await.unwrap();
2351 assert_eq!(candles.len(), 1);
2352 mock.assert_async().await;
2353 }
2354
2355 #[tokio::test]
2356 async fn test_fetch_ohlc_non_array_response() {
2357 let mut server = mockito::Server::new_async().await;
2358 let mock = server
2359 .mock("GET", "/api/v1/klines")
2360 .match_query(mockito::Matcher::AllOf(vec![
2361 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2362 mockito::Matcher::UrlEncoded("interval".into(), "1h".into()),
2363 mockito::Matcher::UrlEncoded("limit".into(), "100".into()),
2364 ]))
2365 .with_status(200)
2366 .with_body(serde_json::json!({"error": "not an array"}).to_string())
2367 .create_async()
2368 .await;
2369
2370 let desc = make_ohlc_test_descriptor(&server.url());
2371 let client = ConfigurableExchangeClient::new(desc);
2372 let err = client.fetch_ohlc("BTCUSDT", "1h", 100).await.unwrap_err();
2373 let msg = err.to_string();
2374 assert!(
2375 msg.contains("expected array for OHLC"),
2376 "expected array error, got: {}",
2377 msg
2378 );
2379 mock.assert_async().await;
2380 }
2381
2382 #[tokio::test]
2383 async fn test_fetch_ohlc_empty_array() {
2384 let mut server = mockito::Server::new_async().await;
2385 let mock = server
2386 .mock("GET", "/api/v1/klines")
2387 .match_query(mockito::Matcher::AllOf(vec![
2388 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2389 mockito::Matcher::UrlEncoded("interval".into(), "1d".into()),
2390 mockito::Matcher::UrlEncoded("limit".into(), "10".into()),
2391 ]))
2392 .with_status(200)
2393 .with_body("[]")
2394 .create_async()
2395 .await;
2396
2397 let desc = make_ohlc_test_descriptor(&server.url());
2398 let client = ConfigurableExchangeClient::new(desc);
2399 let candles = client.fetch_ohlc("BTCUSDT", "1d", 10).await.unwrap();
2400 assert!(candles.is_empty());
2401 mock.assert_async().await;
2402 }
2403
2404 #[tokio::test]
2405 async fn test_fetch_ohlc_skips_malformed_inner_items() {
2406 let mut server = mockito::Server::new_async().await;
2407 let mock = server
2408 .mock("GET", "/api/v1/klines")
2409 .match_query(mockito::Matcher::AllOf(vec![
2410 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2411 mockito::Matcher::UrlEncoded("interval".into(), "1h".into()),
2412 mockito::Matcher::UrlEncoded("limit".into(), "5".into()),
2413 ]))
2414 .with_status(200)
2415 .with_body(
2416 serde_json::json!([
2417 "not an array",
2418 [
2419 1700000000000u64,
2420 "50000.0",
2421 "50500.0",
2422 "49800.0",
2423 "50200.0",
2424 "100.5",
2425 1700003599999u64
2426 ],
2427 42
2428 ])
2429 .to_string(),
2430 )
2431 .create_async()
2432 .await;
2433
2434 let desc = make_ohlc_test_descriptor(&server.url());
2435 let client = ConfigurableExchangeClient::new(desc);
2436 let candles = client.fetch_ohlc("BTCUSDT", "1h", 5).await.unwrap();
2437 assert_eq!(candles.len(), 1);
2439 assert_eq!(candles[0].open, 50000.0);
2440 mock.assert_async().await;
2441 }
2442
2443 #[tokio::test]
2444 async fn test_fetch_ohlc_with_response_root() {
2445 let mut server = mockito::Server::new_async().await;
2446 let mock = server
2447 .mock("GET", "/api/v1/klines")
2448 .match_query(mockito::Matcher::AllOf(vec![
2449 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2450 mockito::Matcher::UrlEncoded("interval".into(), "4h".into()),
2451 mockito::Matcher::UrlEncoded("limit".into(), "2".into()),
2452 ]))
2453 .with_status(200)
2454 .with_body(
2455 serde_json::json!({
2456 "result": [
2457 [1700000000000u64, "50000.0", "50500.0", "49800.0", "50200.0", "100.5", 1700003599999u64],
2458 [1700003600000u64, "50200.0", "50800.0", "50100.0", "50700.0", "120.3", 1700007199999u64]
2459 ]
2460 })
2461 .to_string(),
2462 )
2463 .create_async()
2464 .await;
2465
2466 let mut desc = make_ohlc_test_descriptor(&server.url());
2467 desc.capabilities.ohlc.as_mut().unwrap().response_root = Some("result".to_string());
2468
2469 let client = ConfigurableExchangeClient::new(desc);
2470 let candles = client.fetch_ohlc("BTCUSDT", "4h", 2).await.unwrap();
2471 assert_eq!(candles.len(), 2);
2472 mock.assert_async().await;
2473 }
2474
2475 #[test]
2476 fn test_interpolate_value_full_with_interval() {
2477 let desc = make_test_descriptor();
2478 let client = ConfigurableExchangeClient::new(desc);
2479 let result =
2480 client.interpolate_value_full("{pair}_{interval}_{limit}", "BTCUSDT", "50", "1h");
2481 assert_eq!(result, "BTCUSDT_1h_50");
2482 }
2483
2484 #[test]
2485 fn test_interpolate_json_full_with_interval() {
2486 let desc = make_test_descriptor();
2487 let client = ConfigurableExchangeClient::new(desc);
2488 let template = serde_json::json!({
2489 "symbol": "{pair}",
2490 "interval": "{interval}",
2491 "limit": "{limit}"
2492 });
2493 let result = client.interpolate_json_full(&template, "ETHUSDT", "100", "15m");
2494 assert_eq!(result["symbol"], "ETHUSDT");
2495 assert_eq!(result["interval"], "15m");
2496 assert_eq!(result["limit"], "100");
2497 }
2498
2499 #[tokio::test]
2500 async fn test_fetch_ohlc_via_post_method() {
2501 use crate::market::descriptor::*;
2502 let mut server = mockito::Server::new_async().await;
2503 let mock = server
2504 .mock("POST", "/api/v1/klines")
2505 .with_status(200)
2506 .with_body(
2507 serde_json::json!([[
2508 1700000000000u64,
2509 "50000.0",
2510 "50500.0",
2511 "49800.0",
2512 "50200.0",
2513 "100.5",
2514 1700003599999u64
2515 ]])
2516 .to_string(),
2517 )
2518 .create_async()
2519 .await;
2520
2521 let desc = VenueDescriptor {
2522 id: "post_ohlc".to_string(),
2523 name: "POST OHLC".to_string(),
2524 base_url: server.url(),
2525 timeout_secs: Some(5),
2526 rate_limit_per_sec: None,
2527 symbol: SymbolConfig {
2528 template: "{base}{quote}".to_string(),
2529 default_quote: "USDT".to_string(),
2530 case: SymbolCase::Upper,
2531 },
2532 headers: std::collections::HashMap::new(),
2533 capabilities: CapabilitySet {
2534 order_book: None,
2535 ticker: None,
2536 trades: None,
2537 ohlc: Some(EndpointDescriptor {
2538 path: "/api/v1/klines".to_string(),
2539 method: HttpMethod::POST,
2540 params: std::collections::HashMap::new(),
2541 request_body: Some(serde_json::json!({
2542 "symbol": "{pair}",
2543 "interval": "{interval}",
2544 "limit": "{limit}"
2545 })),
2546 response_root: None,
2547 interval_map: std::collections::HashMap::new(),
2548 response: ResponseMapping {
2549 ohlc_format: Some("array_of_arrays".to_string()),
2550 ohlc_fields: Some(vec![
2551 "open_time".to_string(),
2552 "open".to_string(),
2553 "high".to_string(),
2554 "low".to_string(),
2555 "close".to_string(),
2556 "volume".to_string(),
2557 "close_time".to_string(),
2558 ]),
2559 ..Default::default()
2560 },
2561 }),
2562 },
2563 };
2564
2565 let client = ConfigurableExchangeClient::new(desc);
2566 let candles = client.fetch_ohlc("BTCUSDT", "1h", 1).await.unwrap();
2567 assert_eq!(candles.len(), 1);
2568 assert_eq!(candles[0].open, 50000.0);
2569 mock.assert_async().await;
2570 }
2571
2572 #[tokio::test]
2573 async fn test_fetch_ohlc_post_http_error() {
2574 use crate::market::descriptor::*;
2575 let mut server = mockito::Server::new_async().await;
2576 let mock = server
2577 .mock("POST", "/api/v1/klines")
2578 .with_status(500)
2579 .with_body("Internal Server Error")
2580 .create_async()
2581 .await;
2582
2583 let desc = VenueDescriptor {
2584 id: "post_ohlc_err".to_string(),
2585 name: "POST OHLC Err".to_string(),
2586 base_url: server.url(),
2587 timeout_secs: Some(5),
2588 rate_limit_per_sec: None,
2589 symbol: SymbolConfig {
2590 template: "{base}{quote}".to_string(),
2591 default_quote: "USDT".to_string(),
2592 case: SymbolCase::Upper,
2593 },
2594 headers: std::collections::HashMap::new(),
2595 capabilities: CapabilitySet {
2596 order_book: None,
2597 ticker: None,
2598 trades: None,
2599 ohlc: Some(EndpointDescriptor {
2600 path: "/api/v1/klines".to_string(),
2601 method: HttpMethod::POST,
2602 params: std::collections::HashMap::new(),
2603 request_body: None,
2604 response_root: None,
2605 interval_map: std::collections::HashMap::new(),
2606 response: ResponseMapping {
2607 ohlc_format: Some("array_of_arrays".to_string()),
2608 ..Default::default()
2609 },
2610 }),
2611 },
2612 };
2613
2614 let client = ConfigurableExchangeClient::new(desc);
2615 let err = client.fetch_ohlc("BTCUSDT", "1h", 100).await.unwrap_err();
2616 assert!(err.to_string().contains("API error"));
2617 mock.assert_async().await;
2618 }
2619
2620 #[tokio::test]
2621 async fn test_fetch_ohlc_get_http_error() {
2622 let mut server = mockito::Server::new_async().await;
2623 let mock = server
2624 .mock("GET", "/api/v1/klines")
2625 .match_query(mockito::Matcher::AllOf(vec![
2626 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2627 mockito::Matcher::UrlEncoded("interval".into(), "1h".into()),
2628 mockito::Matcher::UrlEncoded("limit".into(), "100".into()),
2629 ]))
2630 .with_status(429)
2631 .with_body("Rate limited")
2632 .create_async()
2633 .await;
2634
2635 let desc = make_ohlc_test_descriptor(&server.url());
2636 let client = ConfigurableExchangeClient::new(desc);
2637 let err = client.fetch_ohlc("BTCUSDT", "1h", 100).await.unwrap_err();
2638 assert!(err.to_string().contains("API error"));
2639 mock.assert_async().await;
2640 }
2641
2642 #[tokio::test]
2643 async fn test_fetch_ohlc_with_items_key() {
2644 use crate::market::descriptor::*;
2645 let mut server = mockito::Server::new_async().await;
2646 let mock = server
2647 .mock("GET", "/api/v1/candles")
2648 .match_query(mockito::Matcher::AllOf(vec![
2649 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2650 mockito::Matcher::UrlEncoded("interval".into(), "1h".into()),
2651 mockito::Matcher::UrlEncoded("limit".into(), "2".into()),
2652 ]))
2653 .with_status(200)
2654 .with_body(
2655 serde_json::json!({
2656 "data": [
2657 {"ts": 1700000000000u64, "o": "100.0", "h": "110.0", "l": "90.0", "c": "105.0", "vol": "1000.0", "ct": 1700003599999u64},
2658 {"ts": 1700003600000u64, "o": "105.0", "h": "115.0", "l": "100.0", "c": "110.0", "vol": "800.0", "ct": 1700007199999u64}
2659 ]
2660 })
2661 .to_string(),
2662 )
2663 .create_async()
2664 .await;
2665
2666 let desc = VenueDescriptor {
2667 id: "items_key_ohlc".to_string(),
2668 name: "Items Key OHLC".to_string(),
2669 base_url: server.url(),
2670 timeout_secs: Some(5),
2671 rate_limit_per_sec: None,
2672 symbol: SymbolConfig {
2673 template: "{base}{quote}".to_string(),
2674 default_quote: "USDT".to_string(),
2675 case: SymbolCase::Upper,
2676 },
2677 headers: std::collections::HashMap::new(),
2678 capabilities: CapabilitySet {
2679 order_book: None,
2680 ticker: None,
2681 trades: None,
2682 ohlc: Some(EndpointDescriptor {
2683 path: "/api/v1/candles".to_string(),
2684 method: HttpMethod::GET,
2685 params: [
2686 ("symbol".to_string(), "{pair}".to_string()),
2687 ("interval".to_string(), "{interval}".to_string()),
2688 ("limit".to_string(), "{limit}".to_string()),
2689 ]
2690 .into_iter()
2691 .collect(),
2692 request_body: None,
2693 response_root: None,
2694 interval_map: std::collections::HashMap::new(),
2695 response: ResponseMapping {
2696 items_key: Some("data".to_string()),
2697 ohlc_format: Some("objects".to_string()),
2698 open_time: Some("ts".to_string()),
2699 open: Some("o".to_string()),
2700 high: Some("h".to_string()),
2701 low: Some("l".to_string()),
2702 close: Some("c".to_string()),
2703 ohlc_volume: Some("vol".to_string()),
2704 close_time: Some("ct".to_string()),
2705 ..Default::default()
2706 },
2707 }),
2708 },
2709 };
2710
2711 let client = ConfigurableExchangeClient::new(desc);
2712 let candles = client.fetch_ohlc("BTCUSDT", "1h", 2).await.unwrap();
2713 assert_eq!(candles.len(), 2);
2714 assert_eq!(candles[0].open, 100.0);
2715 assert_eq!(candles[1].close, 110.0);
2716 mock.assert_async().await;
2717 }
2718}