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