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