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 json = self
627 .fetch_endpoint_with_interval(endpoint, pair_symbol, Some(limit), interval)
628 .await?;
629 let data = self.navigate_root(&json, endpoint.response_root.as_deref())?;
630
631 let items_key = endpoint.response.items_key.as_deref().unwrap_or("");
633 let arr = if items_key.is_empty() {
634 data
635 } else {
636 data.get(items_key).unwrap_or(data)
637 };
638
639 let items = arr.as_array().ok_or_else(|| {
640 ScopeError::Chain(format!(
641 "{}: expected array for OHLC data",
642 self.descriptor.name
643 ))
644 })?;
645
646 let r = &endpoint.response;
647 let format = r.ohlc_format.as_deref().unwrap_or("objects");
648 let mut candles = Vec::with_capacity(items.len());
649
650 if format == "array_of_arrays" {
651 let default_fields = vec![
654 "open_time".to_string(),
655 "open".to_string(),
656 "high".to_string(),
657 "low".to_string(),
658 "close".to_string(),
659 "volume".to_string(),
660 "close_time".to_string(),
661 ];
662 let fields = r.ohlc_fields.as_ref().unwrap_or(&default_fields);
663 let idx = |name: &str| -> Option<usize> { fields.iter().position(|f| f == name) };
664
665 for item in items {
666 let arr = match item.as_array() {
667 Some(a) => a,
668 None => continue,
669 };
670 let get_f64 = |i: Option<usize>| -> Option<f64> {
671 i.and_then(|idx| arr.get(idx)).and_then(value_to_f64)
672 };
673 let get_u64 = |i: Option<usize>| -> Option<u64> { get_f64(i).map(|v| v as u64) };
674
675 if let (Some(open), Some(high), Some(low), Some(close)) = (
676 get_f64(idx("open")),
677 get_f64(idx("high")),
678 get_f64(idx("low")),
679 get_f64(idx("close")),
680 ) {
681 candles.push(Candle {
682 open_time: get_u64(idx("open_time")).unwrap_or(0),
683 open,
684 high,
685 low,
686 close,
687 volume: get_f64(idx("volume")).unwrap_or(0.0),
688 close_time: get_u64(idx("close_time")).unwrap_or(0),
689 });
690 }
691 }
692 } else {
693 for item in items {
695 let open = r.open.as_ref().and_then(|f| self.extract_f64(item, f));
696 let high = r.high.as_ref().and_then(|f| self.extract_f64(item, f));
697 let low = r.low.as_ref().and_then(|f| self.extract_f64(item, f));
698 let close = r.close.as_ref().and_then(|f| self.extract_f64(item, f));
699
700 if let (Some(open), Some(high), Some(low), Some(close)) = (open, high, low, close) {
701 let open_time = r
702 .open_time
703 .as_ref()
704 .and_then(|f| self.extract_f64(item, f))
705 .map(|v| v as u64)
706 .unwrap_or(0);
707 let volume = r
708 .ohlc_volume
709 .as_ref()
710 .and_then(|f| self.extract_f64(item, f))
711 .unwrap_or(0.0);
712 let close_time = r
713 .close_time
714 .as_ref()
715 .and_then(|f| self.extract_f64(item, f))
716 .map(|v| v as u64)
717 .unwrap_or(0);
718
719 candles.push(Candle {
720 open_time,
721 open,
722 high,
723 low,
724 close,
725 volume,
726 close_time,
727 });
728 }
729 }
730 }
731
732 Ok(candles)
733 }
734}
735
736fn value_to_f64(val: &Value) -> Option<f64> {
742 match val {
743 Value::Number(n) => n.as_f64(),
744 Value::String(s) => s.parse::<f64>().ok(),
745 _ => None,
746 }
747}
748
749fn current_type(val: &Value) -> &'static str {
751 match val {
752 Value::Null => "null",
753 Value::Bool(_) => "bool",
754 Value::Number(_) => "number",
755 Value::String(_) => "string",
756 Value::Array(_) => "array",
757 Value::Object(_) => "object",
758 }
759}
760
761fn format_display_pair(raw: &str, template: &str) -> String {
763 let sep = if template.contains('_') {
765 "_"
766 } else if template.contains('-') {
767 "-"
768 } else {
769 ""
770 };
771
772 if !sep.is_empty() {
773 raw.replace(sep, "/")
774 } else {
775 let upper = raw.to_uppercase();
777 for quote in &["USDT", "USD", "USDC", "BTC", "ETH", "EUR", "GBP"] {
778 if upper.ends_with(quote) {
779 let base_end = raw.len() - quote.len();
780 if base_end > 0 {
781 return format!("{}/{}", &raw[..base_end], &raw[base_end..]);
782 }
783 }
784 }
785 raw.to_string()
786 }
787}
788
789#[cfg(test)]
790mod tests {
791 use super::*;
792
793 #[test]
794 fn test_format_display_pair_underscore() {
795 assert_eq!(
796 format_display_pair("BTC_USDT", "{base}_{quote}"),
797 "BTC/USDT"
798 );
799 }
800
801 #[test]
802 fn test_format_display_pair_dash() {
803 assert_eq!(
804 format_display_pair("BTC-USDT", "{base}-{quote}"),
805 "BTC/USDT"
806 );
807 }
808
809 #[test]
810 fn test_format_display_pair_concatenated() {
811 assert_eq!(format_display_pair("BTCUSDT", "{base}{quote}"), "BTC/USDT");
812 assert_eq!(format_display_pair("ETHUSD", "{base}{quote}"), "ETH/USD");
813 }
814
815 #[test]
816 fn test_value_to_f64_number() {
817 let val = serde_json::json!(42.5);
818 assert_eq!(value_to_f64(&val), Some(42.5));
819 }
820
821 #[test]
822 fn test_value_to_f64_string() {
823 let val = serde_json::json!("42.5");
824 assert_eq!(value_to_f64(&val), Some(42.5));
825 }
826
827 #[test]
828 fn test_value_to_f64_invalid() {
829 let val = serde_json::json!(null);
830 assert_eq!(value_to_f64(&val), None);
831 }
832
833 #[test]
834 fn test_navigate_root_empty() {
835 let desc = make_test_descriptor();
836 let client = ConfigurableExchangeClient::new(desc);
837 let json = serde_json::json!({"price": 42});
838 let result = client.navigate_root(&json, None).unwrap();
839 assert_eq!(result, &json);
840 }
841
842 #[test]
843 fn test_navigate_root_single_key() {
844 let desc = make_test_descriptor();
845 let client = ConfigurableExchangeClient::new(desc);
846 let json = serde_json::json!({"result": {"price": 42}});
847 let result = client.navigate_root(&json, Some("result")).unwrap();
848 assert_eq!(result, &serde_json::json!({"price": 42}));
849 }
850
851 #[test]
852 fn test_navigate_root_nested_with_index() {
853 let desc = make_test_descriptor();
854 let client = ConfigurableExchangeClient::new(desc);
855 let json = serde_json::json!({"data": [{"price": 42}, {"price": 43}]});
856 let result = client.navigate_root(&json, Some("data.0")).unwrap();
857 assert_eq!(result, &serde_json::json!({"price": 42}));
858 }
859
860 #[test]
861 fn test_navigate_root_wildcard() {
862 let desc = make_test_descriptor();
863 let client = ConfigurableExchangeClient::new(desc);
864 let json = serde_json::json!({"result": {"XXBTZUSD": {"a": ["42000.0"]}}});
865 let result = client.navigate_root(&json, Some("result.*")).unwrap();
866 assert_eq!(result, &serde_json::json!({"a": ["42000.0"]}));
867 }
868
869 #[test]
870 fn test_extract_f64_nested() {
871 let desc = make_test_descriptor();
872 let client = ConfigurableExchangeClient::new(desc);
873 let data = serde_json::json!({"c": ["42000.5", "1.5"]});
874 assert_eq!(client.extract_f64(&data, "c.0"), Some(42000.5));
875 assert_eq!(client.extract_f64(&data, "c.1"), Some(1.5));
876 }
877
878 #[test]
879 fn test_parse_positional_levels() {
880 let desc = make_test_descriptor();
881 let client = ConfigurableExchangeClient::new(desc);
882 let arr = serde_json::json!([["42000.0", "1.5"], ["42001.0", "2.0"]]);
883 let mapping = ResponseMapping {
884 level_format: Some("positional".to_string()),
885 ..Default::default()
886 };
887 let levels = client.parse_levels(&arr, &mapping).unwrap();
888 assert_eq!(levels.len(), 2);
889 assert_eq!(levels[0].price, 42000.0);
890 assert_eq!(levels[0].quantity, 1.5);
891 }
892
893 #[test]
894 fn test_parse_object_levels() {
895 let desc = make_test_descriptor();
896 let client = ConfigurableExchangeClient::new(desc);
897 let arr = serde_json::json!([
898 {"price": "42000.0", "size": "1.5"},
899 {"price": "42001.0", "size": "2.0"}
900 ]);
901 let mapping = ResponseMapping {
902 level_format: Some("object".to_string()),
903 level_price_field: Some("price".to_string()),
904 level_size_field: Some("size".to_string()),
905 ..Default::default()
906 };
907 let levels = client.parse_levels(&arr, &mapping).unwrap();
908 assert_eq!(levels.len(), 2);
909 assert_eq!(levels[0].price, 42000.0);
910 assert_eq!(levels[0].quantity, 1.5);
911 }
912
913 #[test]
914 fn test_parse_side_mapping() {
915 let desc = make_test_descriptor();
916 let client = ConfigurableExchangeClient::new(desc);
917 let data = serde_json::json!({"isBuyerMaker": true});
918 let mapping = ResponseMapping {
919 side: Some(crate::market::descriptor::SideMapping {
920 field: "isBuyerMaker".to_string(),
921 mapping: [
922 ("true".to_string(), "sell".to_string()),
923 ("false".to_string(), "buy".to_string()),
924 ]
925 .into_iter()
926 .collect(),
927 }),
928 ..Default::default()
929 };
930 assert_eq!(client.parse_side(&data, &mapping), TradeSide::Sell);
931 }
932
933 #[test]
934 fn test_interpolate_json() {
935 let desc = make_test_descriptor();
936 let client = ConfigurableExchangeClient::new(desc);
937 let template = serde_json::json!({
938 "method": "get-book",
939 "params": {"instrument": "{pair}", "depth": "{limit}"}
940 });
941 let result = client.interpolate_json(&template, "BTC_USDT", "100");
942 assert_eq!(
943 result,
944 serde_json::json!({
945 "method": "get-book",
946 "params": {"instrument": "BTC_USDT", "depth": "100"}
947 })
948 );
949 }
950
951 fn make_test_descriptor() -> VenueDescriptor {
952 use crate::market::descriptor::*;
953 VenueDescriptor {
954 id: "test".to_string(),
955 name: "Test".to_string(),
956 base_url: "https://example.com".to_string(),
957 timeout_secs: Some(5),
958 rate_limit_per_sec: None,
959 symbol: SymbolConfig {
960 template: "{base}{quote}".to_string(),
961 default_quote: "USDT".to_string(),
962 case: SymbolCase::Upper,
963 },
964 headers: std::collections::HashMap::new(),
965 capabilities: CapabilitySet::default(),
966 }
967 }
968
969 #[test]
975 fn test_descriptor_accessor() {
976 let desc = make_test_descriptor();
977 let client = ConfigurableExchangeClient::new(desc.clone());
978 assert_eq!(client.descriptor().id, "test");
979 assert_eq!(client.descriptor().name, "Test");
980 }
981
982 #[test]
983 fn test_format_pair_accessor() {
984 let desc = make_test_descriptor();
985 let client = ConfigurableExchangeClient::new(desc);
986 assert_eq!(client.format_pair("BTC", None), "BTCUSDT");
987 assert_eq!(client.format_pair("ETH", Some("USD")), "ETHUSD");
988 }
989
990 #[test]
991 fn test_navigate_root_empty_string_path() {
992 let desc = make_test_descriptor();
993 let client = ConfigurableExchangeClient::new(desc);
994 let json = serde_json::json!({"price": 42});
995 let result = client.navigate_root(&json, Some("")).unwrap();
996 assert_eq!(result, &json);
997 }
998
999 #[test]
1000 fn test_navigate_root_wildcard_on_non_object_null() {
1001 let desc = make_test_descriptor();
1002 let client = ConfigurableExchangeClient::new(desc);
1003 let json = serde_json::json!({"result": null});
1004 let result = client.navigate_root(&json, Some("result.*"));
1005 assert!(result.is_err());
1006 let err_msg = format!("{:?}", result.unwrap_err());
1007 assert!(err_msg.contains("null"), "error should mention null type");
1008 }
1009
1010 #[test]
1011 fn test_navigate_root_wildcard_on_non_object_bool() {
1012 let desc = make_test_descriptor();
1013 let client = ConfigurableExchangeClient::new(desc);
1014 let json = serde_json::json!({"result": true});
1015 let result = client.navigate_root(&json, Some("result.*"));
1016 assert!(result.is_err());
1017 let err_msg = format!("{:?}", result.unwrap_err());
1018 assert!(err_msg.contains("bool"), "error should mention bool type");
1019 }
1020
1021 #[test]
1022 fn test_navigate_root_wildcard_on_non_object_number() {
1023 let desc = make_test_descriptor();
1024 let client = ConfigurableExchangeClient::new(desc);
1025 let json = serde_json::json!({"result": 42});
1026 let result = client.navigate_root(&json, Some("result.*"));
1027 assert!(result.is_err());
1028 let err_msg = format!("{:?}", result.unwrap_err());
1029 assert!(
1030 err_msg.contains("number"),
1031 "error should mention number type"
1032 );
1033 }
1034
1035 #[test]
1036 fn test_navigate_root_wildcard_on_non_object_string() {
1037 let desc = make_test_descriptor();
1038 let client = ConfigurableExchangeClient::new(desc);
1039 let json = serde_json::json!({"result": "not_an_object"});
1040 let result = client.navigate_root(&json, Some("result.*"));
1041 assert!(result.is_err());
1042 let err_msg = format!("{:?}", result.unwrap_err());
1043 assert!(
1044 err_msg.contains("string"),
1045 "error should mention string type"
1046 );
1047 }
1048
1049 #[test]
1050 fn test_navigate_root_wildcard_on_non_object_array() {
1051 let desc = make_test_descriptor();
1052 let client = ConfigurableExchangeClient::new(desc);
1053 let json = serde_json::json!({"result": [1, 2, 3]});
1054 let result = client.navigate_root(&json, Some("result.*"));
1055 assert!(result.is_err());
1056 let err_msg = format!("{:?}", result.unwrap_err());
1057 assert!(err_msg.contains("array"), "error should mention array type");
1058 }
1059
1060 #[test]
1061 fn test_navigate_root_wildcard_empty_object() {
1062 let desc = make_test_descriptor();
1063 let client = ConfigurableExchangeClient::new(desc);
1064 let json = serde_json::json!({"result": {}});
1065 let result = client.navigate_root(&json, Some("result.*"));
1066 assert!(result.is_err());
1067 let err_msg = format!("{:?}", result.unwrap_err());
1068 assert!(
1069 err_msg.contains("empty object"),
1070 "error should mention empty object"
1071 );
1072 }
1073
1074 #[test]
1075 fn test_extract_string_from_string() {
1076 let desc = make_test_descriptor();
1077 let client = ConfigurableExchangeClient::new(desc);
1078 let data = serde_json::json!({"id": "abc123"});
1079 assert_eq!(
1080 client.extract_string(&data, "id").as_deref(),
1081 Some("abc123")
1082 );
1083 }
1084
1085 #[test]
1086 fn test_extract_string_from_number() {
1087 let desc = make_test_descriptor();
1088 let client = ConfigurableExchangeClient::new(desc);
1089 let data = serde_json::json!({"id": 12345});
1090 assert_eq!(client.extract_string(&data, "id").as_deref(), Some("12345"));
1091 }
1092
1093 #[test]
1094 fn test_extract_string_from_nested_path() {
1095 let desc = make_test_descriptor();
1096 let client = ConfigurableExchangeClient::new(desc);
1097 let data = serde_json::json!({"a": {"b": {"c": ["x", "value"]}}});
1098 assert_eq!(
1099 client.extract_string(&data, "a.b.c.1").as_deref(),
1100 Some("value")
1101 );
1102 }
1103
1104 #[test]
1105 fn test_extract_string_returns_none_for_object() {
1106 let desc = make_test_descriptor();
1107 let client = ConfigurableExchangeClient::new(desc);
1108 let data = serde_json::json!({"id": {"nested": "obj"}});
1109 assert_eq!(client.extract_string(&data, "id"), None);
1110 }
1111
1112 #[test]
1113 fn test_extract_string_returns_none_for_array() {
1114 let desc = make_test_descriptor();
1115 let client = ConfigurableExchangeClient::new(desc);
1116 let data = serde_json::json!({"id": [1, 2, 3]});
1117 assert_eq!(client.extract_string(&data, "id"), None);
1118 }
1119
1120 #[test]
1121 fn test_navigate_field_deeper_path() {
1122 let desc = make_test_descriptor();
1123 let client = ConfigurableExchangeClient::new(desc);
1124 let data = serde_json::json!({"level1": {"level2": {"level3": [0, 99.5]}}});
1125 assert_eq!(
1126 client.extract_f64(&data, "level1.level2.level3.1"),
1127 Some(99.5)
1128 );
1129 }
1130
1131 #[test]
1132 fn test_parse_levels_empty_array() {
1133 let desc = make_test_descriptor();
1134 let client = ConfigurableExchangeClient::new(desc);
1135 let arr = serde_json::json!([]);
1136 let mapping = ResponseMapping::default();
1137 let levels = client.parse_levels(&arr, &mapping).unwrap();
1138 assert!(levels.is_empty());
1139 }
1140
1141 #[test]
1142 fn test_parse_levels_not_array_err() {
1143 let desc = make_test_descriptor();
1144 let client = ConfigurableExchangeClient::new(desc);
1145 let not_arr = serde_json::json!({"not": "array"});
1146 let mapping = ResponseMapping::default();
1147 let result = client.parse_levels(¬_arr, &mapping);
1148 assert!(result.is_err());
1149 let err_msg = format!("{:?}", result.unwrap_err());
1150 assert!(err_msg.contains("expected array"));
1151 }
1152
1153 #[test]
1154 fn test_parse_levels_filters_zero_price_and_quantity() {
1155 let desc = make_test_descriptor();
1156 let client = ConfigurableExchangeClient::new(desc);
1157 let arr = serde_json::json!([
1158 ["42000.0", "1.5"],
1159 ["0.0", "1.0"],
1160 ["42001.0", "0.0"],
1161 ["42002.0", ""]
1162 ]);
1163 let mapping = ResponseMapping {
1164 level_format: Some("positional".to_string()),
1165 ..Default::default()
1166 };
1167 let levels = client.parse_levels(&arr, &mapping).unwrap();
1168 assert_eq!(levels.len(), 1);
1169 assert_eq!(levels[0].price, 42000.0);
1170 assert_eq!(levels[0].quantity, 1.5);
1171 }
1172
1173 #[test]
1174 fn test_parse_levels_object_format_default_fields() {
1175 let desc = make_test_descriptor();
1176 let client = ConfigurableExchangeClient::new(desc);
1177 let arr = serde_json::json!([
1178 {"price": "100.0", "size": "2.0"},
1179 {"price": "101.0", "size": "3.0"}
1180 ]);
1181 let mapping = ResponseMapping {
1182 level_format: Some("object".to_string()),
1183 level_price_field: None,
1184 level_size_field: None,
1185 ..Default::default()
1186 };
1187 let levels = client.parse_levels(&arr, &mapping).unwrap();
1188 assert_eq!(levels.len(), 2);
1189 assert_eq!(levels[0].price, 100.0);
1190 assert_eq!(levels[0].quantity, 2.0);
1191 }
1192
1193 #[test]
1194 fn test_parse_side_no_mapping_returns_buy() {
1195 let desc = make_test_descriptor();
1196 let client = ConfigurableExchangeClient::new(desc);
1197 let data = serde_json::json!({"side": "sell"});
1198 let mapping = ResponseMapping {
1199 side: None,
1200 ..Default::default()
1201 };
1202 assert_eq!(client.parse_side(&data, &mapping), TradeSide::Buy);
1203 }
1204
1205 #[test]
1206 fn test_parse_side_field_missing_returns_buy() {
1207 let desc = make_test_descriptor();
1208 let client = ConfigurableExchangeClient::new(desc);
1209 let data = serde_json::json!({});
1210 let mapping = ResponseMapping {
1211 side: Some(crate::market::descriptor::SideMapping {
1212 field: "nonexistent".to_string(),
1213 mapping: [("sell".to_string(), "sell".to_string())]
1214 .into_iter()
1215 .collect(),
1216 }),
1217 ..Default::default()
1218 };
1219 assert_eq!(client.parse_side(&data, &mapping), TradeSide::Buy);
1220 }
1221
1222 #[test]
1223 fn test_parse_side_string_mapped_to_sell() {
1224 let desc = make_test_descriptor();
1225 let client = ConfigurableExchangeClient::new(desc);
1226 let data = serde_json::json!({"side": "ask"});
1227 let mapping = ResponseMapping {
1228 side: Some(crate::market::descriptor::SideMapping {
1229 field: "side".to_string(),
1230 mapping: [
1231 ("ask".to_string(), "sell".to_string()),
1232 ("bid".to_string(), "buy".to_string()),
1233 ]
1234 .into_iter()
1235 .collect(),
1236 }),
1237 ..Default::default()
1238 };
1239 assert_eq!(client.parse_side(&data, &mapping), TradeSide::Sell);
1240 }
1241
1242 #[test]
1243 fn test_parse_side_string_mapped_to_buy() {
1244 let desc = make_test_descriptor();
1245 let client = ConfigurableExchangeClient::new(desc);
1246 let data = serde_json::json!({"side": "bid"});
1247 let mapping = ResponseMapping {
1248 side: Some(crate::market::descriptor::SideMapping {
1249 field: "side".to_string(),
1250 mapping: [
1251 ("ask".to_string(), "sell".to_string()),
1252 ("bid".to_string(), "buy".to_string()),
1253 ]
1254 .into_iter()
1255 .collect(),
1256 }),
1257 ..Default::default()
1258 };
1259 assert_eq!(client.parse_side(&data, &mapping), TradeSide::Buy);
1260 }
1261
1262 #[test]
1263 fn test_parse_side_numeric_value() {
1264 let desc = make_test_descriptor();
1265 let client = ConfigurableExchangeClient::new(desc);
1266 let data = serde_json::json!({"side": 1});
1267 let mapping = ResponseMapping {
1268 side: Some(crate::market::descriptor::SideMapping {
1269 field: "side".to_string(),
1270 mapping: [
1271 ("1".to_string(), "sell".to_string()),
1272 ("0".to_string(), "buy".to_string()),
1273 ]
1274 .into_iter()
1275 .collect(),
1276 }),
1277 ..Default::default()
1278 };
1279 assert_eq!(client.parse_side(&data, &mapping), TradeSide::Sell);
1280 }
1281
1282 #[test]
1283 fn test_parse_side_unknown_value_returns_buy() {
1284 let desc = make_test_descriptor();
1285 let client = ConfigurableExchangeClient::new(desc);
1286 let data = serde_json::json!({"side": "unknown"});
1287 let mapping = ResponseMapping {
1288 side: Some(crate::market::descriptor::SideMapping {
1289 field: "side".to_string(),
1290 mapping: [
1291 ("ask".to_string(), "sell".to_string()),
1292 ("bid".to_string(), "buy".to_string()),
1293 ]
1294 .into_iter()
1295 .collect(),
1296 }),
1297 ..Default::default()
1298 };
1299 assert_eq!(client.parse_side(&data, &mapping), TradeSide::Buy);
1300 }
1301
1302 #[test]
1303 fn test_parse_side_non_string_number_bool_returns_buy() {
1304 let desc = make_test_descriptor();
1305 let client = ConfigurableExchangeClient::new(desc);
1306 let data = serde_json::json!({"side": [1, 2, 3]});
1307 let mapping = ResponseMapping {
1308 side: Some(crate::market::descriptor::SideMapping {
1309 field: "side".to_string(),
1310 mapping: [("ask".to_string(), "sell".to_string())]
1311 .into_iter()
1312 .collect(),
1313 }),
1314 ..Default::default()
1315 };
1316 assert_eq!(client.parse_side(&data, &mapping), TradeSide::Buy);
1317 }
1318
1319 #[test]
1320 fn test_format_display_pair_eur() {
1321 assert_eq!(format_display_pair("BTCEUR", "{base}{quote}"), "BTC/EUR");
1322 assert_eq!(format_display_pair("etheur", "{base}{quote}"), "eth/eur");
1323 }
1324
1325 #[test]
1326 fn test_format_display_pair_gbp() {
1327 assert_eq!(format_display_pair("BTCGBP", "{base}{quote}"), "BTC/GBP");
1328 }
1329
1330 #[test]
1331 fn test_format_display_pair_unknown_quote() {
1332 assert_eq!(format_display_pair("XYZABC", "{base}{quote}"), "XYZABC");
1333 }
1334
1335 #[test]
1336 fn test_format_display_pair_single_char() {
1337 assert_eq!(format_display_pair("A", "{base}{quote}"), "A");
1338 }
1339
1340 #[test]
1341 fn test_format_display_pair_quote_only_no_split() {
1342 assert_eq!(format_display_pair("USDT", "{base}{quote}"), "USDT");
1343 }
1344
1345 fn make_http_test_descriptor(base_url: &str) -> VenueDescriptor {
1350 use crate::market::descriptor::*;
1351 VenueDescriptor {
1352 id: "mock_test".to_string(),
1353 name: "Mock Test".to_string(),
1354 base_url: base_url.to_string(),
1355 timeout_secs: Some(5),
1356 rate_limit_per_sec: None,
1357 symbol: SymbolConfig {
1358 template: "{base}{quote}".to_string(),
1359 default_quote: "USDT".to_string(),
1360 case: SymbolCase::Upper,
1361 },
1362 headers: std::collections::HashMap::new(),
1363 capabilities: CapabilitySet {
1364 order_book: Some(EndpointDescriptor {
1365 path: "/api/v1/depth".to_string(),
1366 method: HttpMethod::GET,
1367 params: [("symbol".to_string(), "{pair}".to_string())]
1368 .into_iter()
1369 .collect(),
1370 request_body: None,
1371 response_root: None,
1372 response: ResponseMapping {
1373 asks_key: Some("asks".to_string()),
1374 bids_key: Some("bids".to_string()),
1375 level_format: Some("positional".to_string()),
1376 ..Default::default()
1377 },
1378 }),
1379 ticker: Some(EndpointDescriptor {
1380 path: "/api/v1/ticker".to_string(),
1381 method: HttpMethod::GET,
1382 params: [("symbol".to_string(), "{pair}".to_string())]
1383 .into_iter()
1384 .collect(),
1385 request_body: None,
1386 response_root: None,
1387 response: ResponseMapping {
1388 last_price: Some("lastPrice".to_string()),
1389 high_24h: Some("highPrice".to_string()),
1390 low_24h: Some("lowPrice".to_string()),
1391 volume_24h: Some("volume".to_string()),
1392 quote_volume_24h: Some("quoteVolume".to_string()),
1393 best_bid: Some("bidPrice".to_string()),
1394 best_ask: Some("askPrice".to_string()),
1395 ..Default::default()
1396 },
1397 }),
1398 trades: Some(EndpointDescriptor {
1399 path: "/api/v1/trades".to_string(),
1400 method: HttpMethod::GET,
1401 params: [
1402 ("symbol".to_string(), "{pair}".to_string()),
1403 ("limit".to_string(), "{limit}".to_string()),
1404 ]
1405 .into_iter()
1406 .collect(),
1407 request_body: None,
1408 response_root: None,
1409 response: ResponseMapping {
1410 price: Some("price".to_string()),
1411 quantity: Some("qty".to_string()),
1412 quote_quantity: Some("quoteQty".to_string()),
1413 timestamp_ms: Some("time".to_string()),
1414 id: Some("id".to_string()),
1415 side: Some(SideMapping {
1416 field: "isBuyerMaker".to_string(),
1417 mapping: [
1418 ("true".to_string(), "sell".to_string()),
1419 ("false".to_string(), "buy".to_string()),
1420 ]
1421 .into_iter()
1422 .collect(),
1423 }),
1424 ..Default::default()
1425 },
1426 }),
1427 ohlc: None,
1428 },
1429 }
1430 }
1431
1432 #[tokio::test]
1433 async fn test_fetch_order_book_via_http() {
1434 let mut server = mockito::Server::new_async().await;
1435 let mock = server
1436 .mock("GET", "/api/v1/depth")
1437 .match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded(
1438 "symbol".into(),
1439 "BTCUSDT".into(),
1440 )]))
1441 .with_status(200)
1442 .with_body(
1443 serde_json::json!({
1444 "asks": [["50010.0", "1.5"], ["50020.0", "2.0"]],
1445 "bids": [["50000.0", "1.0"], ["49990.0", "3.0"]]
1446 })
1447 .to_string(),
1448 )
1449 .create_async()
1450 .await;
1451
1452 let desc = make_http_test_descriptor(&server.url());
1453 let client = ConfigurableExchangeClient::new(desc);
1454 let book = client.fetch_order_book("BTCUSDT").await.unwrap();
1455 assert_eq!(book.asks.len(), 2);
1456 assert_eq!(book.bids.len(), 2);
1457 assert_eq!(book.asks[0].price, 50010.0);
1458 assert_eq!(book.bids[0].price, 50000.0);
1459 mock.assert_async().await;
1460 }
1461
1462 #[tokio::test]
1463 async fn test_fetch_ticker_via_http() {
1464 let mut server = mockito::Server::new_async().await;
1465 let mock = server
1466 .mock("GET", "/api/v1/ticker")
1467 .match_query(mockito::Matcher::UrlEncoded(
1468 "symbol".into(),
1469 "BTCUSDT".into(),
1470 ))
1471 .with_status(200)
1472 .with_body(
1473 serde_json::json!({
1474 "lastPrice": "50100.5",
1475 "highPrice": "51200.0",
1476 "lowPrice": "48800.0",
1477 "volume": "1234.56",
1478 "quoteVolume": "62000000.0",
1479 "bidPrice": "50095.0",
1480 "askPrice": "50105.0"
1481 })
1482 .to_string(),
1483 )
1484 .create_async()
1485 .await;
1486
1487 let desc = make_http_test_descriptor(&server.url());
1488 let client = ConfigurableExchangeClient::new(desc);
1489 let ticker = client.fetch_ticker("BTCUSDT").await.unwrap();
1490 assert_eq!(ticker.pair, "BTC/USDT");
1491 assert_eq!(ticker.last_price, Some(50100.5));
1492 assert_eq!(ticker.high_24h, Some(51200.0));
1493 assert_eq!(ticker.low_24h, Some(48800.0));
1494 assert_eq!(ticker.volume_24h, Some(1234.56));
1495 assert_eq!(ticker.quote_volume_24h, Some(62000000.0));
1496 assert_eq!(ticker.best_bid, Some(50095.0));
1497 assert_eq!(ticker.best_ask, Some(50105.0));
1498 mock.assert_async().await;
1499 }
1500
1501 #[tokio::test]
1502 async fn test_fetch_recent_trades_via_http() {
1503 let mut server = mockito::Server::new_async().await;
1504 let mock = server
1505 .mock("GET", "/api/v1/trades")
1506 .match_query(mockito::Matcher::AllOf(vec![
1507 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
1508 mockito::Matcher::UrlEncoded("limit".into(), "10".into()),
1509 ]))
1510 .with_status(200)
1511 .with_body(
1512 serde_json::json!([
1513 {
1514 "id": "trade-1",
1515 "price": "50000.0",
1516 "qty": "0.5",
1517 "quoteQty": "25000.0",
1518 "time": "1700000000000",
1519 "isBuyerMaker": true
1520 },
1521 {
1522 "id": "trade-2",
1523 "price": "50001.0",
1524 "qty": "1.0",
1525 "quoteQty": "50001.0",
1526 "time": "1700000001000",
1527 "isBuyerMaker": false
1528 }
1529 ])
1530 .to_string(),
1531 )
1532 .create_async()
1533 .await;
1534
1535 let desc = make_http_test_descriptor(&server.url());
1536 let client = ConfigurableExchangeClient::new(desc);
1537 let trades = client.fetch_recent_trades("BTCUSDT", 10).await.unwrap();
1538 assert_eq!(trades.len(), 2);
1539 assert_eq!(trades[0].price, 50000.0);
1540 assert_eq!(trades[0].quantity, 0.5);
1541 assert_eq!(trades[0].quote_quantity, Some(25000.0));
1542 assert_eq!(trades[0].timestamp_ms, 1700000000000);
1543 assert_eq!(trades[0].id.as_deref(), Some("trade-1"));
1544 assert_eq!(trades[0].side, TradeSide::Sell);
1545 assert_eq!(trades[1].price, 50001.0);
1546 assert_eq!(trades[1].quantity, 1.0);
1547 assert_eq!(trades[1].side, TradeSide::Buy);
1548 mock.assert_async().await;
1549 }
1550
1551 #[tokio::test]
1552 async fn test_fetch_order_book_http_error() {
1553 let mut server = mockito::Server::new_async().await;
1554 let mock = server
1555 .mock("GET", "/api/v1/depth")
1556 .match_query(mockito::Matcher::UrlEncoded(
1557 "symbol".into(),
1558 "BTCUSDT".into(),
1559 ))
1560 .with_status(500)
1561 .with_body("Internal Server Error")
1562 .create_async()
1563 .await;
1564
1565 let desc = make_http_test_descriptor(&server.url());
1566 let client = ConfigurableExchangeClient::new(desc);
1567 let err = client.fetch_order_book("BTCUSDT").await.unwrap_err();
1568 let err_msg = err.to_string();
1569 assert!(
1570 err_msg.contains("API error: HTTP 500"),
1571 "expected error message to contain 'API error: HTTP 500', got: {}",
1572 err_msg
1573 );
1574 mock.assert_async().await;
1575 }
1576
1577 #[tokio::test]
1578 async fn test_fetch_order_book_no_capability() {
1579 let desc = make_test_descriptor();
1580 let client = ConfigurableExchangeClient::new(desc);
1581 let err = client.fetch_order_book("BTCUSDT").await.unwrap_err();
1582 let err_msg = err.to_string();
1583 assert!(
1584 err_msg.contains("does not support order book"),
1585 "expected error message to contain 'does not support order book', got: {}",
1586 err_msg
1587 );
1588 }
1589
1590 #[tokio::test]
1591 async fn test_fetch_order_book_via_post() {
1592 use crate::market::descriptor::*;
1593 let mut server = mockito::Server::new_async().await;
1594 let mock = server
1595 .mock("POST", "/api/v1/depth")
1596 .match_body(mockito::Matcher::Json(serde_json::json!({
1597 "symbol": "BTCUSDT"
1598 })))
1599 .with_status(200)
1600 .with_body(
1601 serde_json::json!({
1602 "asks": [["50100.0", "2.0"]],
1603 "bids": [["50000.0", "1.0"]]
1604 })
1605 .to_string(),
1606 )
1607 .create_async()
1608 .await;
1609
1610 let mut desc = make_http_test_descriptor(&server.url());
1611 desc.capabilities.order_book = Some(EndpointDescriptor {
1612 path: "/api/v1/depth".to_string(),
1613 method: HttpMethod::POST,
1614 params: std::collections::HashMap::new(),
1615 request_body: Some(serde_json::json!({
1616 "symbol": "{pair}"
1617 })),
1618 response_root: None,
1619 response: ResponseMapping {
1620 asks_key: Some("asks".to_string()),
1621 bids_key: Some("bids".to_string()),
1622 level_format: Some("positional".to_string()),
1623 ..Default::default()
1624 },
1625 });
1626
1627 let client = ConfigurableExchangeClient::new(desc);
1628 let book = client.fetch_order_book("BTCUSDT").await.unwrap();
1629 assert_eq!(book.asks.len(), 1);
1630 assert_eq!(book.bids.len(), 1);
1631 assert_eq!(book.asks[0].price, 50100.0);
1632 assert_eq!(book.bids[0].price, 50000.0);
1633 mock.assert_async().await;
1634 }
1635
1636 #[tokio::test]
1637 async fn test_fetch_order_book_missing_asks_key() {
1638 let mut server = mockito::Server::new_async().await;
1639 let mock = server
1640 .mock("GET", "/api/v1/depth")
1641 .match_query(mockito::Matcher::UrlEncoded(
1642 "symbol".into(),
1643 "BTCUSDT".into(),
1644 ))
1645 .with_status(200)
1646 .with_body(
1647 serde_json::json!({
1648 "bids": [["50000.0", "1.0"]]
1649 })
1650 .to_string(),
1651 )
1652 .create_async()
1653 .await;
1654
1655 let desc = make_http_test_descriptor(&server.url());
1656 let client = ConfigurableExchangeClient::new(desc);
1657 let err = client.fetch_order_book("BTCUSDT").await.unwrap_err();
1658 let err_msg = err.to_string();
1659 assert!(
1660 err_msg.contains("missing 'asks'"),
1661 "expected error message to contain 'missing \\'asks\\'', got: {}",
1662 err_msg
1663 );
1664 mock.assert_async().await;
1665 }
1666
1667 #[tokio::test]
1668 async fn test_fetch_order_book_missing_bids_key() {
1669 let mut server = mockito::Server::new_async().await;
1670 let mock = server
1671 .mock("GET", "/api/v1/depth")
1672 .match_query(mockito::Matcher::UrlEncoded(
1673 "symbol".into(),
1674 "BTCUSDT".into(),
1675 ))
1676 .with_status(200)
1677 .with_body(
1678 serde_json::json!({
1679 "asks": [["50010.0", "1.5"]]
1680 })
1681 .to_string(),
1682 )
1683 .create_async()
1684 .await;
1685
1686 let desc = make_http_test_descriptor(&server.url());
1687 let client = ConfigurableExchangeClient::new(desc);
1688 let err = client.fetch_order_book("BTCUSDT").await.unwrap_err();
1689 let err_msg = err.to_string();
1690 assert!(
1691 err_msg.contains("missing 'bids'"),
1692 "expected error message to contain 'missing \\'bids\\'', got: {}",
1693 err_msg
1694 );
1695 mock.assert_async().await;
1696 }
1697
1698 #[tokio::test]
1699 async fn test_fetch_ticker_with_filter() {
1700 use crate::market::descriptor::*;
1701 let mut server = mockito::Server::new_async().await;
1702 let mock = server
1703 .mock("GET", "/api/v1/tickers")
1704 .match_query(mockito::Matcher::UrlEncoded(
1705 "symbol".into(),
1706 "BTCUSDT".into(),
1707 ))
1708 .with_status(200)
1709 .with_body(
1710 serde_json::json!([
1711 {"symbol": "ETHUSDT", "lastPrice": "3000.0"},
1712 {"symbol": "BTCUSDT", "lastPrice": "50100.5", "highPrice": "51200.0"},
1713 {"symbol": "BNBUSDT", "lastPrice": "400.0"}
1714 ])
1715 .to_string(),
1716 )
1717 .create_async()
1718 .await;
1719
1720 let mut desc = make_http_test_descriptor(&server.url());
1721 desc.capabilities.ticker = Some(EndpointDescriptor {
1722 path: "/api/v1/tickers".to_string(),
1723 method: HttpMethod::GET,
1724 params: [("symbol".to_string(), "{pair}".to_string())]
1725 .into_iter()
1726 .collect(),
1727 request_body: None,
1728 response_root: None,
1729 response: ResponseMapping {
1730 filter: Some(FilterConfig {
1731 field: "symbol".to_string(),
1732 value: "{pair}".to_string(),
1733 }),
1734 last_price: Some("lastPrice".to_string()),
1735 high_24h: Some("highPrice".to_string()),
1736 ..Default::default()
1737 },
1738 });
1739
1740 let client = ConfigurableExchangeClient::new(desc);
1741 let ticker = client.fetch_ticker("BTCUSDT").await.unwrap();
1742 assert_eq!(ticker.pair, "BTC/USDT");
1743 assert_eq!(ticker.last_price, Some(50100.5));
1744 assert_eq!(ticker.high_24h, Some(51200.0));
1745 mock.assert_async().await;
1746 }
1747
1748 #[tokio::test]
1749 async fn test_fetch_ticker_filter_no_match() {
1750 use crate::market::descriptor::*;
1751 let mut server = mockito::Server::new_async().await;
1752 let mock = server
1753 .mock("GET", "/api/v1/tickers")
1754 .match_query(mockito::Matcher::UrlEncoded(
1755 "symbol".into(),
1756 "BTCUSDT".into(),
1757 ))
1758 .with_status(200)
1759 .with_body(
1760 serde_json::json!([
1761 {"symbol": "ETHUSDT", "lastPrice": "3000.0"},
1762 {"symbol": "BNBUSDT", "lastPrice": "400.0"}
1763 ])
1764 .to_string(),
1765 )
1766 .create_async()
1767 .await;
1768
1769 let mut desc = make_http_test_descriptor(&server.url());
1770 desc.capabilities.ticker = Some(EndpointDescriptor {
1771 path: "/api/v1/tickers".to_string(),
1772 method: HttpMethod::GET,
1773 params: [("symbol".to_string(), "{pair}".to_string())]
1774 .into_iter()
1775 .collect(),
1776 request_body: None,
1777 response_root: None,
1778 response: ResponseMapping {
1779 filter: Some(FilterConfig {
1780 field: "symbol".to_string(),
1781 value: "{pair}".to_string(),
1782 }),
1783 last_price: Some("lastPrice".to_string()),
1784 ..Default::default()
1785 },
1786 });
1787
1788 let client = ConfigurableExchangeClient::new(desc);
1789 let err = client.fetch_ticker("BTCUSDT").await.unwrap_err();
1790 let err_msg = err.to_string();
1791 assert!(
1792 err_msg.contains("no ticker found for pair"),
1793 "expected error message to contain 'no ticker found for pair', got: {}",
1794 err_msg
1795 );
1796 mock.assert_async().await;
1797 }
1798
1799 #[tokio::test]
1800 async fn test_fetch_trades_non_array_response() {
1801 let mut server = mockito::Server::new_async().await;
1802 let mock = server
1803 .mock("GET", "/api/v1/trades")
1804 .match_query(mockito::Matcher::AllOf(vec![
1805 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
1806 mockito::Matcher::UrlEncoded("limit".into(), "10".into()),
1807 ]))
1808 .with_status(200)
1809 .with_body(
1810 serde_json::json!({
1811 "trades": [{"price": "50000", "qty": "1"}]
1812 })
1813 .to_string(),
1814 )
1815 .create_async()
1816 .await;
1817
1818 let desc = make_http_test_descriptor(&server.url());
1819 let client = ConfigurableExchangeClient::new(desc);
1820 let err = client.fetch_recent_trades("BTCUSDT", 10).await.unwrap_err();
1821 let err_msg = err.to_string();
1822 assert!(
1823 err_msg.contains("expected array for trades"),
1824 "expected error message to contain 'expected array for trades', got: {}",
1825 err_msg
1826 );
1827 mock.assert_async().await;
1828 }
1829
1830 #[tokio::test]
1831 async fn test_fetch_with_custom_headers() {
1832 let mut server = mockito::Server::new_async().await;
1833 let mut headers = std::collections::HashMap::new();
1834 headers.insert("X-Api-Key".to_string(), "test123".to_string());
1835 let mock = server
1836 .mock("GET", "/api/v1/ticker")
1837 .match_header("x-api-key", "test123")
1838 .match_query(mockito::Matcher::UrlEncoded(
1839 "symbol".into(),
1840 "BTCUSDT".into(),
1841 ))
1842 .with_status(200)
1843 .with_body(
1844 serde_json::json!({
1845 "lastPrice": "50100.5",
1846 "highPrice": "51200.0",
1847 "lowPrice": "48800.0",
1848 "volume": "1234.56",
1849 "quoteVolume": "62000000.0",
1850 "bidPrice": "50095.0",
1851 "askPrice": "50105.0"
1852 })
1853 .to_string(),
1854 )
1855 .create_async()
1856 .await;
1857
1858 let mut desc = make_http_test_descriptor(&server.url());
1859 desc.headers = headers;
1860 desc.capabilities.ticker.as_mut().unwrap().response = ResponseMapping {
1861 last_price: Some("lastPrice".to_string()),
1862 high_24h: Some("highPrice".to_string()),
1863 low_24h: Some("lowPrice".to_string()),
1864 volume_24h: Some("volume".to_string()),
1865 quote_volume_24h: Some("quoteVolume".to_string()),
1866 best_bid: Some("bidPrice".to_string()),
1867 best_ask: Some("askPrice".to_string()),
1868 ..Default::default()
1869 };
1870
1871 let client = ConfigurableExchangeClient::new(desc);
1872 let ticker = client.fetch_ticker("BTCUSDT").await.unwrap();
1873 assert_eq!(ticker.last_price, Some(50100.5));
1874 mock.assert_async().await;
1875 }
1876
1877 #[tokio::test]
1878 async fn test_fetch_trades_no_capability() {
1879 let desc = make_test_descriptor();
1880 let client = ConfigurableExchangeClient::new(desc);
1881 let err = client.fetch_recent_trades("BTCUSDT", 10).await.unwrap_err();
1882 let err_msg = err.to_string();
1883 assert!(
1884 err_msg.contains("does not support trades"),
1885 "expected error message to contain 'does not support trades', got: {}",
1886 err_msg
1887 );
1888 }
1889
1890 #[test]
1891 fn test_navigate_root_index_out_of_bounds() {
1892 let desc = make_test_descriptor();
1893 let client = ConfigurableExchangeClient::new(desc);
1894 let json = serde_json::json!({"data": [1, 2]});
1895 let result = client.navigate_root(&json, Some("data.5"));
1896 assert!(result.is_err());
1897 assert!(result.unwrap_err().to_string().contains("out of bounds"));
1898 }
1899
1900 #[test]
1901 fn test_navigate_root_missing_key() {
1902 let desc = make_test_descriptor();
1903 let client = ConfigurableExchangeClient::new(desc);
1904 let json = serde_json::json!({"data": {"nested": 1}});
1905 let result = client.navigate_root(&json, Some("data.missing_key"));
1906 assert!(result.is_err());
1907 assert!(result.unwrap_err().to_string().contains("missing key"));
1908 }
1909
1910 #[test]
1911 fn test_interpolate_json_array() {
1912 let desc = make_test_descriptor();
1913 let client = ConfigurableExchangeClient::new(desc);
1914 let template = serde_json::json!(["{pair}", "{limit}", 42]);
1915 let result = client.interpolate_json(&template, "BTC_USDT", "100");
1916 assert_eq!(result, serde_json::json!(["BTC_USDT", "100", 42]));
1917 }
1918
1919 #[test]
1920 fn test_interpolate_json_passthrough() {
1921 let desc = make_test_descriptor();
1922 let client = ConfigurableExchangeClient::new(desc);
1923 assert_eq!(
1924 client.interpolate_json(&serde_json::json!(42), "BTC", "100"),
1925 serde_json::json!(42)
1926 );
1927 assert_eq!(
1928 client.interpolate_json(&serde_json::json!(true), "BTC", "100"),
1929 serde_json::json!(true)
1930 );
1931 assert_eq!(
1932 client.interpolate_json(&serde_json::json!(null), "BTC", "100"),
1933 serde_json::json!(null)
1934 );
1935 }
1936
1937 fn make_ohlc_test_descriptor(base_url: &str) -> VenueDescriptor {
1942 use crate::market::descriptor::*;
1943 VenueDescriptor {
1944 id: "ohlc_mock".to_string(),
1945 name: "OHLC Mock".to_string(),
1946 base_url: base_url.to_string(),
1947 timeout_secs: Some(5),
1948 rate_limit_per_sec: None,
1949 symbol: SymbolConfig {
1950 template: "{base}{quote}".to_string(),
1951 default_quote: "USDT".to_string(),
1952 case: SymbolCase::Upper,
1953 },
1954 headers: std::collections::HashMap::new(),
1955 capabilities: CapabilitySet {
1956 order_book: None,
1957 ticker: None,
1958 trades: None,
1959 ohlc: Some(EndpointDescriptor {
1960 path: "/api/v1/klines".to_string(),
1961 method: HttpMethod::GET,
1962 params: [
1963 ("symbol".to_string(), "{pair}".to_string()),
1964 ("interval".to_string(), "{interval}".to_string()),
1965 ("limit".to_string(), "{limit}".to_string()),
1966 ]
1967 .into_iter()
1968 .collect(),
1969 request_body: None,
1970 response_root: None,
1971 response: ResponseMapping {
1972 ohlc_format: Some("array_of_arrays".to_string()),
1973 ohlc_fields: Some(vec![
1974 "open_time".to_string(),
1975 "open".to_string(),
1976 "high".to_string(),
1977 "low".to_string(),
1978 "close".to_string(),
1979 "volume".to_string(),
1980 "close_time".to_string(),
1981 ]),
1982 ..Default::default()
1983 },
1984 }),
1985 },
1986 }
1987 }
1988
1989 #[tokio::test]
1990 async fn test_fetch_ohlc_array_of_arrays() {
1991 let mut server = mockito::Server::new_async().await;
1992 let mock = server
1993 .mock("GET", "/api/v1/klines")
1994 .match_query(mockito::Matcher::AllOf(vec![
1995 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
1996 mockito::Matcher::UrlEncoded("interval".into(), "1h".into()),
1997 mockito::Matcher::UrlEncoded("limit".into(), "3".into()),
1998 ]))
1999 .with_status(200)
2000 .with_body(
2001 serde_json::json!([
2002 [
2003 1700000000000u64,
2004 "50000.0",
2005 "50500.0",
2006 "49800.0",
2007 "50200.0",
2008 "100.5",
2009 1700003599999u64
2010 ],
2011 [
2012 1700003600000u64,
2013 "50200.0",
2014 "50800.0",
2015 "50100.0",
2016 "50700.0",
2017 "120.3",
2018 1700007199999u64
2019 ],
2020 [
2021 1700007200000u64,
2022 "50700.0",
2023 "51000.0",
2024 "50600.0",
2025 "50900.0",
2026 "95.7",
2027 1700010799999u64
2028 ]
2029 ])
2030 .to_string(),
2031 )
2032 .create_async()
2033 .await;
2034
2035 let desc = make_ohlc_test_descriptor(&server.url());
2036 let client = ConfigurableExchangeClient::new(desc);
2037 let candles = client.fetch_ohlc("BTCUSDT", "1h", 3).await.unwrap();
2038
2039 assert_eq!(candles.len(), 3);
2040 assert_eq!(candles[0].open_time, 1700000000000);
2041 assert_eq!(candles[0].open, 50000.0);
2042 assert_eq!(candles[0].high, 50500.0);
2043 assert_eq!(candles[0].low, 49800.0);
2044 assert_eq!(candles[0].close, 50200.0);
2045 assert_eq!(candles[0].volume, 100.5);
2046 assert_eq!(candles[0].close_time, 1700003599999);
2047 assert_eq!(candles[2].open, 50700.0);
2048 mock.assert_async().await;
2049 }
2050
2051 #[tokio::test]
2052 async fn test_fetch_ohlc_object_format() {
2053 use crate::market::descriptor::*;
2054 let mut server = mockito::Server::new_async().await;
2055 let mock = server
2056 .mock("GET", "/api/v1/candles")
2057 .match_query(mockito::Matcher::AllOf(vec![
2058 mockito::Matcher::UrlEncoded("symbol".into(), "ETHUSDT".into()),
2059 mockito::Matcher::UrlEncoded("interval".into(), "15m".into()),
2060 mockito::Matcher::UrlEncoded("limit".into(), "2".into()),
2061 ]))
2062 .with_status(200)
2063 .with_body(
2064 serde_json::json!([
2065 {
2066 "ts": 1700000000000u64,
2067 "o": "3000.0",
2068 "h": "3050.0",
2069 "l": "2980.0",
2070 "c": "3020.0",
2071 "vol": "500.0",
2072 "ct": 1700000899999u64
2073 },
2074 {
2075 "ts": 1700000900000u64,
2076 "o": "3020.0",
2077 "h": "3080.0",
2078 "l": "3010.0",
2079 "c": "3060.0",
2080 "vol": "420.0",
2081 "ct": 1700001799999u64
2082 }
2083 ])
2084 .to_string(),
2085 )
2086 .create_async()
2087 .await;
2088
2089 let desc = VenueDescriptor {
2090 id: "ohlc_obj_mock".to_string(),
2091 name: "OHLC Obj Mock".to_string(),
2092 base_url: server.url(),
2093 timeout_secs: Some(5),
2094 rate_limit_per_sec: None,
2095 symbol: SymbolConfig {
2096 template: "{base}{quote}".to_string(),
2097 default_quote: "USDT".to_string(),
2098 case: SymbolCase::Upper,
2099 },
2100 headers: std::collections::HashMap::new(),
2101 capabilities: CapabilitySet {
2102 order_book: None,
2103 ticker: None,
2104 trades: None,
2105 ohlc: Some(EndpointDescriptor {
2106 path: "/api/v1/candles".to_string(),
2107 method: HttpMethod::GET,
2108 params: [
2109 ("symbol".to_string(), "{pair}".to_string()),
2110 ("interval".to_string(), "{interval}".to_string()),
2111 ("limit".to_string(), "{limit}".to_string()),
2112 ]
2113 .into_iter()
2114 .collect(),
2115 request_body: None,
2116 response_root: None,
2117 response: ResponseMapping {
2118 ohlc_format: Some("objects".to_string()),
2119 open_time: Some("ts".to_string()),
2120 open: Some("o".to_string()),
2121 high: Some("h".to_string()),
2122 low: Some("l".to_string()),
2123 close: Some("c".to_string()),
2124 ohlc_volume: Some("vol".to_string()),
2125 close_time: Some("ct".to_string()),
2126 ..Default::default()
2127 },
2128 }),
2129 },
2130 };
2131
2132 let client = ConfigurableExchangeClient::new(desc);
2133 let candles = client.fetch_ohlc("ETHUSDT", "15m", 2).await.unwrap();
2134
2135 assert_eq!(candles.len(), 2);
2136 assert_eq!(candles[0].open_time, 1700000000000);
2137 assert_eq!(candles[0].open, 3000.0);
2138 assert_eq!(candles[0].high, 3050.0);
2139 assert_eq!(candles[0].low, 2980.0);
2140 assert_eq!(candles[0].close, 3020.0);
2141 assert_eq!(candles[0].volume, 500.0);
2142 assert_eq!(candles[1].close, 3060.0);
2143 mock.assert_async().await;
2144 }
2145
2146 #[tokio::test]
2147 async fn test_fetch_ohlc_no_capability() {
2148 let desc = make_test_descriptor();
2149 let client = ConfigurableExchangeClient::new(desc);
2150 let err = client.fetch_ohlc("BTCUSDT", "1h", 100).await.unwrap_err();
2151 let msg = err.to_string();
2152 assert!(
2153 msg.contains("does not support OHLC"),
2154 "expected OHLC error, got: {}",
2155 msg
2156 );
2157 }
2158
2159 #[tokio::test]
2160 async fn test_fetch_ohlc_non_array_response() {
2161 let mut server = mockito::Server::new_async().await;
2162 let mock = server
2163 .mock("GET", "/api/v1/klines")
2164 .match_query(mockito::Matcher::AllOf(vec![
2165 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2166 mockito::Matcher::UrlEncoded("interval".into(), "1h".into()),
2167 mockito::Matcher::UrlEncoded("limit".into(), "100".into()),
2168 ]))
2169 .with_status(200)
2170 .with_body(serde_json::json!({"error": "not an array"}).to_string())
2171 .create_async()
2172 .await;
2173
2174 let desc = make_ohlc_test_descriptor(&server.url());
2175 let client = ConfigurableExchangeClient::new(desc);
2176 let err = client.fetch_ohlc("BTCUSDT", "1h", 100).await.unwrap_err();
2177 let msg = err.to_string();
2178 assert!(
2179 msg.contains("expected array for OHLC"),
2180 "expected array error, got: {}",
2181 msg
2182 );
2183 mock.assert_async().await;
2184 }
2185
2186 #[tokio::test]
2187 async fn test_fetch_ohlc_empty_array() {
2188 let mut server = mockito::Server::new_async().await;
2189 let mock = server
2190 .mock("GET", "/api/v1/klines")
2191 .match_query(mockito::Matcher::AllOf(vec![
2192 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2193 mockito::Matcher::UrlEncoded("interval".into(), "1d".into()),
2194 mockito::Matcher::UrlEncoded("limit".into(), "10".into()),
2195 ]))
2196 .with_status(200)
2197 .with_body("[]")
2198 .create_async()
2199 .await;
2200
2201 let desc = make_ohlc_test_descriptor(&server.url());
2202 let client = ConfigurableExchangeClient::new(desc);
2203 let candles = client.fetch_ohlc("BTCUSDT", "1d", 10).await.unwrap();
2204 assert!(candles.is_empty());
2205 mock.assert_async().await;
2206 }
2207
2208 #[tokio::test]
2209 async fn test_fetch_ohlc_skips_malformed_inner_items() {
2210 let mut server = mockito::Server::new_async().await;
2211 let mock = server
2212 .mock("GET", "/api/v1/klines")
2213 .match_query(mockito::Matcher::AllOf(vec![
2214 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2215 mockito::Matcher::UrlEncoded("interval".into(), "1h".into()),
2216 mockito::Matcher::UrlEncoded("limit".into(), "5".into()),
2217 ]))
2218 .with_status(200)
2219 .with_body(
2220 serde_json::json!([
2221 "not an array",
2222 [
2223 1700000000000u64,
2224 "50000.0",
2225 "50500.0",
2226 "49800.0",
2227 "50200.0",
2228 "100.5",
2229 1700003599999u64
2230 ],
2231 42
2232 ])
2233 .to_string(),
2234 )
2235 .create_async()
2236 .await;
2237
2238 let desc = make_ohlc_test_descriptor(&server.url());
2239 let client = ConfigurableExchangeClient::new(desc);
2240 let candles = client.fetch_ohlc("BTCUSDT", "1h", 5).await.unwrap();
2241 assert_eq!(candles.len(), 1);
2243 assert_eq!(candles[0].open, 50000.0);
2244 mock.assert_async().await;
2245 }
2246
2247 #[tokio::test]
2248 async fn test_fetch_ohlc_with_response_root() {
2249 let mut server = mockito::Server::new_async().await;
2250 let mock = server
2251 .mock("GET", "/api/v1/klines")
2252 .match_query(mockito::Matcher::AllOf(vec![
2253 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2254 mockito::Matcher::UrlEncoded("interval".into(), "4h".into()),
2255 mockito::Matcher::UrlEncoded("limit".into(), "2".into()),
2256 ]))
2257 .with_status(200)
2258 .with_body(
2259 serde_json::json!({
2260 "result": [
2261 [1700000000000u64, "50000.0", "50500.0", "49800.0", "50200.0", "100.5", 1700003599999u64],
2262 [1700003600000u64, "50200.0", "50800.0", "50100.0", "50700.0", "120.3", 1700007199999u64]
2263 ]
2264 })
2265 .to_string(),
2266 )
2267 .create_async()
2268 .await;
2269
2270 let mut desc = make_ohlc_test_descriptor(&server.url());
2271 desc.capabilities.ohlc.as_mut().unwrap().response_root = Some("result".to_string());
2272
2273 let client = ConfigurableExchangeClient::new(desc);
2274 let candles = client.fetch_ohlc("BTCUSDT", "4h", 2).await.unwrap();
2275 assert_eq!(candles.len(), 2);
2276 mock.assert_async().await;
2277 }
2278
2279 #[test]
2280 fn test_interpolate_value_full_with_interval() {
2281 let desc = make_test_descriptor();
2282 let client = ConfigurableExchangeClient::new(desc);
2283 let result =
2284 client.interpolate_value_full("{pair}_{interval}_{limit}", "BTCUSDT", "50", "1h");
2285 assert_eq!(result, "BTCUSDT_1h_50");
2286 }
2287
2288 #[test]
2289 fn test_interpolate_json_full_with_interval() {
2290 let desc = make_test_descriptor();
2291 let client = ConfigurableExchangeClient::new(desc);
2292 let template = serde_json::json!({
2293 "symbol": "{pair}",
2294 "interval": "{interval}",
2295 "limit": "{limit}"
2296 });
2297 let result = client.interpolate_json_full(&template, "ETHUSDT", "100", "15m");
2298 assert_eq!(result["symbol"], "ETHUSDT");
2299 assert_eq!(result["interval"], "15m");
2300 assert_eq!(result["limit"], "100");
2301 }
2302
2303 #[tokio::test]
2304 async fn test_fetch_ohlc_via_post_method() {
2305 use crate::market::descriptor::*;
2306 let mut server = mockito::Server::new_async().await;
2307 let mock = server
2308 .mock("POST", "/api/v1/klines")
2309 .with_status(200)
2310 .with_body(
2311 serde_json::json!([[
2312 1700000000000u64,
2313 "50000.0",
2314 "50500.0",
2315 "49800.0",
2316 "50200.0",
2317 "100.5",
2318 1700003599999u64
2319 ]])
2320 .to_string(),
2321 )
2322 .create_async()
2323 .await;
2324
2325 let desc = VenueDescriptor {
2326 id: "post_ohlc".to_string(),
2327 name: "POST OHLC".to_string(),
2328 base_url: server.url(),
2329 timeout_secs: Some(5),
2330 rate_limit_per_sec: None,
2331 symbol: SymbolConfig {
2332 template: "{base}{quote}".to_string(),
2333 default_quote: "USDT".to_string(),
2334 case: SymbolCase::Upper,
2335 },
2336 headers: std::collections::HashMap::new(),
2337 capabilities: CapabilitySet {
2338 order_book: None,
2339 ticker: None,
2340 trades: None,
2341 ohlc: Some(EndpointDescriptor {
2342 path: "/api/v1/klines".to_string(),
2343 method: HttpMethod::POST,
2344 params: std::collections::HashMap::new(),
2345 request_body: Some(serde_json::json!({
2346 "symbol": "{pair}",
2347 "interval": "{interval}",
2348 "limit": "{limit}"
2349 })),
2350 response_root: None,
2351 response: ResponseMapping {
2352 ohlc_format: Some("array_of_arrays".to_string()),
2353 ohlc_fields: Some(vec![
2354 "open_time".to_string(),
2355 "open".to_string(),
2356 "high".to_string(),
2357 "low".to_string(),
2358 "close".to_string(),
2359 "volume".to_string(),
2360 "close_time".to_string(),
2361 ]),
2362 ..Default::default()
2363 },
2364 }),
2365 },
2366 };
2367
2368 let client = ConfigurableExchangeClient::new(desc);
2369 let candles = client.fetch_ohlc("BTCUSDT", "1h", 1).await.unwrap();
2370 assert_eq!(candles.len(), 1);
2371 assert_eq!(candles[0].open, 50000.0);
2372 mock.assert_async().await;
2373 }
2374
2375 #[tokio::test]
2376 async fn test_fetch_ohlc_post_http_error() {
2377 use crate::market::descriptor::*;
2378 let mut server = mockito::Server::new_async().await;
2379 let mock = server
2380 .mock("POST", "/api/v1/klines")
2381 .with_status(500)
2382 .with_body("Internal Server Error")
2383 .create_async()
2384 .await;
2385
2386 let desc = VenueDescriptor {
2387 id: "post_ohlc_err".to_string(),
2388 name: "POST OHLC Err".to_string(),
2389 base_url: server.url(),
2390 timeout_secs: Some(5),
2391 rate_limit_per_sec: None,
2392 symbol: SymbolConfig {
2393 template: "{base}{quote}".to_string(),
2394 default_quote: "USDT".to_string(),
2395 case: SymbolCase::Upper,
2396 },
2397 headers: std::collections::HashMap::new(),
2398 capabilities: CapabilitySet {
2399 order_book: None,
2400 ticker: None,
2401 trades: None,
2402 ohlc: Some(EndpointDescriptor {
2403 path: "/api/v1/klines".to_string(),
2404 method: HttpMethod::POST,
2405 params: std::collections::HashMap::new(),
2406 request_body: None,
2407 response_root: None,
2408 response: ResponseMapping {
2409 ohlc_format: Some("array_of_arrays".to_string()),
2410 ..Default::default()
2411 },
2412 }),
2413 },
2414 };
2415
2416 let client = ConfigurableExchangeClient::new(desc);
2417 let err = client.fetch_ohlc("BTCUSDT", "1h", 100).await.unwrap_err();
2418 assert!(err.to_string().contains("API error"));
2419 mock.assert_async().await;
2420 }
2421
2422 #[tokio::test]
2423 async fn test_fetch_ohlc_get_http_error() {
2424 let mut server = mockito::Server::new_async().await;
2425 let mock = server
2426 .mock("GET", "/api/v1/klines")
2427 .match_query(mockito::Matcher::AllOf(vec![
2428 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2429 mockito::Matcher::UrlEncoded("interval".into(), "1h".into()),
2430 mockito::Matcher::UrlEncoded("limit".into(), "100".into()),
2431 ]))
2432 .with_status(429)
2433 .with_body("Rate limited")
2434 .create_async()
2435 .await;
2436
2437 let desc = make_ohlc_test_descriptor(&server.url());
2438 let client = ConfigurableExchangeClient::new(desc);
2439 let err = client.fetch_ohlc("BTCUSDT", "1h", 100).await.unwrap_err();
2440 assert!(err.to_string().contains("API error"));
2441 mock.assert_async().await;
2442 }
2443
2444 #[tokio::test]
2445 async fn test_fetch_ohlc_with_items_key() {
2446 use crate::market::descriptor::*;
2447 let mut server = mockito::Server::new_async().await;
2448 let mock = server
2449 .mock("GET", "/api/v1/candles")
2450 .match_query(mockito::Matcher::AllOf(vec![
2451 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2452 mockito::Matcher::UrlEncoded("interval".into(), "1h".into()),
2453 mockito::Matcher::UrlEncoded("limit".into(), "2".into()),
2454 ]))
2455 .with_status(200)
2456 .with_body(
2457 serde_json::json!({
2458 "data": [
2459 {"ts": 1700000000000u64, "o": "100.0", "h": "110.0", "l": "90.0", "c": "105.0", "vol": "1000.0", "ct": 1700003599999u64},
2460 {"ts": 1700003600000u64, "o": "105.0", "h": "115.0", "l": "100.0", "c": "110.0", "vol": "800.0", "ct": 1700007199999u64}
2461 ]
2462 })
2463 .to_string(),
2464 )
2465 .create_async()
2466 .await;
2467
2468 let desc = VenueDescriptor {
2469 id: "items_key_ohlc".to_string(),
2470 name: "Items Key OHLC".to_string(),
2471 base_url: server.url(),
2472 timeout_secs: Some(5),
2473 rate_limit_per_sec: None,
2474 symbol: SymbolConfig {
2475 template: "{base}{quote}".to_string(),
2476 default_quote: "USDT".to_string(),
2477 case: SymbolCase::Upper,
2478 },
2479 headers: std::collections::HashMap::new(),
2480 capabilities: CapabilitySet {
2481 order_book: None,
2482 ticker: None,
2483 trades: None,
2484 ohlc: Some(EndpointDescriptor {
2485 path: "/api/v1/candles".to_string(),
2486 method: HttpMethod::GET,
2487 params: [
2488 ("symbol".to_string(), "{pair}".to_string()),
2489 ("interval".to_string(), "{interval}".to_string()),
2490 ("limit".to_string(), "{limit}".to_string()),
2491 ]
2492 .into_iter()
2493 .collect(),
2494 request_body: None,
2495 response_root: None,
2496 response: ResponseMapping {
2497 items_key: Some("data".to_string()),
2498 ohlc_format: Some("objects".to_string()),
2499 open_time: Some("ts".to_string()),
2500 open: Some("o".to_string()),
2501 high: Some("h".to_string()),
2502 low: Some("l".to_string()),
2503 close: Some("c".to_string()),
2504 ohlc_volume: Some("vol".to_string()),
2505 close_time: Some("ct".to_string()),
2506 ..Default::default()
2507 },
2508 }),
2509 },
2510 };
2511
2512 let client = ConfigurableExchangeClient::new(desc);
2513 let candles = client.fetch_ohlc("BTCUSDT", "1h", 2).await.unwrap();
2514 assert_eq!(candles.len(), 2);
2515 assert_eq!(candles[0].open, 100.0);
2516 assert_eq!(candles[1].close, 110.0);
2517 mock.assert_async().await;
2518 }
2519}