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