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_format_display_pair_eur_concatenated() {
824 assert_eq!(format_display_pair("XBTEUR", "{base}{quote}"), "XBT/EUR");
825 }
826
827 #[test]
828 fn test_format_display_pair_gbp_concatenated() {
829 assert_eq!(format_display_pair("XBTGBP", "{base}{quote}"), "XBT/GBP");
830 }
831
832 #[test]
833 fn test_format_display_pair_usdc_concatenated() {
834 assert_eq!(format_display_pair("ETHUSDC", "{base}{quote}"), "ETH/USDC");
835 }
836
837 #[test]
838 fn test_format_display_pair_no_quote_match_returns_raw() {
839 assert_eq!(format_display_pair("XYZABC", "{base}{quote}"), "XYZABC");
840 }
841
842 #[test]
843 fn test_format_display_pair_base_zero_len_returns_raw() {
844 assert_eq!(format_display_pair("USDT", "{base}{quote}"), "USDT");
845 }
846
847 #[test]
848 fn test_value_to_f64_number() {
849 let val = serde_json::json!(42.5);
850 assert_eq!(value_to_f64(&val), Some(42.5));
851 }
852
853 #[test]
854 fn test_value_to_f64_string() {
855 let val = serde_json::json!("42.5");
856 assert_eq!(value_to_f64(&val), Some(42.5));
857 }
858
859 #[test]
860 fn test_value_to_f64_invalid() {
861 let val = serde_json::json!(null);
862 assert_eq!(value_to_f64(&val), None);
863 }
864
865 #[test]
866 fn test_navigate_root_empty() {
867 let desc = make_test_descriptor();
868 let client = ConfigurableExchangeClient::new(desc);
869 let json = serde_json::json!({"price": 42});
870 let result = client.navigate_root(&json, None).unwrap();
871 assert_eq!(result, &json);
872 }
873
874 #[test]
875 fn test_navigate_root_single_key() {
876 let desc = make_test_descriptor();
877 let client = ConfigurableExchangeClient::new(desc);
878 let json = serde_json::json!({"result": {"price": 42}});
879 let result = client.navigate_root(&json, Some("result")).unwrap();
880 assert_eq!(result, &serde_json::json!({"price": 42}));
881 }
882
883 #[test]
884 fn test_navigate_root_nested_with_index() {
885 let desc = make_test_descriptor();
886 let client = ConfigurableExchangeClient::new(desc);
887 let json = serde_json::json!({"data": [{"price": 42}, {"price": 43}]});
888 let result = client.navigate_root(&json, Some("data.0")).unwrap();
889 assert_eq!(result, &serde_json::json!({"price": 42}));
890 }
891
892 #[test]
893 fn test_navigate_root_wildcard() {
894 let desc = make_test_descriptor();
895 let client = ConfigurableExchangeClient::new(desc);
896 let json = serde_json::json!({"result": {"XXBTZUSD": {"a": ["42000.0"]}}});
897 let result = client.navigate_root(&json, Some("result.*")).unwrap();
898 assert_eq!(result, &serde_json::json!({"a": ["42000.0"]}));
899 }
900
901 #[test]
902 fn test_extract_f64_nested() {
903 let desc = make_test_descriptor();
904 let client = ConfigurableExchangeClient::new(desc);
905 let data = serde_json::json!({"c": ["42000.5", "1.5"]});
906 assert_eq!(client.extract_f64(&data, "c.0"), Some(42000.5));
907 assert_eq!(client.extract_f64(&data, "c.1"), Some(1.5));
908 }
909
910 #[test]
911 fn test_parse_positional_levels() {
912 let desc = make_test_descriptor();
913 let client = ConfigurableExchangeClient::new(desc);
914 let arr = serde_json::json!([["42000.0", "1.5"], ["42001.0", "2.0"]]);
915 let mapping = ResponseMapping {
916 level_format: Some("positional".to_string()),
917 ..Default::default()
918 };
919 let levels = client.parse_levels(&arr, &mapping).unwrap();
920 assert_eq!(levels.len(), 2);
921 assert_eq!(levels[0].price, 42000.0);
922 assert_eq!(levels[0].quantity, 1.5);
923 }
924
925 #[test]
926 fn test_parse_object_levels() {
927 let desc = make_test_descriptor();
928 let client = ConfigurableExchangeClient::new(desc);
929 let arr = serde_json::json!([
930 {"price": "42000.0", "size": "1.5"},
931 {"price": "42001.0", "size": "2.0"}
932 ]);
933 let mapping = ResponseMapping {
934 level_format: Some("object".to_string()),
935 level_price_field: Some("price".to_string()),
936 level_size_field: Some("size".to_string()),
937 ..Default::default()
938 };
939 let levels = client.parse_levels(&arr, &mapping).unwrap();
940 assert_eq!(levels.len(), 2);
941 assert_eq!(levels[0].price, 42000.0);
942 assert_eq!(levels[0].quantity, 1.5);
943 }
944
945 #[test]
946 fn test_parse_side_mapping() {
947 let desc = make_test_descriptor();
948 let client = ConfigurableExchangeClient::new(desc);
949 let data = serde_json::json!({"isBuyerMaker": true});
950 let mapping = ResponseMapping {
951 side: Some(crate::market::descriptor::SideMapping {
952 field: "isBuyerMaker".to_string(),
953 mapping: [
954 ("true".to_string(), "sell".to_string()),
955 ("false".to_string(), "buy".to_string()),
956 ]
957 .into_iter()
958 .collect(),
959 }),
960 ..Default::default()
961 };
962 assert_eq!(client.parse_side(&data, &mapping), TradeSide::Sell);
963 }
964
965 #[test]
966 fn test_interpolate_json() {
967 let desc = make_test_descriptor();
968 let client = ConfigurableExchangeClient::new(desc);
969 let template = serde_json::json!({
970 "method": "get-book",
971 "params": {"instrument": "{pair}", "depth": "{limit}"}
972 });
973 let result = client.interpolate_json(&template, "BTC_USDT", "100");
974 assert_eq!(
975 result,
976 serde_json::json!({
977 "method": "get-book",
978 "params": {"instrument": "BTC_USDT", "depth": "100"}
979 })
980 );
981 }
982
983 #[test]
984 fn test_interpolate_json_array_with_placeholders() {
985 let desc = make_test_descriptor();
986 let client = ConfigurableExchangeClient::new(desc);
987 let template = serde_json::json!({
988 "pairs": ["{pair}", "limit:{limit}"]
989 });
990 let result = client.interpolate_json(&template, "BTCUSDT", "50");
991 assert_eq!(result["pairs"][0], "BTCUSDT");
992 assert_eq!(result["pairs"][1], "limit:50");
993 }
994
995 #[test]
996 fn test_interpolate_json_preserves_primitive_types() {
997 let desc = make_test_descriptor();
998 let client = ConfigurableExchangeClient::new(desc);
999 let template = serde_json::json!({
1000 "flag": true,
1001 "count": 42,
1002 "name": "static"
1003 });
1004 let result = client.interpolate_json(&template, "BTC", "10");
1005 assert_eq!(result["flag"], true);
1006 assert_eq!(result["count"], 42);
1007 assert_eq!(result["name"], "static");
1008 }
1009
1010 fn make_test_descriptor() -> VenueDescriptor {
1011 use crate::market::descriptor::*;
1012 VenueDescriptor {
1013 id: "test".to_string(),
1014 name: "Test".to_string(),
1015 base_url: "https://example.com".to_string(),
1016 timeout_secs: Some(5),
1017 rate_limit_per_sec: None,
1018 symbol: SymbolConfig {
1019 template: "{base}{quote}".to_string(),
1020 default_quote: "USDT".to_string(),
1021 case: SymbolCase::Upper,
1022 },
1023 headers: std::collections::HashMap::new(),
1024 capabilities: CapabilitySet::default(),
1025 }
1026 }
1027
1028 #[test]
1034 fn test_descriptor_accessor() {
1035 let desc = make_test_descriptor();
1036 let client = ConfigurableExchangeClient::new(desc.clone());
1037 assert_eq!(client.descriptor().id, "test");
1038 assert_eq!(client.descriptor().name, "Test");
1039 }
1040
1041 #[test]
1042 fn test_format_pair_accessor() {
1043 let desc = make_test_descriptor();
1044 let client = ConfigurableExchangeClient::new(desc);
1045 assert_eq!(client.format_pair("BTC", None), "BTCUSDT");
1046 assert_eq!(client.format_pair("ETH", Some("USD")), "ETHUSD");
1047 }
1048
1049 #[test]
1050 fn test_navigate_root_empty_string_path() {
1051 let desc = make_test_descriptor();
1052 let client = ConfigurableExchangeClient::new(desc);
1053 let json = serde_json::json!({"price": 42});
1054 let result = client.navigate_root(&json, Some("")).unwrap();
1055 assert_eq!(result, &json);
1056 }
1057
1058 #[test]
1059 fn test_navigate_root_wildcard_on_non_object_null() {
1060 let desc = make_test_descriptor();
1061 let client = ConfigurableExchangeClient::new(desc);
1062 let json = serde_json::json!({"result": null});
1063 let result = client.navigate_root(&json, Some("result.*"));
1064 assert!(result.is_err());
1065 let err_msg = format!("{:?}", result.unwrap_err());
1066 assert!(err_msg.contains("null"), "error should mention null type");
1067 }
1068
1069 #[test]
1070 fn test_navigate_root_wildcard_on_non_object_bool() {
1071 let desc = make_test_descriptor();
1072 let client = ConfigurableExchangeClient::new(desc);
1073 let json = serde_json::json!({"result": true});
1074 let result = client.navigate_root(&json, Some("result.*"));
1075 assert!(result.is_err());
1076 let err_msg = format!("{:?}", result.unwrap_err());
1077 assert!(err_msg.contains("bool"), "error should mention bool type");
1078 }
1079
1080 #[test]
1081 fn test_navigate_root_wildcard_on_non_object_number() {
1082 let desc = make_test_descriptor();
1083 let client = ConfigurableExchangeClient::new(desc);
1084 let json = serde_json::json!({"result": 42});
1085 let result = client.navigate_root(&json, Some("result.*"));
1086 assert!(result.is_err());
1087 let err_msg = format!("{:?}", result.unwrap_err());
1088 assert!(
1089 err_msg.contains("number"),
1090 "error should mention number type"
1091 );
1092 }
1093
1094 #[test]
1095 fn test_navigate_root_wildcard_on_non_object_string() {
1096 let desc = make_test_descriptor();
1097 let client = ConfigurableExchangeClient::new(desc);
1098 let json = serde_json::json!({"result": "not_an_object"});
1099 let result = client.navigate_root(&json, Some("result.*"));
1100 assert!(result.is_err());
1101 let err_msg = format!("{:?}", result.unwrap_err());
1102 assert!(
1103 err_msg.contains("string"),
1104 "error should mention string type"
1105 );
1106 }
1107
1108 #[test]
1109 fn test_navigate_root_wildcard_on_non_object_array() {
1110 let desc = make_test_descriptor();
1111 let client = ConfigurableExchangeClient::new(desc);
1112 let json = serde_json::json!({"result": [1, 2, 3]});
1113 let result = client.navigate_root(&json, Some("result.*"));
1114 assert!(result.is_err());
1115 let err_msg = format!("{:?}", result.unwrap_err());
1116 assert!(err_msg.contains("array"), "error should mention array type");
1117 }
1118
1119 #[test]
1120 fn test_navigate_root_wildcard_empty_object() {
1121 let desc = make_test_descriptor();
1122 let client = ConfigurableExchangeClient::new(desc);
1123 let json = serde_json::json!({"result": {}});
1124 let result = client.navigate_root(&json, Some("result.*"));
1125 assert!(result.is_err());
1126 let err_msg = format!("{:?}", result.unwrap_err());
1127 assert!(
1128 err_msg.contains("empty object"),
1129 "error should mention empty object"
1130 );
1131 }
1132
1133 #[test]
1134 fn test_extract_string_from_string() {
1135 let desc = make_test_descriptor();
1136 let client = ConfigurableExchangeClient::new(desc);
1137 let data = serde_json::json!({"id": "abc123"});
1138 assert_eq!(
1139 client.extract_string(&data, "id").as_deref(),
1140 Some("abc123")
1141 );
1142 }
1143
1144 #[test]
1145 fn test_extract_string_from_number() {
1146 let desc = make_test_descriptor();
1147 let client = ConfigurableExchangeClient::new(desc);
1148 let data = serde_json::json!({"id": 12345});
1149 assert_eq!(client.extract_string(&data, "id").as_deref(), Some("12345"));
1150 }
1151
1152 #[test]
1153 fn test_extract_string_from_nested_path() {
1154 let desc = make_test_descriptor();
1155 let client = ConfigurableExchangeClient::new(desc);
1156 let data = serde_json::json!({"a": {"b": {"c": ["x", "value"]}}});
1157 assert_eq!(
1158 client.extract_string(&data, "a.b.c.1").as_deref(),
1159 Some("value")
1160 );
1161 }
1162
1163 #[test]
1164 fn test_extract_string_returns_none_for_object() {
1165 let desc = make_test_descriptor();
1166 let client = ConfigurableExchangeClient::new(desc);
1167 let data = serde_json::json!({"id": {"nested": "obj"}});
1168 assert_eq!(client.extract_string(&data, "id"), None);
1169 }
1170
1171 #[test]
1172 fn test_extract_string_returns_none_for_array() {
1173 let desc = make_test_descriptor();
1174 let client = ConfigurableExchangeClient::new(desc);
1175 let data = serde_json::json!({"id": [1, 2, 3]});
1176 assert_eq!(client.extract_string(&data, "id"), None);
1177 }
1178
1179 #[test]
1180 fn test_navigate_field_deeper_path() {
1181 let desc = make_test_descriptor();
1182 let client = ConfigurableExchangeClient::new(desc);
1183 let data = serde_json::json!({"level1": {"level2": {"level3": [0, 99.5]}}});
1184 assert_eq!(
1185 client.extract_f64(&data, "level1.level2.level3.1"),
1186 Some(99.5)
1187 );
1188 }
1189
1190 #[test]
1191 fn test_parse_levels_empty_array() {
1192 let desc = make_test_descriptor();
1193 let client = ConfigurableExchangeClient::new(desc);
1194 let arr = serde_json::json!([]);
1195 let mapping = ResponseMapping::default();
1196 let levels = client.parse_levels(&arr, &mapping).unwrap();
1197 assert!(levels.is_empty());
1198 }
1199
1200 #[test]
1201 fn test_parse_levels_not_array_err() {
1202 let desc = make_test_descriptor();
1203 let client = ConfigurableExchangeClient::new(desc);
1204 let not_arr = serde_json::json!({"not": "array"});
1205 let mapping = ResponseMapping::default();
1206 let result = client.parse_levels(¬_arr, &mapping);
1207 assert!(result.is_err());
1208 let err_msg = format!("{:?}", result.unwrap_err());
1209 assert!(err_msg.contains("expected array"));
1210 }
1211
1212 #[test]
1213 fn test_parse_levels_filters_zero_price_and_quantity() {
1214 let desc = make_test_descriptor();
1215 let client = ConfigurableExchangeClient::new(desc);
1216 let arr = serde_json::json!([
1217 ["42000.0", "1.5"],
1218 ["0.0", "1.0"],
1219 ["42001.0", "0.0"],
1220 ["42002.0", ""]
1221 ]);
1222 let mapping = ResponseMapping {
1223 level_format: Some("positional".to_string()),
1224 ..Default::default()
1225 };
1226 let levels = client.parse_levels(&arr, &mapping).unwrap();
1227 assert_eq!(levels.len(), 1);
1228 assert_eq!(levels[0].price, 42000.0);
1229 assert_eq!(levels[0].quantity, 1.5);
1230 }
1231
1232 #[test]
1233 fn test_parse_levels_object_format_default_fields() {
1234 let desc = make_test_descriptor();
1235 let client = ConfigurableExchangeClient::new(desc);
1236 let arr = serde_json::json!([
1237 {"price": "100.0", "size": "2.0"},
1238 {"price": "101.0", "size": "3.0"}
1239 ]);
1240 let mapping = ResponseMapping {
1241 level_format: Some("object".to_string()),
1242 level_price_field: None,
1243 level_size_field: None,
1244 ..Default::default()
1245 };
1246 let levels = client.parse_levels(&arr, &mapping).unwrap();
1247 assert_eq!(levels.len(), 2);
1248 assert_eq!(levels[0].price, 100.0);
1249 assert_eq!(levels[0].quantity, 2.0);
1250 }
1251
1252 #[test]
1253 fn test_parse_side_no_mapping_returns_buy() {
1254 let desc = make_test_descriptor();
1255 let client = ConfigurableExchangeClient::new(desc);
1256 let data = serde_json::json!({"side": "sell"});
1257 let mapping = ResponseMapping {
1258 side: None,
1259 ..Default::default()
1260 };
1261 assert_eq!(client.parse_side(&data, &mapping), TradeSide::Buy);
1262 }
1263
1264 #[test]
1265 fn test_parse_side_field_missing_returns_buy() {
1266 let desc = make_test_descriptor();
1267 let client = ConfigurableExchangeClient::new(desc);
1268 let data = serde_json::json!({});
1269 let mapping = ResponseMapping {
1270 side: Some(crate::market::descriptor::SideMapping {
1271 field: "nonexistent".to_string(),
1272 mapping: [("sell".to_string(), "sell".to_string())]
1273 .into_iter()
1274 .collect(),
1275 }),
1276 ..Default::default()
1277 };
1278 assert_eq!(client.parse_side(&data, &mapping), TradeSide::Buy);
1279 }
1280
1281 #[test]
1282 fn test_parse_side_string_mapped_to_sell() {
1283 let desc = make_test_descriptor();
1284 let client = ConfigurableExchangeClient::new(desc);
1285 let data = serde_json::json!({"side": "ask"});
1286 let mapping = ResponseMapping {
1287 side: Some(crate::market::descriptor::SideMapping {
1288 field: "side".to_string(),
1289 mapping: [
1290 ("ask".to_string(), "sell".to_string()),
1291 ("bid".to_string(), "buy".to_string()),
1292 ]
1293 .into_iter()
1294 .collect(),
1295 }),
1296 ..Default::default()
1297 };
1298 assert_eq!(client.parse_side(&data, &mapping), TradeSide::Sell);
1299 }
1300
1301 #[test]
1302 fn test_parse_side_string_mapped_to_buy() {
1303 let desc = make_test_descriptor();
1304 let client = ConfigurableExchangeClient::new(desc);
1305 let data = serde_json::json!({"side": "bid"});
1306 let mapping = ResponseMapping {
1307 side: Some(crate::market::descriptor::SideMapping {
1308 field: "side".to_string(),
1309 mapping: [
1310 ("ask".to_string(), "sell".to_string()),
1311 ("bid".to_string(), "buy".to_string()),
1312 ]
1313 .into_iter()
1314 .collect(),
1315 }),
1316 ..Default::default()
1317 };
1318 assert_eq!(client.parse_side(&data, &mapping), TradeSide::Buy);
1319 }
1320
1321 #[test]
1322 fn test_parse_side_numeric_value() {
1323 let desc = make_test_descriptor();
1324 let client = ConfigurableExchangeClient::new(desc);
1325 let data = serde_json::json!({"side": 1});
1326 let mapping = ResponseMapping {
1327 side: Some(crate::market::descriptor::SideMapping {
1328 field: "side".to_string(),
1329 mapping: [
1330 ("1".to_string(), "sell".to_string()),
1331 ("0".to_string(), "buy".to_string()),
1332 ]
1333 .into_iter()
1334 .collect(),
1335 }),
1336 ..Default::default()
1337 };
1338 assert_eq!(client.parse_side(&data, &mapping), TradeSide::Sell);
1339 }
1340
1341 #[test]
1342 fn test_parse_side_unknown_value_returns_buy() {
1343 let desc = make_test_descriptor();
1344 let client = ConfigurableExchangeClient::new(desc);
1345 let data = serde_json::json!({"side": "unknown"});
1346 let mapping = ResponseMapping {
1347 side: Some(crate::market::descriptor::SideMapping {
1348 field: "side".to_string(),
1349 mapping: [
1350 ("ask".to_string(), "sell".to_string()),
1351 ("bid".to_string(), "buy".to_string()),
1352 ]
1353 .into_iter()
1354 .collect(),
1355 }),
1356 ..Default::default()
1357 };
1358 assert_eq!(client.parse_side(&data, &mapping), TradeSide::Buy);
1359 }
1360
1361 #[test]
1362 fn test_parse_side_non_string_number_bool_returns_buy() {
1363 let desc = make_test_descriptor();
1364 let client = ConfigurableExchangeClient::new(desc);
1365 let data = serde_json::json!({"side": [1, 2, 3]});
1366 let mapping = ResponseMapping {
1367 side: Some(crate::market::descriptor::SideMapping {
1368 field: "side".to_string(),
1369 mapping: [("ask".to_string(), "sell".to_string())]
1370 .into_iter()
1371 .collect(),
1372 }),
1373 ..Default::default()
1374 };
1375 assert_eq!(client.parse_side(&data, &mapping), TradeSide::Buy);
1376 }
1377
1378 #[test]
1379 fn test_format_display_pair_unknown_quote() {
1380 assert_eq!(format_display_pair("XYZABC", "{base}{quote}"), "XYZABC");
1381 }
1382
1383 #[test]
1384 fn test_format_display_pair_single_char() {
1385 assert_eq!(format_display_pair("A", "{base}{quote}"), "A");
1386 }
1387
1388 #[test]
1389 fn test_format_display_pair_quote_only_no_split() {
1390 assert_eq!(format_display_pair("USDT", "{base}{quote}"), "USDT");
1391 }
1392
1393 fn make_http_test_descriptor(base_url: &str) -> VenueDescriptor {
1398 use crate::market::descriptor::*;
1399 VenueDescriptor {
1400 id: "mock_test".to_string(),
1401 name: "Mock Test".to_string(),
1402 base_url: base_url.to_string(),
1403 timeout_secs: Some(5),
1404 rate_limit_per_sec: None,
1405 symbol: SymbolConfig {
1406 template: "{base}{quote}".to_string(),
1407 default_quote: "USDT".to_string(),
1408 case: SymbolCase::Upper,
1409 },
1410 headers: std::collections::HashMap::new(),
1411 capabilities: CapabilitySet {
1412 order_book: Some(EndpointDescriptor {
1413 path: "/api/v1/depth".to_string(),
1414 method: HttpMethod::GET,
1415 params: [("symbol".to_string(), "{pair}".to_string())]
1416 .into_iter()
1417 .collect(),
1418 request_body: None,
1419 response_root: None,
1420 interval_map: std::collections::HashMap::new(),
1421 response: ResponseMapping {
1422 asks_key: Some("asks".to_string()),
1423 bids_key: Some("bids".to_string()),
1424 level_format: Some("positional".to_string()),
1425 ..Default::default()
1426 },
1427 }),
1428 ticker: Some(EndpointDescriptor {
1429 path: "/api/v1/ticker".to_string(),
1430 method: HttpMethod::GET,
1431 params: [("symbol".to_string(), "{pair}".to_string())]
1432 .into_iter()
1433 .collect(),
1434 request_body: None,
1435 response_root: None,
1436 interval_map: std::collections::HashMap::new(),
1437 response: ResponseMapping {
1438 last_price: Some("lastPrice".to_string()),
1439 high_24h: Some("highPrice".to_string()),
1440 low_24h: Some("lowPrice".to_string()),
1441 volume_24h: Some("volume".to_string()),
1442 quote_volume_24h: Some("quoteVolume".to_string()),
1443 best_bid: Some("bidPrice".to_string()),
1444 best_ask: Some("askPrice".to_string()),
1445 ..Default::default()
1446 },
1447 }),
1448 trades: Some(EndpointDescriptor {
1449 path: "/api/v1/trades".to_string(),
1450 method: HttpMethod::GET,
1451 params: [
1452 ("symbol".to_string(), "{pair}".to_string()),
1453 ("limit".to_string(), "{limit}".to_string()),
1454 ]
1455 .into_iter()
1456 .collect(),
1457 request_body: None,
1458 response_root: None,
1459 interval_map: std::collections::HashMap::new(),
1460 response: ResponseMapping {
1461 price: Some("price".to_string()),
1462 quantity: Some("qty".to_string()),
1463 quote_quantity: Some("quoteQty".to_string()),
1464 timestamp_ms: Some("time".to_string()),
1465 id: Some("id".to_string()),
1466 side: Some(SideMapping {
1467 field: "isBuyerMaker".to_string(),
1468 mapping: [
1469 ("true".to_string(), "sell".to_string()),
1470 ("false".to_string(), "buy".to_string()),
1471 ]
1472 .into_iter()
1473 .collect(),
1474 }),
1475 ..Default::default()
1476 },
1477 }),
1478 ohlc: None,
1479 },
1480 }
1481 }
1482
1483 #[tokio::test]
1484 async fn test_fetch_order_book_via_http() {
1485 let mut server = mockito::Server::new_async().await;
1486 let mock = server
1487 .mock("GET", "/api/v1/depth")
1488 .match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded(
1489 "symbol".into(),
1490 "BTCUSDT".into(),
1491 )]))
1492 .with_status(200)
1493 .with_body(
1494 serde_json::json!({
1495 "asks": [["50010.0", "1.5"], ["50020.0", "2.0"]],
1496 "bids": [["50000.0", "1.0"], ["49990.0", "3.0"]]
1497 })
1498 .to_string(),
1499 )
1500 .create_async()
1501 .await;
1502
1503 let desc = make_http_test_descriptor(&server.url());
1504 let client = ConfigurableExchangeClient::new(desc);
1505 let book = client.fetch_order_book("BTCUSDT").await.unwrap();
1506 assert_eq!(book.asks.len(), 2);
1507 assert_eq!(book.bids.len(), 2);
1508 assert_eq!(book.asks[0].price, 50010.0);
1509 assert_eq!(book.bids[0].price, 50000.0);
1510 mock.assert_async().await;
1511 }
1512
1513 #[tokio::test]
1514 async fn test_fetch_ticker_via_http() {
1515 let mut server = mockito::Server::new_async().await;
1516 let mock = server
1517 .mock("GET", "/api/v1/ticker")
1518 .match_query(mockito::Matcher::UrlEncoded(
1519 "symbol".into(),
1520 "BTCUSDT".into(),
1521 ))
1522 .with_status(200)
1523 .with_body(
1524 serde_json::json!({
1525 "lastPrice": "50100.5",
1526 "highPrice": "51200.0",
1527 "lowPrice": "48800.0",
1528 "volume": "1234.56",
1529 "quoteVolume": "62000000.0",
1530 "bidPrice": "50095.0",
1531 "askPrice": "50105.0"
1532 })
1533 .to_string(),
1534 )
1535 .create_async()
1536 .await;
1537
1538 let desc = make_http_test_descriptor(&server.url());
1539 let client = ConfigurableExchangeClient::new(desc);
1540 let ticker = client.fetch_ticker("BTCUSDT").await.unwrap();
1541 assert_eq!(ticker.pair, "BTC/USDT");
1542 assert_eq!(ticker.last_price, Some(50100.5));
1543 assert_eq!(ticker.high_24h, Some(51200.0));
1544 assert_eq!(ticker.low_24h, Some(48800.0));
1545 assert_eq!(ticker.volume_24h, Some(1234.56));
1546 assert_eq!(ticker.quote_volume_24h, Some(62000000.0));
1547 assert_eq!(ticker.best_bid, Some(50095.0));
1548 assert_eq!(ticker.best_ask, Some(50105.0));
1549 mock.assert_async().await;
1550 }
1551
1552 #[tokio::test]
1553 async fn test_fetch_recent_trades_via_http() {
1554 let mut server = mockito::Server::new_async().await;
1555 let mock = server
1556 .mock("GET", "/api/v1/trades")
1557 .match_query(mockito::Matcher::AllOf(vec![
1558 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
1559 mockito::Matcher::UrlEncoded("limit".into(), "10".into()),
1560 ]))
1561 .with_status(200)
1562 .with_body(
1563 serde_json::json!([
1564 {
1565 "id": "trade-1",
1566 "price": "50000.0",
1567 "qty": "0.5",
1568 "quoteQty": "25000.0",
1569 "time": "1700000000000",
1570 "isBuyerMaker": true
1571 },
1572 {
1573 "id": "trade-2",
1574 "price": "50001.0",
1575 "qty": "1.0",
1576 "quoteQty": "50001.0",
1577 "time": "1700000001000",
1578 "isBuyerMaker": false
1579 }
1580 ])
1581 .to_string(),
1582 )
1583 .create_async()
1584 .await;
1585
1586 let desc = make_http_test_descriptor(&server.url());
1587 let client = ConfigurableExchangeClient::new(desc);
1588 let trades = client.fetch_recent_trades("BTCUSDT", 10).await.unwrap();
1589 assert_eq!(trades.len(), 2);
1590 assert_eq!(trades[0].price, 50000.0);
1591 assert_eq!(trades[0].quantity, 0.5);
1592 assert_eq!(trades[0].quote_quantity, Some(25000.0));
1593 assert_eq!(trades[0].timestamp_ms, 1700000000000);
1594 assert_eq!(trades[0].id.as_deref(), Some("trade-1"));
1595 assert_eq!(trades[0].side, TradeSide::Sell);
1596 assert_eq!(trades[1].price, 50001.0);
1597 assert_eq!(trades[1].quantity, 1.0);
1598 assert_eq!(trades[1].side, TradeSide::Buy);
1599 mock.assert_async().await;
1600 }
1601
1602 #[tokio::test]
1603 async fn test_fetch_order_book_http_error() {
1604 let mut server = mockito::Server::new_async().await;
1605 let mock = server
1606 .mock("GET", "/api/v1/depth")
1607 .match_query(mockito::Matcher::UrlEncoded(
1608 "symbol".into(),
1609 "BTCUSDT".into(),
1610 ))
1611 .with_status(500)
1612 .with_body("Internal Server Error")
1613 .create_async()
1614 .await;
1615
1616 let desc = make_http_test_descriptor(&server.url());
1617 let client = ConfigurableExchangeClient::new(desc);
1618 let err = client.fetch_order_book("BTCUSDT").await.unwrap_err();
1619 let err_msg = err.to_string();
1620 assert!(
1621 err_msg.contains("API error: HTTP 500"),
1622 "expected error message to contain 'API error: HTTP 500', got: {}",
1623 err_msg
1624 );
1625 mock.assert_async().await;
1626 }
1627
1628 #[tokio::test]
1629 async fn test_fetch_order_book_no_capability() {
1630 let desc = make_test_descriptor();
1631 let client = ConfigurableExchangeClient::new(desc);
1632 let err = client.fetch_order_book("BTCUSDT").await.unwrap_err();
1633 let err_msg = err.to_string();
1634 assert!(
1635 err_msg.contains("does not support order book"),
1636 "expected error message to contain 'does not support order book', got: {}",
1637 err_msg
1638 );
1639 }
1640
1641 #[tokio::test]
1642 async fn test_fetch_order_book_via_post() {
1643 use crate::market::descriptor::*;
1644 let mut server = mockito::Server::new_async().await;
1645 let mock = server
1646 .mock("POST", "/api/v1/depth")
1647 .match_body(mockito::Matcher::Json(serde_json::json!({
1648 "symbol": "BTCUSDT"
1649 })))
1650 .with_status(200)
1651 .with_body(
1652 serde_json::json!({
1653 "asks": [["50100.0", "2.0"]],
1654 "bids": [["50000.0", "1.0"]]
1655 })
1656 .to_string(),
1657 )
1658 .create_async()
1659 .await;
1660
1661 let mut desc = make_http_test_descriptor(&server.url());
1662 desc.capabilities.order_book = Some(EndpointDescriptor {
1663 path: "/api/v1/depth".to_string(),
1664 method: HttpMethod::POST,
1665 params: std::collections::HashMap::new(),
1666 request_body: Some(serde_json::json!({
1667 "symbol": "{pair}"
1668 })),
1669 response_root: None,
1670 interval_map: std::collections::HashMap::new(),
1671 response: ResponseMapping {
1672 asks_key: Some("asks".to_string()),
1673 bids_key: Some("bids".to_string()),
1674 level_format: Some("positional".to_string()),
1675 ..Default::default()
1676 },
1677 });
1678
1679 let client = ConfigurableExchangeClient::new(desc);
1680 let book = client.fetch_order_book("BTCUSDT").await.unwrap();
1681 assert_eq!(book.asks.len(), 1);
1682 assert_eq!(book.bids.len(), 1);
1683 assert_eq!(book.asks[0].price, 50100.0);
1684 assert_eq!(book.bids[0].price, 50000.0);
1685 mock.assert_async().await;
1686 }
1687
1688 #[tokio::test]
1689 async fn test_fetch_order_book_missing_asks_key() {
1690 let mut server = mockito::Server::new_async().await;
1691 let mock = server
1692 .mock("GET", "/api/v1/depth")
1693 .match_query(mockito::Matcher::UrlEncoded(
1694 "symbol".into(),
1695 "BTCUSDT".into(),
1696 ))
1697 .with_status(200)
1698 .with_body(
1699 serde_json::json!({
1700 "bids": [["50000.0", "1.0"]]
1701 })
1702 .to_string(),
1703 )
1704 .create_async()
1705 .await;
1706
1707 let desc = make_http_test_descriptor(&server.url());
1708 let client = ConfigurableExchangeClient::new(desc);
1709 let err = client.fetch_order_book("BTCUSDT").await.unwrap_err();
1710 let err_msg = err.to_string();
1711 assert!(
1712 err_msg.contains("missing 'asks'"),
1713 "expected error message to contain 'missing \\'asks\\'', got: {}",
1714 err_msg
1715 );
1716 mock.assert_async().await;
1717 }
1718
1719 #[tokio::test]
1720 async fn test_fetch_order_book_missing_bids_key() {
1721 let mut server = mockito::Server::new_async().await;
1722 let mock = server
1723 .mock("GET", "/api/v1/depth")
1724 .match_query(mockito::Matcher::UrlEncoded(
1725 "symbol".into(),
1726 "BTCUSDT".into(),
1727 ))
1728 .with_status(200)
1729 .with_body(
1730 serde_json::json!({
1731 "asks": [["50010.0", "1.5"]]
1732 })
1733 .to_string(),
1734 )
1735 .create_async()
1736 .await;
1737
1738 let desc = make_http_test_descriptor(&server.url());
1739 let client = ConfigurableExchangeClient::new(desc);
1740 let err = client.fetch_order_book("BTCUSDT").await.unwrap_err();
1741 let err_msg = err.to_string();
1742 assert!(
1743 err_msg.contains("missing 'bids'"),
1744 "expected error message to contain 'missing \\'bids\\'', got: {}",
1745 err_msg
1746 );
1747 mock.assert_async().await;
1748 }
1749
1750 #[tokio::test]
1751 async fn test_fetch_ticker_with_filter() {
1752 use crate::market::descriptor::*;
1753 let mut server = mockito::Server::new_async().await;
1754 let mock = server
1755 .mock("GET", "/api/v1/tickers")
1756 .match_query(mockito::Matcher::UrlEncoded(
1757 "symbol".into(),
1758 "BTCUSDT".into(),
1759 ))
1760 .with_status(200)
1761 .with_body(
1762 serde_json::json!([
1763 {"symbol": "ETHUSDT", "lastPrice": "3000.0"},
1764 {"symbol": "BTCUSDT", "lastPrice": "50100.5", "highPrice": "51200.0"},
1765 {"symbol": "BNBUSDT", "lastPrice": "400.0"}
1766 ])
1767 .to_string(),
1768 )
1769 .create_async()
1770 .await;
1771
1772 let mut desc = make_http_test_descriptor(&server.url());
1773 desc.capabilities.ticker = Some(EndpointDescriptor {
1774 path: "/api/v1/tickers".to_string(),
1775 method: HttpMethod::GET,
1776 params: [("symbol".to_string(), "{pair}".to_string())]
1777 .into_iter()
1778 .collect(),
1779 request_body: None,
1780 response_root: None,
1781 interval_map: std::collections::HashMap::new(),
1782 response: ResponseMapping {
1783 filter: Some(FilterConfig {
1784 field: "symbol".to_string(),
1785 value: "{pair}".to_string(),
1786 }),
1787 last_price: Some("lastPrice".to_string()),
1788 high_24h: Some("highPrice".to_string()),
1789 ..Default::default()
1790 },
1791 });
1792
1793 let client = ConfigurableExchangeClient::new(desc);
1794 let ticker = client.fetch_ticker("BTCUSDT").await.unwrap();
1795 assert_eq!(ticker.pair, "BTC/USDT");
1796 assert_eq!(ticker.last_price, Some(50100.5));
1797 assert_eq!(ticker.high_24h, Some(51200.0));
1798 mock.assert_async().await;
1799 }
1800
1801 #[tokio::test]
1802 async fn test_fetch_ticker_filter_no_match() {
1803 use crate::market::descriptor::*;
1804 let mut server = mockito::Server::new_async().await;
1805 let mock = server
1806 .mock("GET", "/api/v1/tickers")
1807 .match_query(mockito::Matcher::UrlEncoded(
1808 "symbol".into(),
1809 "BTCUSDT".into(),
1810 ))
1811 .with_status(200)
1812 .with_body(
1813 serde_json::json!([
1814 {"symbol": "ETHUSDT", "lastPrice": "3000.0"},
1815 {"symbol": "BNBUSDT", "lastPrice": "400.0"}
1816 ])
1817 .to_string(),
1818 )
1819 .create_async()
1820 .await;
1821
1822 let mut desc = make_http_test_descriptor(&server.url());
1823 desc.capabilities.ticker = Some(EndpointDescriptor {
1824 path: "/api/v1/tickers".to_string(),
1825 method: HttpMethod::GET,
1826 params: [("symbol".to_string(), "{pair}".to_string())]
1827 .into_iter()
1828 .collect(),
1829 request_body: None,
1830 response_root: None,
1831 interval_map: std::collections::HashMap::new(),
1832 response: ResponseMapping {
1833 filter: Some(FilterConfig {
1834 field: "symbol".to_string(),
1835 value: "{pair}".to_string(),
1836 }),
1837 last_price: Some("lastPrice".to_string()),
1838 ..Default::default()
1839 },
1840 });
1841
1842 let client = ConfigurableExchangeClient::new(desc);
1843 let err = client.fetch_ticker("BTCUSDT").await.unwrap_err();
1844 let err_msg = err.to_string();
1845 assert!(
1846 err_msg.contains("no ticker found for pair"),
1847 "expected error message to contain 'no ticker found for pair', got: {}",
1848 err_msg
1849 );
1850 mock.assert_async().await;
1851 }
1852
1853 #[tokio::test]
1854 async fn test_fetch_trades_non_array_response() {
1855 let mut server = mockito::Server::new_async().await;
1856 let mock = server
1857 .mock("GET", "/api/v1/trades")
1858 .match_query(mockito::Matcher::AllOf(vec![
1859 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
1860 mockito::Matcher::UrlEncoded("limit".into(), "10".into()),
1861 ]))
1862 .with_status(200)
1863 .with_body(
1864 serde_json::json!({
1865 "trades": [{"price": "50000", "qty": "1"}]
1866 })
1867 .to_string(),
1868 )
1869 .create_async()
1870 .await;
1871
1872 let desc = make_http_test_descriptor(&server.url());
1873 let client = ConfigurableExchangeClient::new(desc);
1874 let err = client.fetch_recent_trades("BTCUSDT", 10).await.unwrap_err();
1875 let err_msg = err.to_string();
1876 assert!(
1877 err_msg.contains("expected array for trades"),
1878 "expected error message to contain 'expected array for trades', got: {}",
1879 err_msg
1880 );
1881 mock.assert_async().await;
1882 }
1883
1884 #[tokio::test]
1885 async fn test_fetch_with_custom_headers() {
1886 let mut server = mockito::Server::new_async().await;
1887 let mut headers = std::collections::HashMap::new();
1888 headers.insert("X-Api-Key".to_string(), "test123".to_string());
1889 let mock = server
1890 .mock("GET", "/api/v1/ticker")
1891 .match_header("x-api-key", "test123")
1892 .match_query(mockito::Matcher::UrlEncoded(
1893 "symbol".into(),
1894 "BTCUSDT".into(),
1895 ))
1896 .with_status(200)
1897 .with_body(
1898 serde_json::json!({
1899 "lastPrice": "50100.5",
1900 "highPrice": "51200.0",
1901 "lowPrice": "48800.0",
1902 "volume": "1234.56",
1903 "quoteVolume": "62000000.0",
1904 "bidPrice": "50095.0",
1905 "askPrice": "50105.0"
1906 })
1907 .to_string(),
1908 )
1909 .create_async()
1910 .await;
1911
1912 let mut desc = make_http_test_descriptor(&server.url());
1913 desc.headers = headers;
1914 desc.capabilities.ticker.as_mut().unwrap().response = ResponseMapping {
1915 last_price: Some("lastPrice".to_string()),
1916 high_24h: Some("highPrice".to_string()),
1917 low_24h: Some("lowPrice".to_string()),
1918 volume_24h: Some("volume".to_string()),
1919 quote_volume_24h: Some("quoteVolume".to_string()),
1920 best_bid: Some("bidPrice".to_string()),
1921 best_ask: Some("askPrice".to_string()),
1922 ..Default::default()
1923 };
1924
1925 let client = ConfigurableExchangeClient::new(desc);
1926 let ticker = client.fetch_ticker("BTCUSDT").await.unwrap();
1927 assert_eq!(ticker.last_price, Some(50100.5));
1928 mock.assert_async().await;
1929 }
1930
1931 #[tokio::test]
1932 async fn test_fetch_trades_no_capability() {
1933 let desc = make_test_descriptor();
1934 let client = ConfigurableExchangeClient::new(desc);
1935 let err = client.fetch_recent_trades("BTCUSDT", 10).await.unwrap_err();
1936 let err_msg = err.to_string();
1937 assert!(
1938 err_msg.contains("does not support trades"),
1939 "expected error message to contain 'does not support trades', got: {}",
1940 err_msg
1941 );
1942 }
1943
1944 #[test]
1945 fn test_navigate_root_index_out_of_bounds() {
1946 let desc = make_test_descriptor();
1947 let client = ConfigurableExchangeClient::new(desc);
1948 let json = serde_json::json!({"data": [1, 2]});
1949 let result = client.navigate_root(&json, Some("data.5"));
1950 assert!(result.is_err());
1951 assert!(result.unwrap_err().to_string().contains("out of bounds"));
1952 }
1953
1954 #[test]
1955 fn test_navigate_root_missing_key() {
1956 let desc = make_test_descriptor();
1957 let client = ConfigurableExchangeClient::new(desc);
1958 let json = serde_json::json!({"data": {"nested": 1}});
1959 let result = client.navigate_root(&json, Some("data.missing_key"));
1960 assert!(result.is_err());
1961 assert!(result.unwrap_err().to_string().contains("missing key"));
1962 }
1963
1964 #[test]
1965 fn test_interpolate_json_passthrough() {
1966 let desc = make_test_descriptor();
1967 let client = ConfigurableExchangeClient::new(desc);
1968 assert_eq!(
1969 client.interpolate_json(&serde_json::json!(42), "BTC", "100"),
1970 serde_json::json!(42)
1971 );
1972 assert_eq!(
1973 client.interpolate_json(&serde_json::json!(true), "BTC", "100"),
1974 serde_json::json!(true)
1975 );
1976 assert_eq!(
1977 client.interpolate_json(&serde_json::json!(null), "BTC", "100"),
1978 serde_json::json!(null)
1979 );
1980 }
1981
1982 fn make_ohlc_test_descriptor(base_url: &str) -> VenueDescriptor {
1987 use crate::market::descriptor::*;
1988 VenueDescriptor {
1989 id: "ohlc_mock".to_string(),
1990 name: "OHLC Mock".to_string(),
1991 base_url: base_url.to_string(),
1992 timeout_secs: Some(5),
1993 rate_limit_per_sec: None,
1994 symbol: SymbolConfig {
1995 template: "{base}{quote}".to_string(),
1996 default_quote: "USDT".to_string(),
1997 case: SymbolCase::Upper,
1998 },
1999 headers: std::collections::HashMap::new(),
2000 capabilities: CapabilitySet {
2001 order_book: None,
2002 ticker: None,
2003 trades: None,
2004 ohlc: Some(EndpointDescriptor {
2005 path: "/api/v1/klines".to_string(),
2006 method: HttpMethod::GET,
2007 params: [
2008 ("symbol".to_string(), "{pair}".to_string()),
2009 ("interval".to_string(), "{interval}".to_string()),
2010 ("limit".to_string(), "{limit}".to_string()),
2011 ]
2012 .into_iter()
2013 .collect(),
2014 request_body: None,
2015 response_root: None,
2016 interval_map: std::collections::HashMap::new(),
2017 response: ResponseMapping {
2018 ohlc_format: Some("array_of_arrays".to_string()),
2019 ohlc_fields: Some(vec![
2020 "open_time".to_string(),
2021 "open".to_string(),
2022 "high".to_string(),
2023 "low".to_string(),
2024 "close".to_string(),
2025 "volume".to_string(),
2026 "close_time".to_string(),
2027 ]),
2028 ..Default::default()
2029 },
2030 }),
2031 },
2032 }
2033 }
2034
2035 #[tokio::test]
2036 async fn test_fetch_ohlc_array_of_arrays() {
2037 let mut server = mockito::Server::new_async().await;
2038 let mock = server
2039 .mock("GET", "/api/v1/klines")
2040 .match_query(mockito::Matcher::AllOf(vec![
2041 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2042 mockito::Matcher::UrlEncoded("interval".into(), "1h".into()),
2043 mockito::Matcher::UrlEncoded("limit".into(), "3".into()),
2044 ]))
2045 .with_status(200)
2046 .with_body(
2047 serde_json::json!([
2048 [
2049 1700000000000u64,
2050 "50000.0",
2051 "50500.0",
2052 "49800.0",
2053 "50200.0",
2054 "100.5",
2055 1700003599999u64
2056 ],
2057 [
2058 1700003600000u64,
2059 "50200.0",
2060 "50800.0",
2061 "50100.0",
2062 "50700.0",
2063 "120.3",
2064 1700007199999u64
2065 ],
2066 [
2067 1700007200000u64,
2068 "50700.0",
2069 "51000.0",
2070 "50600.0",
2071 "50900.0",
2072 "95.7",
2073 1700010799999u64
2074 ]
2075 ])
2076 .to_string(),
2077 )
2078 .create_async()
2079 .await;
2080
2081 let desc = make_ohlc_test_descriptor(&server.url());
2082 let client = ConfigurableExchangeClient::new(desc);
2083 let candles = client.fetch_ohlc("BTCUSDT", "1h", 3).await.unwrap();
2084
2085 assert_eq!(candles.len(), 3);
2086 assert_eq!(candles[0].open_time, 1700000000000);
2087 assert_eq!(candles[0].open, 50000.0);
2088 assert_eq!(candles[0].high, 50500.0);
2089 assert_eq!(candles[0].low, 49800.0);
2090 assert_eq!(candles[0].close, 50200.0);
2091 assert_eq!(candles[0].volume, 100.5);
2092 assert_eq!(candles[0].close_time, 1700003599999);
2093 assert_eq!(candles[2].open, 50700.0);
2094 mock.assert_async().await;
2095 }
2096
2097 #[tokio::test]
2098 async fn test_fetch_ohlc_object_format() {
2099 use crate::market::descriptor::*;
2100 let mut server = mockito::Server::new_async().await;
2101 let mock = server
2102 .mock("GET", "/api/v1/candles")
2103 .match_query(mockito::Matcher::AllOf(vec![
2104 mockito::Matcher::UrlEncoded("symbol".into(), "ETHUSDT".into()),
2105 mockito::Matcher::UrlEncoded("interval".into(), "15m".into()),
2106 mockito::Matcher::UrlEncoded("limit".into(), "2".into()),
2107 ]))
2108 .with_status(200)
2109 .with_body(
2110 serde_json::json!([
2111 {
2112 "ts": 1700000000000u64,
2113 "o": "3000.0",
2114 "h": "3050.0",
2115 "l": "2980.0",
2116 "c": "3020.0",
2117 "vol": "500.0",
2118 "ct": 1700000899999u64
2119 },
2120 {
2121 "ts": 1700000900000u64,
2122 "o": "3020.0",
2123 "h": "3080.0",
2124 "l": "3010.0",
2125 "c": "3060.0",
2126 "vol": "420.0",
2127 "ct": 1700001799999u64
2128 }
2129 ])
2130 .to_string(),
2131 )
2132 .create_async()
2133 .await;
2134
2135 let desc = VenueDescriptor {
2136 id: "ohlc_obj_mock".to_string(),
2137 name: "OHLC Obj Mock".to_string(),
2138 base_url: server.url(),
2139 timeout_secs: Some(5),
2140 rate_limit_per_sec: None,
2141 symbol: SymbolConfig {
2142 template: "{base}{quote}".to_string(),
2143 default_quote: "USDT".to_string(),
2144 case: SymbolCase::Upper,
2145 },
2146 headers: std::collections::HashMap::new(),
2147 capabilities: CapabilitySet {
2148 order_book: None,
2149 ticker: None,
2150 trades: None,
2151 ohlc: Some(EndpointDescriptor {
2152 path: "/api/v1/candles".to_string(),
2153 method: HttpMethod::GET,
2154 params: [
2155 ("symbol".to_string(), "{pair}".to_string()),
2156 ("interval".to_string(), "{interval}".to_string()),
2157 ("limit".to_string(), "{limit}".to_string()),
2158 ]
2159 .into_iter()
2160 .collect(),
2161 request_body: None,
2162 response_root: None,
2163 interval_map: std::collections::HashMap::new(),
2164 response: ResponseMapping {
2165 ohlc_format: Some("objects".to_string()),
2166 open_time: Some("ts".to_string()),
2167 open: Some("o".to_string()),
2168 high: Some("h".to_string()),
2169 low: Some("l".to_string()),
2170 close: Some("c".to_string()),
2171 ohlc_volume: Some("vol".to_string()),
2172 close_time: Some("ct".to_string()),
2173 ..Default::default()
2174 },
2175 }),
2176 },
2177 };
2178
2179 let client = ConfigurableExchangeClient::new(desc);
2180 let candles = client.fetch_ohlc("ETHUSDT", "15m", 2).await.unwrap();
2181
2182 assert_eq!(candles.len(), 2);
2183 assert_eq!(candles[0].open_time, 1700000000000);
2184 assert_eq!(candles[0].open, 3000.0);
2185 assert_eq!(candles[0].high, 3050.0);
2186 assert_eq!(candles[0].low, 2980.0);
2187 assert_eq!(candles[0].close, 3020.0);
2188 assert_eq!(candles[0].volume, 500.0);
2189 assert_eq!(candles[1].close, 3060.0);
2190 mock.assert_async().await;
2191 }
2192
2193 #[tokio::test]
2194 async fn test_fetch_ohlc_no_capability() {
2195 let desc = make_test_descriptor();
2196 let client = ConfigurableExchangeClient::new(desc);
2197 let err = client.fetch_ohlc("BTCUSDT", "1h", 100).await.unwrap_err();
2198 let msg = err.to_string();
2199 assert!(
2200 msg.contains("does not support OHLC"),
2201 "expected OHLC error, got: {}",
2202 msg
2203 );
2204 }
2205
2206 #[tokio::test]
2209 async fn test_fetch_ohlc_interval_map() {
2210 use crate::market::descriptor::*;
2211 let mut server = mockito::Server::new_async().await;
2212 let mock = server
2214 .mock("GET", "/api/v1/kline")
2215 .match_query(mockito::Matcher::AllOf(vec![
2216 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2217 mockito::Matcher::UrlEncoded("type".into(), "hour".into()),
2218 mockito::Matcher::UrlEncoded("size".into(), "2".into()),
2219 ]))
2220 .with_status(200)
2221 .with_body(
2222 serde_json::json!([
2223 [
2224 1700000000000u64,
2225 "50000.0",
2226 "50500.0",
2227 "49800.0",
2228 "50200.0",
2229 "100.5"
2230 ],
2231 [
2232 1700003600000u64,
2233 "50200.0",
2234 "50800.0",
2235 "50100.0",
2236 "50700.0",
2237 "120.3"
2238 ]
2239 ])
2240 .to_string(),
2241 )
2242 .create_async()
2243 .await;
2244
2245 let desc = VenueDescriptor {
2246 id: "interval_map_test".to_string(),
2247 name: "Interval Map Test".to_string(),
2248 base_url: server.url(),
2249 timeout_secs: Some(5),
2250 rate_limit_per_sec: None,
2251 symbol: SymbolConfig {
2252 template: "{base}{quote}".to_string(),
2253 default_quote: "USDT".to_string(),
2254 case: SymbolCase::Upper,
2255 },
2256 headers: std::collections::HashMap::new(),
2257 capabilities: CapabilitySet {
2258 order_book: None,
2259 ticker: None,
2260 trades: None,
2261 ohlc: Some(EndpointDescriptor {
2262 path: "/api/v1/kline".to_string(),
2263 method: HttpMethod::GET,
2264 params: [
2265 ("symbol".to_string(), "{pair}".to_string()),
2266 ("type".to_string(), "{interval}".to_string()),
2267 ("size".to_string(), "{limit}".to_string()),
2268 ]
2269 .into_iter()
2270 .collect(),
2271 request_body: None,
2272 response_root: None,
2273 interval_map: [
2274 ("1m".to_string(), "1min".to_string()),
2275 ("5m".to_string(), "5min".to_string()),
2276 ("1h".to_string(), "hour".to_string()),
2277 ("1d".to_string(), "day".to_string()),
2278 ]
2279 .into_iter()
2280 .collect(),
2281 response: ResponseMapping {
2282 ohlc_format: Some("array_of_arrays".to_string()),
2283 ohlc_fields: Some(vec![
2284 "open_time".to_string(),
2285 "open".to_string(),
2286 "high".to_string(),
2287 "low".to_string(),
2288 "close".to_string(),
2289 "volume".to_string(),
2290 ]),
2291 ..Default::default()
2292 },
2293 }),
2294 },
2295 };
2296
2297 let client = ConfigurableExchangeClient::new(desc);
2298 let candles = client.fetch_ohlc("BTCUSDT", "1h", 2).await.unwrap();
2299 assert_eq!(candles.len(), 2);
2300 assert_eq!(candles[0].open, 50000.0);
2301 assert_eq!(candles[1].close, 50700.0);
2302 mock.assert_async().await;
2303 }
2304
2305 #[tokio::test]
2307 async fn test_fetch_ohlc_interval_map_passthrough() {
2308 use crate::market::descriptor::*;
2309 let mut server = mockito::Server::new_async().await;
2310 let mock = server
2312 .mock("GET", "/api/v1/kline")
2313 .match_query(mockito::Matcher::AllOf(vec![
2314 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2315 mockito::Matcher::UrlEncoded("type".into(), "15m".into()),
2316 mockito::Matcher::UrlEncoded("size".into(), "1".into()),
2317 ]))
2318 .with_status(200)
2319 .with_body(
2320 serde_json::json!([[
2321 1700000000000u64,
2322 "50000.0",
2323 "50500.0",
2324 "49800.0",
2325 "50200.0",
2326 "100.5"
2327 ]])
2328 .to_string(),
2329 )
2330 .create_async()
2331 .await;
2332
2333 let desc = VenueDescriptor {
2334 id: "passthrough_test".to_string(),
2335 name: "Passthrough Test".to_string(),
2336 base_url: server.url(),
2337 timeout_secs: Some(5),
2338 rate_limit_per_sec: None,
2339 symbol: SymbolConfig {
2340 template: "{base}{quote}".to_string(),
2341 default_quote: "USDT".to_string(),
2342 case: SymbolCase::Upper,
2343 },
2344 headers: std::collections::HashMap::new(),
2345 capabilities: CapabilitySet {
2346 order_book: None,
2347 ticker: None,
2348 trades: None,
2349 ohlc: Some(EndpointDescriptor {
2350 path: "/api/v1/kline".to_string(),
2351 method: HttpMethod::GET,
2352 params: [
2353 ("symbol".to_string(), "{pair}".to_string()),
2354 ("type".to_string(), "{interval}".to_string()),
2355 ("size".to_string(), "{limit}".to_string()),
2356 ]
2357 .into_iter()
2358 .collect(),
2359 request_body: None,
2360 response_root: None,
2361 interval_map: [("1h".to_string(), "hour".to_string())]
2363 .into_iter()
2364 .collect(),
2365 response: ResponseMapping {
2366 ohlc_format: Some("array_of_arrays".to_string()),
2367 ohlc_fields: Some(vec![
2368 "open_time".to_string(),
2369 "open".to_string(),
2370 "high".to_string(),
2371 "low".to_string(),
2372 "close".to_string(),
2373 "volume".to_string(),
2374 ]),
2375 ..Default::default()
2376 },
2377 }),
2378 },
2379 };
2380
2381 let client = ConfigurableExchangeClient::new(desc);
2382 let candles = client.fetch_ohlc("BTCUSDT", "15m", 1).await.unwrap();
2383 assert_eq!(candles.len(), 1);
2384 mock.assert_async().await;
2385 }
2386
2387 #[tokio::test]
2388 async fn test_fetch_ohlc_non_array_response() {
2389 let mut server = mockito::Server::new_async().await;
2390 let mock = server
2391 .mock("GET", "/api/v1/klines")
2392 .match_query(mockito::Matcher::AllOf(vec![
2393 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2394 mockito::Matcher::UrlEncoded("interval".into(), "1h".into()),
2395 mockito::Matcher::UrlEncoded("limit".into(), "100".into()),
2396 ]))
2397 .with_status(200)
2398 .with_body(serde_json::json!({"error": "not an array"}).to_string())
2399 .create_async()
2400 .await;
2401
2402 let desc = make_ohlc_test_descriptor(&server.url());
2403 let client = ConfigurableExchangeClient::new(desc);
2404 let err = client.fetch_ohlc("BTCUSDT", "1h", 100).await.unwrap_err();
2405 let msg = err.to_string();
2406 assert!(
2407 msg.contains("expected array for OHLC"),
2408 "expected array error, got: {}",
2409 msg
2410 );
2411 mock.assert_async().await;
2412 }
2413
2414 #[tokio::test]
2415 async fn test_fetch_ohlc_empty_array() {
2416 let mut server = mockito::Server::new_async().await;
2417 let mock = server
2418 .mock("GET", "/api/v1/klines")
2419 .match_query(mockito::Matcher::AllOf(vec![
2420 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2421 mockito::Matcher::UrlEncoded("interval".into(), "1d".into()),
2422 mockito::Matcher::UrlEncoded("limit".into(), "10".into()),
2423 ]))
2424 .with_status(200)
2425 .with_body("[]")
2426 .create_async()
2427 .await;
2428
2429 let desc = make_ohlc_test_descriptor(&server.url());
2430 let client = ConfigurableExchangeClient::new(desc);
2431 let candles = client.fetch_ohlc("BTCUSDT", "1d", 10).await.unwrap();
2432 assert!(candles.is_empty());
2433 mock.assert_async().await;
2434 }
2435
2436 #[tokio::test]
2437 async fn test_fetch_ohlc_skips_malformed_inner_items() {
2438 let mut server = mockito::Server::new_async().await;
2439 let mock = server
2440 .mock("GET", "/api/v1/klines")
2441 .match_query(mockito::Matcher::AllOf(vec![
2442 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2443 mockito::Matcher::UrlEncoded("interval".into(), "1h".into()),
2444 mockito::Matcher::UrlEncoded("limit".into(), "5".into()),
2445 ]))
2446 .with_status(200)
2447 .with_body(
2448 serde_json::json!([
2449 "not an array",
2450 [
2451 1700000000000u64,
2452 "50000.0",
2453 "50500.0",
2454 "49800.0",
2455 "50200.0",
2456 "100.5",
2457 1700003599999u64
2458 ],
2459 42
2460 ])
2461 .to_string(),
2462 )
2463 .create_async()
2464 .await;
2465
2466 let desc = make_ohlc_test_descriptor(&server.url());
2467 let client = ConfigurableExchangeClient::new(desc);
2468 let candles = client.fetch_ohlc("BTCUSDT", "1h", 5).await.unwrap();
2469 assert_eq!(candles.len(), 1);
2471 assert_eq!(candles[0].open, 50000.0);
2472 mock.assert_async().await;
2473 }
2474
2475 #[tokio::test]
2476 async fn test_fetch_ohlc_with_response_root() {
2477 let mut server = mockito::Server::new_async().await;
2478 let mock = server
2479 .mock("GET", "/api/v1/klines")
2480 .match_query(mockito::Matcher::AllOf(vec![
2481 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2482 mockito::Matcher::UrlEncoded("interval".into(), "4h".into()),
2483 mockito::Matcher::UrlEncoded("limit".into(), "2".into()),
2484 ]))
2485 .with_status(200)
2486 .with_body(
2487 serde_json::json!({
2488 "result": [
2489 [1700000000000u64, "50000.0", "50500.0", "49800.0", "50200.0", "100.5", 1700003599999u64],
2490 [1700003600000u64, "50200.0", "50800.0", "50100.0", "50700.0", "120.3", 1700007199999u64]
2491 ]
2492 })
2493 .to_string(),
2494 )
2495 .create_async()
2496 .await;
2497
2498 let mut desc = make_ohlc_test_descriptor(&server.url());
2499 desc.capabilities.ohlc.as_mut().unwrap().response_root = Some("result".to_string());
2500
2501 let client = ConfigurableExchangeClient::new(desc);
2502 let candles = client.fetch_ohlc("BTCUSDT", "4h", 2).await.unwrap();
2503 assert_eq!(candles.len(), 2);
2504 mock.assert_async().await;
2505 }
2506
2507 #[test]
2508 fn test_interpolate_value_full_with_interval() {
2509 let desc = make_test_descriptor();
2510 let client = ConfigurableExchangeClient::new(desc);
2511 let result =
2512 client.interpolate_value_full("{pair}_{interval}_{limit}", "BTCUSDT", "50", "1h");
2513 assert_eq!(result, "BTCUSDT_1h_50");
2514 }
2515
2516 #[test]
2517 fn test_interpolate_json_full_with_interval() {
2518 let desc = make_test_descriptor();
2519 let client = ConfigurableExchangeClient::new(desc);
2520 let template = serde_json::json!({
2521 "symbol": "{pair}",
2522 "interval": "{interval}",
2523 "limit": "{limit}"
2524 });
2525 let result = client.interpolate_json_full(&template, "ETHUSDT", "100", "15m");
2526 assert_eq!(result["symbol"], "ETHUSDT");
2527 assert_eq!(result["interval"], "15m");
2528 assert_eq!(result["limit"], "100");
2529 }
2530
2531 #[tokio::test]
2532 async fn test_fetch_ohlc_via_post_method() {
2533 use crate::market::descriptor::*;
2534 let mut server = mockito::Server::new_async().await;
2535 let mock = server
2536 .mock("POST", "/api/v1/klines")
2537 .with_status(200)
2538 .with_body(
2539 serde_json::json!([[
2540 1700000000000u64,
2541 "50000.0",
2542 "50500.0",
2543 "49800.0",
2544 "50200.0",
2545 "100.5",
2546 1700003599999u64
2547 ]])
2548 .to_string(),
2549 )
2550 .create_async()
2551 .await;
2552
2553 let desc = VenueDescriptor {
2554 id: "post_ohlc".to_string(),
2555 name: "POST OHLC".to_string(),
2556 base_url: server.url(),
2557 timeout_secs: Some(5),
2558 rate_limit_per_sec: None,
2559 symbol: SymbolConfig {
2560 template: "{base}{quote}".to_string(),
2561 default_quote: "USDT".to_string(),
2562 case: SymbolCase::Upper,
2563 },
2564 headers: std::collections::HashMap::new(),
2565 capabilities: CapabilitySet {
2566 order_book: None,
2567 ticker: None,
2568 trades: None,
2569 ohlc: Some(EndpointDescriptor {
2570 path: "/api/v1/klines".to_string(),
2571 method: HttpMethod::POST,
2572 params: std::collections::HashMap::new(),
2573 request_body: Some(serde_json::json!({
2574 "symbol": "{pair}",
2575 "interval": "{interval}",
2576 "limit": "{limit}"
2577 })),
2578 response_root: None,
2579 interval_map: std::collections::HashMap::new(),
2580 response: ResponseMapping {
2581 ohlc_format: Some("array_of_arrays".to_string()),
2582 ohlc_fields: Some(vec![
2583 "open_time".to_string(),
2584 "open".to_string(),
2585 "high".to_string(),
2586 "low".to_string(),
2587 "close".to_string(),
2588 "volume".to_string(),
2589 "close_time".to_string(),
2590 ]),
2591 ..Default::default()
2592 },
2593 }),
2594 },
2595 };
2596
2597 let client = ConfigurableExchangeClient::new(desc);
2598 let candles = client.fetch_ohlc("BTCUSDT", "1h", 1).await.unwrap();
2599 assert_eq!(candles.len(), 1);
2600 assert_eq!(candles[0].open, 50000.0);
2601 mock.assert_async().await;
2602 }
2603
2604 #[tokio::test]
2605 async fn test_fetch_ohlc_post_http_error() {
2606 use crate::market::descriptor::*;
2607 let mut server = mockito::Server::new_async().await;
2608 let mock = server
2609 .mock("POST", "/api/v1/klines")
2610 .with_status(500)
2611 .with_body("Internal Server Error")
2612 .create_async()
2613 .await;
2614
2615 let desc = VenueDescriptor {
2616 id: "post_ohlc_err".to_string(),
2617 name: "POST OHLC Err".to_string(),
2618 base_url: server.url(),
2619 timeout_secs: Some(5),
2620 rate_limit_per_sec: None,
2621 symbol: SymbolConfig {
2622 template: "{base}{quote}".to_string(),
2623 default_quote: "USDT".to_string(),
2624 case: SymbolCase::Upper,
2625 },
2626 headers: std::collections::HashMap::new(),
2627 capabilities: CapabilitySet {
2628 order_book: None,
2629 ticker: None,
2630 trades: None,
2631 ohlc: Some(EndpointDescriptor {
2632 path: "/api/v1/klines".to_string(),
2633 method: HttpMethod::POST,
2634 params: std::collections::HashMap::new(),
2635 request_body: None,
2636 response_root: None,
2637 interval_map: std::collections::HashMap::new(),
2638 response: ResponseMapping {
2639 ohlc_format: Some("array_of_arrays".to_string()),
2640 ..Default::default()
2641 },
2642 }),
2643 },
2644 };
2645
2646 let client = ConfigurableExchangeClient::new(desc);
2647 let err = client.fetch_ohlc("BTCUSDT", "1h", 100).await.unwrap_err();
2648 assert!(err.to_string().contains("API error"));
2649 mock.assert_async().await;
2650 }
2651
2652 #[tokio::test]
2653 async fn test_fetch_ohlc_get_http_error() {
2654 let mut server = mockito::Server::new_async().await;
2655 let mock = server
2656 .mock("GET", "/api/v1/klines")
2657 .match_query(mockito::Matcher::AllOf(vec![
2658 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2659 mockito::Matcher::UrlEncoded("interval".into(), "1h".into()),
2660 mockito::Matcher::UrlEncoded("limit".into(), "100".into()),
2661 ]))
2662 .with_status(429)
2663 .with_body("Rate limited")
2664 .create_async()
2665 .await;
2666
2667 let desc = make_ohlc_test_descriptor(&server.url());
2668 let client = ConfigurableExchangeClient::new(desc);
2669 let err = client.fetch_ohlc("BTCUSDT", "1h", 100).await.unwrap_err();
2670 assert!(err.to_string().contains("API error"));
2671 mock.assert_async().await;
2672 }
2673
2674 #[tokio::test]
2675 async fn test_fetch_ohlc_with_items_key() {
2676 use crate::market::descriptor::*;
2677 let mut server = mockito::Server::new_async().await;
2678 let mock = server
2679 .mock("GET", "/api/v1/candles")
2680 .match_query(mockito::Matcher::AllOf(vec![
2681 mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2682 mockito::Matcher::UrlEncoded("interval".into(), "1h".into()),
2683 mockito::Matcher::UrlEncoded("limit".into(), "2".into()),
2684 ]))
2685 .with_status(200)
2686 .with_body(
2687 serde_json::json!({
2688 "data": [
2689 {"ts": 1700000000000u64, "o": "100.0", "h": "110.0", "l": "90.0", "c": "105.0", "vol": "1000.0", "ct": 1700003599999u64},
2690 {"ts": 1700003600000u64, "o": "105.0", "h": "115.0", "l": "100.0", "c": "110.0", "vol": "800.0", "ct": 1700007199999u64}
2691 ]
2692 })
2693 .to_string(),
2694 )
2695 .create_async()
2696 .await;
2697
2698 let desc = VenueDescriptor {
2699 id: "items_key_ohlc".to_string(),
2700 name: "Items Key OHLC".to_string(),
2701 base_url: server.url(),
2702 timeout_secs: Some(5),
2703 rate_limit_per_sec: None,
2704 symbol: SymbolConfig {
2705 template: "{base}{quote}".to_string(),
2706 default_quote: "USDT".to_string(),
2707 case: SymbolCase::Upper,
2708 },
2709 headers: std::collections::HashMap::new(),
2710 capabilities: CapabilitySet {
2711 order_book: None,
2712 ticker: None,
2713 trades: None,
2714 ohlc: Some(EndpointDescriptor {
2715 path: "/api/v1/candles".to_string(),
2716 method: HttpMethod::GET,
2717 params: [
2718 ("symbol".to_string(), "{pair}".to_string()),
2719 ("interval".to_string(), "{interval}".to_string()),
2720 ("limit".to_string(), "{limit}".to_string()),
2721 ]
2722 .into_iter()
2723 .collect(),
2724 request_body: None,
2725 response_root: None,
2726 interval_map: std::collections::HashMap::new(),
2727 response: ResponseMapping {
2728 items_key: Some("data".to_string()),
2729 ohlc_format: Some("objects".to_string()),
2730 open_time: Some("ts".to_string()),
2731 open: Some("o".to_string()),
2732 high: Some("h".to_string()),
2733 low: Some("l".to_string()),
2734 close: Some("c".to_string()),
2735 ohlc_volume: Some("vol".to_string()),
2736 close_time: Some("ct".to_string()),
2737 ..Default::default()
2738 },
2739 }),
2740 },
2741 };
2742
2743 let client = ConfigurableExchangeClient::new(desc);
2744 let candles = client.fetch_ohlc("BTCUSDT", "1h", 2).await.unwrap();
2745 assert_eq!(candles.len(), 2);
2746 assert_eq!(candles[0].open, 100.0);
2747 assert_eq!(candles[1].close, 110.0);
2748 mock.assert_async().await;
2749 }
2750}