Skip to main content

scope/market/
configurable_client.rs

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