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