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