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 [1700000000000u64, "50000.0", "50500.0", "49800.0", "50200.0", "100.5"],
2192 [1700003600000u64, "50200.0", "50800.0", "50100.0", "50700.0", "120.3"]
2193 ])
2194 .to_string(),
2195 )
2196 .create_async()
2197 .await;
2198
2199 let desc = VenueDescriptor {
2200 id: "interval_map_test".to_string(),
2201 name: "Interval Map Test".to_string(),
2202 base_url: server.url(),
2203 timeout_secs: Some(5),
2204 rate_limit_per_sec: None,
2205 symbol: SymbolConfig {
2206 template: "{base}{quote}".to_string(),
2207 default_quote: "USDT".to_string(),
2208 case: SymbolCase::Upper,
2209 },
2210 headers: std::collections::HashMap::new(),
2211 capabilities: CapabilitySet {
2212 order_book: None,
2213 ticker: None,
2214 trades: None,
2215 ohlc: Some(EndpointDescriptor {
2216 path: "/api/v1/kline".to_string(),
2217 method: HttpMethod::GET,
2218 params: [
2219 ("symbol".to_string(), "{pair}".to_string()),
2220 ("type".to_string(), "{interval}".to_string()),
2221 ("size".to_string(), "{limit}".to_string()),
2222 ]
2223 .into_iter()
2224 .collect(),
2225 request_body: None,
2226 response_root: None,
2227 interval_map: [
2228 ("1m".to_string(), "1min".to_string()),
2229 ("5m".to_string(), "5min".to_string()),
2230 ("1h".to_string(), "hour".to_string()),
2231 ("1d".to_string(), "day".to_string()),
2232 ]
2233 .into_iter()
2234 .collect(),
2235 response: ResponseMapping {
2236 ohlc_format: Some("array_of_arrays".to_string()),
2237 ohlc_fields: Some(vec![
2238 "open_time".to_string(),
2239 "open".to_string(),
2240 "high".to_string(),
2241 "low".to_string(),
2242 "close".to_string(),
2243 "volume".to_string(),
2244 ]),
2245 ..Default::default()
2246 },
2247 }),
2248 },
2249 };
2250
2251 let client = ConfigurableExchangeClient::new(desc);
2252 let candles = client.fetch_ohlc("BTCUSDT", "1h", 2).await.unwrap();
2253 assert_eq!(candles.len(), 2);
2254 assert_eq!(candles[0].open, 50000.0);
2255 assert_eq!(candles[1].close, 50700.0);
2256 mock.assert_async().await;
2257 }
2258
2259 #[tokio::test]
2261 async fn test_fetch_ohlc_interval_map_passthrough() {
2262 use crate::market::descriptor::*;
2263 let mut server = mockito::Server::new_async().await;
2264 let mock = server
2266 .mock("GET", "/api/v1/kline")
2267 .match_query(mockito::Matcher::AllOf(vec![
2268 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2269 mockito::Matcher::UrlEncoded("type".into(), "15m".into()),
2270 mockito::Matcher::UrlEncoded("size".into(), "1".into()),
2271 ]))
2272 .with_status(200)
2273 .with_body(
2274 serde_json::json!([
2275 [1700000000000u64, "50000.0", "50500.0", "49800.0", "50200.0", "100.5"]
2276 ])
2277 .to_string(),
2278 )
2279 .create_async()
2280 .await;
2281
2282 let desc = VenueDescriptor {
2283 id: "passthrough_test".to_string(),
2284 name: "Passthrough Test".to_string(),
2285 base_url: server.url(),
2286 timeout_secs: Some(5),
2287 rate_limit_per_sec: None,
2288 symbol: SymbolConfig {
2289 template: "{base}{quote}".to_string(),
2290 default_quote: "USDT".to_string(),
2291 case: SymbolCase::Upper,
2292 },
2293 headers: std::collections::HashMap::new(),
2294 capabilities: CapabilitySet {
2295 order_book: None,
2296 ticker: None,
2297 trades: None,
2298 ohlc: Some(EndpointDescriptor {
2299 path: "/api/v1/kline".to_string(),
2300 method: HttpMethod::GET,
2301 params: [
2302 ("symbol".to_string(), "{pair}".to_string()),
2303 ("type".to_string(), "{interval}".to_string()),
2304 ("size".to_string(), "{limit}".to_string()),
2305 ]
2306 .into_iter()
2307 .collect(),
2308 request_body: None,
2309 response_root: None,
2310 interval_map: [("1h".to_string(), "hour".to_string())]
2312 .into_iter()
2313 .collect(),
2314 response: ResponseMapping {
2315 ohlc_format: Some("array_of_arrays".to_string()),
2316 ohlc_fields: Some(vec![
2317 "open_time".to_string(),
2318 "open".to_string(),
2319 "high".to_string(),
2320 "low".to_string(),
2321 "close".to_string(),
2322 "volume".to_string(),
2323 ]),
2324 ..Default::default()
2325 },
2326 }),
2327 },
2328 };
2329
2330 let client = ConfigurableExchangeClient::new(desc);
2331 let candles = client.fetch_ohlc("BTCUSDT", "15m", 1).await.unwrap();
2332 assert_eq!(candles.len(), 1);
2333 mock.assert_async().await;
2334 }
2335
2336 #[tokio::test]
2337 async fn test_fetch_ohlc_non_array_response() {
2338 let mut server = mockito::Server::new_async().await;
2339 let mock = server
2340 .mock("GET", "/api/v1/klines")
2341 .match_query(mockito::Matcher::AllOf(vec![
2342 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2343 mockito::Matcher::UrlEncoded("interval".into(), "1h".into()),
2344 mockito::Matcher::UrlEncoded("limit".into(), "100".into()),
2345 ]))
2346 .with_status(200)
2347 .with_body(serde_json::json!({"error": "not an array"}).to_string())
2348 .create_async()
2349 .await;
2350
2351 let desc = make_ohlc_test_descriptor(&server.url());
2352 let client = ConfigurableExchangeClient::new(desc);
2353 let err = client.fetch_ohlc("BTCUSDT", "1h", 100).await.unwrap_err();
2354 let msg = err.to_string();
2355 assert!(
2356 msg.contains("expected array for OHLC"),
2357 "expected array error, got: {}",
2358 msg
2359 );
2360 mock.assert_async().await;
2361 }
2362
2363 #[tokio::test]
2364 async fn test_fetch_ohlc_empty_array() {
2365 let mut server = mockito::Server::new_async().await;
2366 let mock = server
2367 .mock("GET", "/api/v1/klines")
2368 .match_query(mockito::Matcher::AllOf(vec![
2369 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2370 mockito::Matcher::UrlEncoded("interval".into(), "1d".into()),
2371 mockito::Matcher::UrlEncoded("limit".into(), "10".into()),
2372 ]))
2373 .with_status(200)
2374 .with_body("[]")
2375 .create_async()
2376 .await;
2377
2378 let desc = make_ohlc_test_descriptor(&server.url());
2379 let client = ConfigurableExchangeClient::new(desc);
2380 let candles = client.fetch_ohlc("BTCUSDT", "1d", 10).await.unwrap();
2381 assert!(candles.is_empty());
2382 mock.assert_async().await;
2383 }
2384
2385 #[tokio::test]
2386 async fn test_fetch_ohlc_skips_malformed_inner_items() {
2387 let mut server = mockito::Server::new_async().await;
2388 let mock = server
2389 .mock("GET", "/api/v1/klines")
2390 .match_query(mockito::Matcher::AllOf(vec![
2391 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2392 mockito::Matcher::UrlEncoded("interval".into(), "1h".into()),
2393 mockito::Matcher::UrlEncoded("limit".into(), "5".into()),
2394 ]))
2395 .with_status(200)
2396 .with_body(
2397 serde_json::json!([
2398 "not an array",
2399 [
2400 1700000000000u64,
2401 "50000.0",
2402 "50500.0",
2403 "49800.0",
2404 "50200.0",
2405 "100.5",
2406 1700003599999u64
2407 ],
2408 42
2409 ])
2410 .to_string(),
2411 )
2412 .create_async()
2413 .await;
2414
2415 let desc = make_ohlc_test_descriptor(&server.url());
2416 let client = ConfigurableExchangeClient::new(desc);
2417 let candles = client.fetch_ohlc("BTCUSDT", "1h", 5).await.unwrap();
2418 assert_eq!(candles.len(), 1);
2420 assert_eq!(candles[0].open, 50000.0);
2421 mock.assert_async().await;
2422 }
2423
2424 #[tokio::test]
2425 async fn test_fetch_ohlc_with_response_root() {
2426 let mut server = mockito::Server::new_async().await;
2427 let mock = server
2428 .mock("GET", "/api/v1/klines")
2429 .match_query(mockito::Matcher::AllOf(vec![
2430 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2431 mockito::Matcher::UrlEncoded("interval".into(), "4h".into()),
2432 mockito::Matcher::UrlEncoded("limit".into(), "2".into()),
2433 ]))
2434 .with_status(200)
2435 .with_body(
2436 serde_json::json!({
2437 "result": [
2438 [1700000000000u64, "50000.0", "50500.0", "49800.0", "50200.0", "100.5", 1700003599999u64],
2439 [1700003600000u64, "50200.0", "50800.0", "50100.0", "50700.0", "120.3", 1700007199999u64]
2440 ]
2441 })
2442 .to_string(),
2443 )
2444 .create_async()
2445 .await;
2446
2447 let mut desc = make_ohlc_test_descriptor(&server.url());
2448 desc.capabilities.ohlc.as_mut().unwrap().response_root = Some("result".to_string());
2449
2450 let client = ConfigurableExchangeClient::new(desc);
2451 let candles = client.fetch_ohlc("BTCUSDT", "4h", 2).await.unwrap();
2452 assert_eq!(candles.len(), 2);
2453 mock.assert_async().await;
2454 }
2455
2456 #[test]
2457 fn test_interpolate_value_full_with_interval() {
2458 let desc = make_test_descriptor();
2459 let client = ConfigurableExchangeClient::new(desc);
2460 let result =
2461 client.interpolate_value_full("{pair}_{interval}_{limit}", "BTCUSDT", "50", "1h");
2462 assert_eq!(result, "BTCUSDT_1h_50");
2463 }
2464
2465 #[test]
2466 fn test_interpolate_json_full_with_interval() {
2467 let desc = make_test_descriptor();
2468 let client = ConfigurableExchangeClient::new(desc);
2469 let template = serde_json::json!({
2470 "symbol": "{pair}",
2471 "interval": "{interval}",
2472 "limit": "{limit}"
2473 });
2474 let result = client.interpolate_json_full(&template, "ETHUSDT", "100", "15m");
2475 assert_eq!(result["symbol"], "ETHUSDT");
2476 assert_eq!(result["interval"], "15m");
2477 assert_eq!(result["limit"], "100");
2478 }
2479
2480 #[tokio::test]
2481 async fn test_fetch_ohlc_via_post_method() {
2482 use crate::market::descriptor::*;
2483 let mut server = mockito::Server::new_async().await;
2484 let mock = server
2485 .mock("POST", "/api/v1/klines")
2486 .with_status(200)
2487 .with_body(
2488 serde_json::json!([[
2489 1700000000000u64,
2490 "50000.0",
2491 "50500.0",
2492 "49800.0",
2493 "50200.0",
2494 "100.5",
2495 1700003599999u64
2496 ]])
2497 .to_string(),
2498 )
2499 .create_async()
2500 .await;
2501
2502 let desc = VenueDescriptor {
2503 id: "post_ohlc".to_string(),
2504 name: "POST OHLC".to_string(),
2505 base_url: server.url(),
2506 timeout_secs: Some(5),
2507 rate_limit_per_sec: None,
2508 symbol: SymbolConfig {
2509 template: "{base}{quote}".to_string(),
2510 default_quote: "USDT".to_string(),
2511 case: SymbolCase::Upper,
2512 },
2513 headers: std::collections::HashMap::new(),
2514 capabilities: CapabilitySet {
2515 order_book: None,
2516 ticker: None,
2517 trades: None,
2518 ohlc: Some(EndpointDescriptor {
2519 path: "/api/v1/klines".to_string(),
2520 method: HttpMethod::POST,
2521 params: std::collections::HashMap::new(),
2522 request_body: Some(serde_json::json!({
2523 "symbol": "{pair}",
2524 "interval": "{interval}",
2525 "limit": "{limit}"
2526 })),
2527 response_root: None,
2528 interval_map: std::collections::HashMap::new(),
2529 response: ResponseMapping {
2530 ohlc_format: Some("array_of_arrays".to_string()),
2531 ohlc_fields: Some(vec![
2532 "open_time".to_string(),
2533 "open".to_string(),
2534 "high".to_string(),
2535 "low".to_string(),
2536 "close".to_string(),
2537 "volume".to_string(),
2538 "close_time".to_string(),
2539 ]),
2540 ..Default::default()
2541 },
2542 }),
2543 },
2544 };
2545
2546 let client = ConfigurableExchangeClient::new(desc);
2547 let candles = client.fetch_ohlc("BTCUSDT", "1h", 1).await.unwrap();
2548 assert_eq!(candles.len(), 1);
2549 assert_eq!(candles[0].open, 50000.0);
2550 mock.assert_async().await;
2551 }
2552
2553 #[tokio::test]
2554 async fn test_fetch_ohlc_post_http_error() {
2555 use crate::market::descriptor::*;
2556 let mut server = mockito::Server::new_async().await;
2557 let mock = server
2558 .mock("POST", "/api/v1/klines")
2559 .with_status(500)
2560 .with_body("Internal Server Error")
2561 .create_async()
2562 .await;
2563
2564 let desc = VenueDescriptor {
2565 id: "post_ohlc_err".to_string(),
2566 name: "POST OHLC Err".to_string(),
2567 base_url: server.url(),
2568 timeout_secs: Some(5),
2569 rate_limit_per_sec: None,
2570 symbol: SymbolConfig {
2571 template: "{base}{quote}".to_string(),
2572 default_quote: "USDT".to_string(),
2573 case: SymbolCase::Upper,
2574 },
2575 headers: std::collections::HashMap::new(),
2576 capabilities: CapabilitySet {
2577 order_book: None,
2578 ticker: None,
2579 trades: None,
2580 ohlc: Some(EndpointDescriptor {
2581 path: "/api/v1/klines".to_string(),
2582 method: HttpMethod::POST,
2583 params: std::collections::HashMap::new(),
2584 request_body: None,
2585 response_root: None,
2586 interval_map: std::collections::HashMap::new(),
2587 response: ResponseMapping {
2588 ohlc_format: Some("array_of_arrays".to_string()),
2589 ..Default::default()
2590 },
2591 }),
2592 },
2593 };
2594
2595 let client = ConfigurableExchangeClient::new(desc);
2596 let err = client.fetch_ohlc("BTCUSDT", "1h", 100).await.unwrap_err();
2597 assert!(err.to_string().contains("API error"));
2598 mock.assert_async().await;
2599 }
2600
2601 #[tokio::test]
2602 async fn test_fetch_ohlc_get_http_error() {
2603 let mut server = mockito::Server::new_async().await;
2604 let mock = server
2605 .mock("GET", "/api/v1/klines")
2606 .match_query(mockito::Matcher::AllOf(vec![
2607 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2608 mockito::Matcher::UrlEncoded("interval".into(), "1h".into()),
2609 mockito::Matcher::UrlEncoded("limit".into(), "100".into()),
2610 ]))
2611 .with_status(429)
2612 .with_body("Rate limited")
2613 .create_async()
2614 .await;
2615
2616 let desc = make_ohlc_test_descriptor(&server.url());
2617 let client = ConfigurableExchangeClient::new(desc);
2618 let err = client.fetch_ohlc("BTCUSDT", "1h", 100).await.unwrap_err();
2619 assert!(err.to_string().contains("API error"));
2620 mock.assert_async().await;
2621 }
2622
2623 #[tokio::test]
2624 async fn test_fetch_ohlc_with_items_key() {
2625 use crate::market::descriptor::*;
2626 let mut server = mockito::Server::new_async().await;
2627 let mock = server
2628 .mock("GET", "/api/v1/candles")
2629 .match_query(mockito::Matcher::AllOf(vec![
2630 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2631 mockito::Matcher::UrlEncoded("interval".into(), "1h".into()),
2632 mockito::Matcher::UrlEncoded("limit".into(), "2".into()),
2633 ]))
2634 .with_status(200)
2635 .with_body(
2636 serde_json::json!({
2637 "data": [
2638 {"ts": 1700000000000u64, "o": "100.0", "h": "110.0", "l": "90.0", "c": "105.0", "vol": "1000.0", "ct": 1700003599999u64},
2639 {"ts": 1700003600000u64, "o": "105.0", "h": "115.0", "l": "100.0", "c": "110.0", "vol": "800.0", "ct": 1700007199999u64}
2640 ]
2641 })
2642 .to_string(),
2643 )
2644 .create_async()
2645 .await;
2646
2647 let desc = VenueDescriptor {
2648 id: "items_key_ohlc".to_string(),
2649 name: "Items Key OHLC".to_string(),
2650 base_url: server.url(),
2651 timeout_secs: Some(5),
2652 rate_limit_per_sec: None,
2653 symbol: SymbolConfig {
2654 template: "{base}{quote}".to_string(),
2655 default_quote: "USDT".to_string(),
2656 case: SymbolCase::Upper,
2657 },
2658 headers: std::collections::HashMap::new(),
2659 capabilities: CapabilitySet {
2660 order_book: None,
2661 ticker: None,
2662 trades: None,
2663 ohlc: Some(EndpointDescriptor {
2664 path: "/api/v1/candles".to_string(),
2665 method: HttpMethod::GET,
2666 params: [
2667 ("symbol".to_string(), "{pair}".to_string()),
2668 ("interval".to_string(), "{interval}".to_string()),
2669 ("limit".to_string(), "{limit}".to_string()),
2670 ]
2671 .into_iter()
2672 .collect(),
2673 request_body: None,
2674 response_root: None,
2675 interval_map: std::collections::HashMap::new(),
2676 response: ResponseMapping {
2677 items_key: Some("data".to_string()),
2678 ohlc_format: Some("objects".to_string()),
2679 open_time: Some("ts".to_string()),
2680 open: Some("o".to_string()),
2681 high: Some("h".to_string()),
2682 low: Some("l".to_string()),
2683 close: Some("c".to_string()),
2684 ohlc_volume: Some("vol".to_string()),
2685 close_time: Some("ct".to_string()),
2686 ..Default::default()
2687 },
2688 }),
2689 },
2690 };
2691
2692 let client = ConfigurableExchangeClient::new(desc);
2693 let candles = client.fetch_ohlc("BTCUSDT", "1h", 2).await.unwrap();
2694 assert_eq!(candles.len(), 2);
2695 assert_eq!(candles[0].open, 100.0);
2696 assert_eq!(candles[1].close, 110.0);
2697 mock.assert_async().await;
2698 }
2699}