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