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