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_value_to_f64_number() {
824        let val = serde_json::json!(42.5);
825        assert_eq!(value_to_f64(&val), Some(42.5));
826    }
827
828    #[test]
829    fn test_value_to_f64_string() {
830        let val = serde_json::json!("42.5");
831        assert_eq!(value_to_f64(&val), Some(42.5));
832    }
833
834    #[test]
835    fn test_value_to_f64_invalid() {
836        let val = serde_json::json!(null);
837        assert_eq!(value_to_f64(&val), None);
838    }
839
840    #[test]
841    fn test_navigate_root_empty() {
842        let desc = make_test_descriptor();
843        let client = ConfigurableExchangeClient::new(desc);
844        let json = serde_json::json!({"price": 42});
845        let result = client.navigate_root(&json, None).unwrap();
846        assert_eq!(result, &json);
847    }
848
849    #[test]
850    fn test_navigate_root_single_key() {
851        let desc = make_test_descriptor();
852        let client = ConfigurableExchangeClient::new(desc);
853        let json = serde_json::json!({"result": {"price": 42}});
854        let result = client.navigate_root(&json, Some("result")).unwrap();
855        assert_eq!(result, &serde_json::json!({"price": 42}));
856    }
857
858    #[test]
859    fn test_navigate_root_nested_with_index() {
860        let desc = make_test_descriptor();
861        let client = ConfigurableExchangeClient::new(desc);
862        let json = serde_json::json!({"data": [{"price": 42}, {"price": 43}]});
863        let result = client.navigate_root(&json, Some("data.0")).unwrap();
864        assert_eq!(result, &serde_json::json!({"price": 42}));
865    }
866
867    #[test]
868    fn test_navigate_root_wildcard() {
869        let desc = make_test_descriptor();
870        let client = ConfigurableExchangeClient::new(desc);
871        let json = serde_json::json!({"result": {"XXBTZUSD": {"a": ["42000.0"]}}});
872        let result = client.navigate_root(&json, Some("result.*")).unwrap();
873        assert_eq!(result, &serde_json::json!({"a": ["42000.0"]}));
874    }
875
876    #[test]
877    fn test_extract_f64_nested() {
878        let desc = make_test_descriptor();
879        let client = ConfigurableExchangeClient::new(desc);
880        let data = serde_json::json!({"c": ["42000.5", "1.5"]});
881        assert_eq!(client.extract_f64(&data, "c.0"), Some(42000.5));
882        assert_eq!(client.extract_f64(&data, "c.1"), Some(1.5));
883    }
884
885    #[test]
886    fn test_parse_positional_levels() {
887        let desc = make_test_descriptor();
888        let client = ConfigurableExchangeClient::new(desc);
889        let arr = serde_json::json!([["42000.0", "1.5"], ["42001.0", "2.0"]]);
890        let mapping = ResponseMapping {
891            level_format: Some("positional".to_string()),
892            ..Default::default()
893        };
894        let levels = client.parse_levels(&arr, &mapping).unwrap();
895        assert_eq!(levels.len(), 2);
896        assert_eq!(levels[0].price, 42000.0);
897        assert_eq!(levels[0].quantity, 1.5);
898    }
899
900    #[test]
901    fn test_parse_object_levels() {
902        let desc = make_test_descriptor();
903        let client = ConfigurableExchangeClient::new(desc);
904        let arr = serde_json::json!([
905            {"price": "42000.0", "size": "1.5"},
906            {"price": "42001.0", "size": "2.0"}
907        ]);
908        let mapping = ResponseMapping {
909            level_format: Some("object".to_string()),
910            level_price_field: Some("price".to_string()),
911            level_size_field: Some("size".to_string()),
912            ..Default::default()
913        };
914        let levels = client.parse_levels(&arr, &mapping).unwrap();
915        assert_eq!(levels.len(), 2);
916        assert_eq!(levels[0].price, 42000.0);
917        assert_eq!(levels[0].quantity, 1.5);
918    }
919
920    #[test]
921    fn test_parse_side_mapping() {
922        let desc = make_test_descriptor();
923        let client = ConfigurableExchangeClient::new(desc);
924        let data = serde_json::json!({"isBuyerMaker": true});
925        let mapping = ResponseMapping {
926            side: Some(crate::market::descriptor::SideMapping {
927                field: "isBuyerMaker".to_string(),
928                mapping: [
929                    ("true".to_string(), "sell".to_string()),
930                    ("false".to_string(), "buy".to_string()),
931                ]
932                .into_iter()
933                .collect(),
934            }),
935            ..Default::default()
936        };
937        assert_eq!(client.parse_side(&data, &mapping), TradeSide::Sell);
938    }
939
940    #[test]
941    fn test_interpolate_json() {
942        let desc = make_test_descriptor();
943        let client = ConfigurableExchangeClient::new(desc);
944        let template = serde_json::json!({
945            "method": "get-book",
946            "params": {"instrument": "{pair}", "depth": "{limit}"}
947        });
948        let result = client.interpolate_json(&template, "BTC_USDT", "100");
949        assert_eq!(
950            result,
951            serde_json::json!({
952                "method": "get-book",
953                "params": {"instrument": "BTC_USDT", "depth": "100"}
954            })
955        );
956    }
957
958    fn make_test_descriptor() -> VenueDescriptor {
959        use crate::market::descriptor::*;
960        VenueDescriptor {
961            id: "test".to_string(),
962            name: "Test".to_string(),
963            base_url: "https://example.com".to_string(),
964            timeout_secs: Some(5),
965            rate_limit_per_sec: None,
966            symbol: SymbolConfig {
967                template: "{base}{quote}".to_string(),
968                default_quote: "USDT".to_string(),
969                case: SymbolCase::Upper,
970            },
971            headers: std::collections::HashMap::new(),
972            capabilities: CapabilitySet::default(),
973        }
974    }
975
976    // -------------------------------------------------------------------------
977    // Additional tests for coverage of descriptor(), format_pair(), current_type,
978    // extract_string(), navigate_field(), parse_levels(), parse_side(), format_display_pair
979    // -------------------------------------------------------------------------
980
981    #[test]
982    fn test_descriptor_accessor() {
983        let desc = make_test_descriptor();
984        let client = ConfigurableExchangeClient::new(desc.clone());
985        assert_eq!(client.descriptor().id, "test");
986        assert_eq!(client.descriptor().name, "Test");
987    }
988
989    #[test]
990    fn test_format_pair_accessor() {
991        let desc = make_test_descriptor();
992        let client = ConfigurableExchangeClient::new(desc);
993        assert_eq!(client.format_pair("BTC", None), "BTCUSDT");
994        assert_eq!(client.format_pair("ETH", Some("USD")), "ETHUSD");
995    }
996
997    #[test]
998    fn test_navigate_root_empty_string_path() {
999        let desc = make_test_descriptor();
1000        let client = ConfigurableExchangeClient::new(desc);
1001        let json = serde_json::json!({"price": 42});
1002        let result = client.navigate_root(&json, Some("")).unwrap();
1003        assert_eq!(result, &json);
1004    }
1005
1006    #[test]
1007    fn test_navigate_root_wildcard_on_non_object_null() {
1008        let desc = make_test_descriptor();
1009        let client = ConfigurableExchangeClient::new(desc);
1010        let json = serde_json::json!({"result": null});
1011        let result = client.navigate_root(&json, Some("result.*"));
1012        assert!(result.is_err());
1013        let err_msg = format!("{:?}", result.unwrap_err());
1014        assert!(err_msg.contains("null"), "error should mention null type");
1015    }
1016
1017    #[test]
1018    fn test_navigate_root_wildcard_on_non_object_bool() {
1019        let desc = make_test_descriptor();
1020        let client = ConfigurableExchangeClient::new(desc);
1021        let json = serde_json::json!({"result": true});
1022        let result = client.navigate_root(&json, Some("result.*"));
1023        assert!(result.is_err());
1024        let err_msg = format!("{:?}", result.unwrap_err());
1025        assert!(err_msg.contains("bool"), "error should mention bool type");
1026    }
1027
1028    #[test]
1029    fn test_navigate_root_wildcard_on_non_object_number() {
1030        let desc = make_test_descriptor();
1031        let client = ConfigurableExchangeClient::new(desc);
1032        let json = serde_json::json!({"result": 42});
1033        let result = client.navigate_root(&json, Some("result.*"));
1034        assert!(result.is_err());
1035        let err_msg = format!("{:?}", result.unwrap_err());
1036        assert!(
1037            err_msg.contains("number"),
1038            "error should mention number type"
1039        );
1040    }
1041
1042    #[test]
1043    fn test_navigate_root_wildcard_on_non_object_string() {
1044        let desc = make_test_descriptor();
1045        let client = ConfigurableExchangeClient::new(desc);
1046        let json = serde_json::json!({"result": "not_an_object"});
1047        let result = client.navigate_root(&json, Some("result.*"));
1048        assert!(result.is_err());
1049        let err_msg = format!("{:?}", result.unwrap_err());
1050        assert!(
1051            err_msg.contains("string"),
1052            "error should mention string type"
1053        );
1054    }
1055
1056    #[test]
1057    fn test_navigate_root_wildcard_on_non_object_array() {
1058        let desc = make_test_descriptor();
1059        let client = ConfigurableExchangeClient::new(desc);
1060        let json = serde_json::json!({"result": [1, 2, 3]});
1061        let result = client.navigate_root(&json, Some("result.*"));
1062        assert!(result.is_err());
1063        let err_msg = format!("{:?}", result.unwrap_err());
1064        assert!(err_msg.contains("array"), "error should mention array type");
1065    }
1066
1067    #[test]
1068    fn test_navigate_root_wildcard_empty_object() {
1069        let desc = make_test_descriptor();
1070        let client = ConfigurableExchangeClient::new(desc);
1071        let json = serde_json::json!({"result": {}});
1072        let result = client.navigate_root(&json, Some("result.*"));
1073        assert!(result.is_err());
1074        let err_msg = format!("{:?}", result.unwrap_err());
1075        assert!(
1076            err_msg.contains("empty object"),
1077            "error should mention empty object"
1078        );
1079    }
1080
1081    #[test]
1082    fn test_extract_string_from_string() {
1083        let desc = make_test_descriptor();
1084        let client = ConfigurableExchangeClient::new(desc);
1085        let data = serde_json::json!({"id": "abc123"});
1086        assert_eq!(
1087            client.extract_string(&data, "id").as_deref(),
1088            Some("abc123")
1089        );
1090    }
1091
1092    #[test]
1093    fn test_extract_string_from_number() {
1094        let desc = make_test_descriptor();
1095        let client = ConfigurableExchangeClient::new(desc);
1096        let data = serde_json::json!({"id": 12345});
1097        assert_eq!(client.extract_string(&data, "id").as_deref(), Some("12345"));
1098    }
1099
1100    #[test]
1101    fn test_extract_string_from_nested_path() {
1102        let desc = make_test_descriptor();
1103        let client = ConfigurableExchangeClient::new(desc);
1104        let data = serde_json::json!({"a": {"b": {"c": ["x", "value"]}}});
1105        assert_eq!(
1106            client.extract_string(&data, "a.b.c.1").as_deref(),
1107            Some("value")
1108        );
1109    }
1110
1111    #[test]
1112    fn test_extract_string_returns_none_for_object() {
1113        let desc = make_test_descriptor();
1114        let client = ConfigurableExchangeClient::new(desc);
1115        let data = serde_json::json!({"id": {"nested": "obj"}});
1116        assert_eq!(client.extract_string(&data, "id"), None);
1117    }
1118
1119    #[test]
1120    fn test_extract_string_returns_none_for_array() {
1121        let desc = make_test_descriptor();
1122        let client = ConfigurableExchangeClient::new(desc);
1123        let data = serde_json::json!({"id": [1, 2, 3]});
1124        assert_eq!(client.extract_string(&data, "id"), None);
1125    }
1126
1127    #[test]
1128    fn test_navigate_field_deeper_path() {
1129        let desc = make_test_descriptor();
1130        let client = ConfigurableExchangeClient::new(desc);
1131        let data = serde_json::json!({"level1": {"level2": {"level3": [0, 99.5]}}});
1132        assert_eq!(
1133            client.extract_f64(&data, "level1.level2.level3.1"),
1134            Some(99.5)
1135        );
1136    }
1137
1138    #[test]
1139    fn test_parse_levels_empty_array() {
1140        let desc = make_test_descriptor();
1141        let client = ConfigurableExchangeClient::new(desc);
1142        let arr = serde_json::json!([]);
1143        let mapping = ResponseMapping::default();
1144        let levels = client.parse_levels(&arr, &mapping).unwrap();
1145        assert!(levels.is_empty());
1146    }
1147
1148    #[test]
1149    fn test_parse_levels_not_array_err() {
1150        let desc = make_test_descriptor();
1151        let client = ConfigurableExchangeClient::new(desc);
1152        let not_arr = serde_json::json!({"not": "array"});
1153        let mapping = ResponseMapping::default();
1154        let result = client.parse_levels(&not_arr, &mapping);
1155        assert!(result.is_err());
1156        let err_msg = format!("{:?}", result.unwrap_err());
1157        assert!(err_msg.contains("expected array"));
1158    }
1159
1160    #[test]
1161    fn test_parse_levels_filters_zero_price_and_quantity() {
1162        let desc = make_test_descriptor();
1163        let client = ConfigurableExchangeClient::new(desc);
1164        let arr = serde_json::json!([
1165            ["42000.0", "1.5"],
1166            ["0.0", "1.0"],
1167            ["42001.0", "0.0"],
1168            ["42002.0", ""]
1169        ]);
1170        let mapping = ResponseMapping {
1171            level_format: Some("positional".to_string()),
1172            ..Default::default()
1173        };
1174        let levels = client.parse_levels(&arr, &mapping).unwrap();
1175        assert_eq!(levels.len(), 1);
1176        assert_eq!(levels[0].price, 42000.0);
1177        assert_eq!(levels[0].quantity, 1.5);
1178    }
1179
1180    #[test]
1181    fn test_parse_levels_object_format_default_fields() {
1182        let desc = make_test_descriptor();
1183        let client = ConfigurableExchangeClient::new(desc);
1184        let arr = serde_json::json!([
1185            {"price": "100.0", "size": "2.0"},
1186            {"price": "101.0", "size": "3.0"}
1187        ]);
1188        let mapping = ResponseMapping {
1189            level_format: Some("object".to_string()),
1190            level_price_field: None,
1191            level_size_field: None,
1192            ..Default::default()
1193        };
1194        let levels = client.parse_levels(&arr, &mapping).unwrap();
1195        assert_eq!(levels.len(), 2);
1196        assert_eq!(levels[0].price, 100.0);
1197        assert_eq!(levels[0].quantity, 2.0);
1198    }
1199
1200    #[test]
1201    fn test_parse_side_no_mapping_returns_buy() {
1202        let desc = make_test_descriptor();
1203        let client = ConfigurableExchangeClient::new(desc);
1204        let data = serde_json::json!({"side": "sell"});
1205        let mapping = ResponseMapping {
1206            side: None,
1207            ..Default::default()
1208        };
1209        assert_eq!(client.parse_side(&data, &mapping), TradeSide::Buy);
1210    }
1211
1212    #[test]
1213    fn test_parse_side_field_missing_returns_buy() {
1214        let desc = make_test_descriptor();
1215        let client = ConfigurableExchangeClient::new(desc);
1216        let data = serde_json::json!({});
1217        let mapping = ResponseMapping {
1218            side: Some(crate::market::descriptor::SideMapping {
1219                field: "nonexistent".to_string(),
1220                mapping: [("sell".to_string(), "sell".to_string())]
1221                    .into_iter()
1222                    .collect(),
1223            }),
1224            ..Default::default()
1225        };
1226        assert_eq!(client.parse_side(&data, &mapping), TradeSide::Buy);
1227    }
1228
1229    #[test]
1230    fn test_parse_side_string_mapped_to_sell() {
1231        let desc = make_test_descriptor();
1232        let client = ConfigurableExchangeClient::new(desc);
1233        let data = serde_json::json!({"side": "ask"});
1234        let mapping = ResponseMapping {
1235            side: Some(crate::market::descriptor::SideMapping {
1236                field: "side".to_string(),
1237                mapping: [
1238                    ("ask".to_string(), "sell".to_string()),
1239                    ("bid".to_string(), "buy".to_string()),
1240                ]
1241                .into_iter()
1242                .collect(),
1243            }),
1244            ..Default::default()
1245        };
1246        assert_eq!(client.parse_side(&data, &mapping), TradeSide::Sell);
1247    }
1248
1249    #[test]
1250    fn test_parse_side_string_mapped_to_buy() {
1251        let desc = make_test_descriptor();
1252        let client = ConfigurableExchangeClient::new(desc);
1253        let data = serde_json::json!({"side": "bid"});
1254        let mapping = ResponseMapping {
1255            side: Some(crate::market::descriptor::SideMapping {
1256                field: "side".to_string(),
1257                mapping: [
1258                    ("ask".to_string(), "sell".to_string()),
1259                    ("bid".to_string(), "buy".to_string()),
1260                ]
1261                .into_iter()
1262                .collect(),
1263            }),
1264            ..Default::default()
1265        };
1266        assert_eq!(client.parse_side(&data, &mapping), TradeSide::Buy);
1267    }
1268
1269    #[test]
1270    fn test_parse_side_numeric_value() {
1271        let desc = make_test_descriptor();
1272        let client = ConfigurableExchangeClient::new(desc);
1273        let data = serde_json::json!({"side": 1});
1274        let mapping = ResponseMapping {
1275            side: Some(crate::market::descriptor::SideMapping {
1276                field: "side".to_string(),
1277                mapping: [
1278                    ("1".to_string(), "sell".to_string()),
1279                    ("0".to_string(), "buy".to_string()),
1280                ]
1281                .into_iter()
1282                .collect(),
1283            }),
1284            ..Default::default()
1285        };
1286        assert_eq!(client.parse_side(&data, &mapping), TradeSide::Sell);
1287    }
1288
1289    #[test]
1290    fn test_parse_side_unknown_value_returns_buy() {
1291        let desc = make_test_descriptor();
1292        let client = ConfigurableExchangeClient::new(desc);
1293        let data = serde_json::json!({"side": "unknown"});
1294        let mapping = ResponseMapping {
1295            side: Some(crate::market::descriptor::SideMapping {
1296                field: "side".to_string(),
1297                mapping: [
1298                    ("ask".to_string(), "sell".to_string()),
1299                    ("bid".to_string(), "buy".to_string()),
1300                ]
1301                .into_iter()
1302                .collect(),
1303            }),
1304            ..Default::default()
1305        };
1306        assert_eq!(client.parse_side(&data, &mapping), TradeSide::Buy);
1307    }
1308
1309    #[test]
1310    fn test_parse_side_non_string_number_bool_returns_buy() {
1311        let desc = make_test_descriptor();
1312        let client = ConfigurableExchangeClient::new(desc);
1313        let data = serde_json::json!({"side": [1, 2, 3]});
1314        let mapping = ResponseMapping {
1315            side: Some(crate::market::descriptor::SideMapping {
1316                field: "side".to_string(),
1317                mapping: [("ask".to_string(), "sell".to_string())]
1318                    .into_iter()
1319                    .collect(),
1320            }),
1321            ..Default::default()
1322        };
1323        assert_eq!(client.parse_side(&data, &mapping), TradeSide::Buy);
1324    }
1325
1326    #[test]
1327    fn test_format_display_pair_eur() {
1328        assert_eq!(format_display_pair("BTCEUR", "{base}{quote}"), "BTC/EUR");
1329        assert_eq!(format_display_pair("etheur", "{base}{quote}"), "eth/eur");
1330    }
1331
1332    #[test]
1333    fn test_format_display_pair_gbp() {
1334        assert_eq!(format_display_pair("BTCGBP", "{base}{quote}"), "BTC/GBP");
1335    }
1336
1337    #[test]
1338    fn test_format_display_pair_unknown_quote() {
1339        assert_eq!(format_display_pair("XYZABC", "{base}{quote}"), "XYZABC");
1340    }
1341
1342    #[test]
1343    fn test_format_display_pair_single_char() {
1344        assert_eq!(format_display_pair("A", "{base}{quote}"), "A");
1345    }
1346
1347    #[test]
1348    fn test_format_display_pair_quote_only_no_split() {
1349        assert_eq!(format_display_pair("USDT", "{base}{quote}"), "USDT");
1350    }
1351
1352    // -------------------------------------------------------------------------
1353    // Mockito-based HTTP tests for async fetch paths
1354    // -------------------------------------------------------------------------
1355
1356    fn make_http_test_descriptor(base_url: &str) -> VenueDescriptor {
1357        use crate::market::descriptor::*;
1358        VenueDescriptor {
1359            id: "mock_test".to_string(),
1360            name: "Mock Test".to_string(),
1361            base_url: base_url.to_string(),
1362            timeout_secs: Some(5),
1363            rate_limit_per_sec: None,
1364            symbol: SymbolConfig {
1365                template: "{base}{quote}".to_string(),
1366                default_quote: "USDT".to_string(),
1367                case: SymbolCase::Upper,
1368            },
1369            headers: std::collections::HashMap::new(),
1370            capabilities: CapabilitySet {
1371                order_book: Some(EndpointDescriptor {
1372                    path: "/api/v1/depth".to_string(),
1373                    method: HttpMethod::GET,
1374                    params: [("symbol".to_string(), "{pair}".to_string())]
1375                        .into_iter()
1376                        .collect(),
1377                    request_body: None,
1378                    response_root: None,
1379                    interval_map: std::collections::HashMap::new(),
1380                    response: ResponseMapping {
1381                        asks_key: Some("asks".to_string()),
1382                        bids_key: Some("bids".to_string()),
1383                        level_format: Some("positional".to_string()),
1384                        ..Default::default()
1385                    },
1386                }),
1387                ticker: Some(EndpointDescriptor {
1388                    path: "/api/v1/ticker".to_string(),
1389                    method: HttpMethod::GET,
1390                    params: [("symbol".to_string(), "{pair}".to_string())]
1391                        .into_iter()
1392                        .collect(),
1393                    request_body: None,
1394                    response_root: None,
1395                    interval_map: std::collections::HashMap::new(),
1396                    response: ResponseMapping {
1397                        last_price: Some("lastPrice".to_string()),
1398                        high_24h: Some("highPrice".to_string()),
1399                        low_24h: Some("lowPrice".to_string()),
1400                        volume_24h: Some("volume".to_string()),
1401                        quote_volume_24h: Some("quoteVolume".to_string()),
1402                        best_bid: Some("bidPrice".to_string()),
1403                        best_ask: Some("askPrice".to_string()),
1404                        ..Default::default()
1405                    },
1406                }),
1407                trades: Some(EndpointDescriptor {
1408                    path: "/api/v1/trades".to_string(),
1409                    method: HttpMethod::GET,
1410                    params: [
1411                        ("symbol".to_string(), "{pair}".to_string()),
1412                        ("limit".to_string(), "{limit}".to_string()),
1413                    ]
1414                    .into_iter()
1415                    .collect(),
1416                    request_body: None,
1417                    response_root: None,
1418                    interval_map: std::collections::HashMap::new(),
1419                    response: ResponseMapping {
1420                        price: Some("price".to_string()),
1421                        quantity: Some("qty".to_string()),
1422                        quote_quantity: Some("quoteQty".to_string()),
1423                        timestamp_ms: Some("time".to_string()),
1424                        id: Some("id".to_string()),
1425                        side: Some(SideMapping {
1426                            field: "isBuyerMaker".to_string(),
1427                            mapping: [
1428                                ("true".to_string(), "sell".to_string()),
1429                                ("false".to_string(), "buy".to_string()),
1430                            ]
1431                            .into_iter()
1432                            .collect(),
1433                        }),
1434                        ..Default::default()
1435                    },
1436                }),
1437                ohlc: None,
1438            },
1439        }
1440    }
1441
1442    #[tokio::test]
1443    async fn test_fetch_order_book_via_http() {
1444        let mut server = mockito::Server::new_async().await;
1445        let mock = server
1446            .mock("GET", "/api/v1/depth")
1447            .match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded(
1448                "symbol".into(),
1449                "BTCUSDT".into(),
1450            )]))
1451            .with_status(200)
1452            .with_body(
1453                serde_json::json!({
1454                    "asks": [["50010.0", "1.5"], ["50020.0", "2.0"]],
1455                    "bids": [["50000.0", "1.0"], ["49990.0", "3.0"]]
1456                })
1457                .to_string(),
1458            )
1459            .create_async()
1460            .await;
1461
1462        let desc = make_http_test_descriptor(&server.url());
1463        let client = ConfigurableExchangeClient::new(desc);
1464        let book = client.fetch_order_book("BTCUSDT").await.unwrap();
1465        assert_eq!(book.asks.len(), 2);
1466        assert_eq!(book.bids.len(), 2);
1467        assert_eq!(book.asks[0].price, 50010.0);
1468        assert_eq!(book.bids[0].price, 50000.0);
1469        mock.assert_async().await;
1470    }
1471
1472    #[tokio::test]
1473    async fn test_fetch_ticker_via_http() {
1474        let mut server = mockito::Server::new_async().await;
1475        let mock = server
1476            .mock("GET", "/api/v1/ticker")
1477            .match_query(mockito::Matcher::UrlEncoded(
1478                "symbol".into(),
1479                "BTCUSDT".into(),
1480            ))
1481            .with_status(200)
1482            .with_body(
1483                serde_json::json!({
1484                    "lastPrice": "50100.5",
1485                    "highPrice": "51200.0",
1486                    "lowPrice": "48800.0",
1487                    "volume": "1234.56",
1488                    "quoteVolume": "62000000.0",
1489                    "bidPrice": "50095.0",
1490                    "askPrice": "50105.0"
1491                })
1492                .to_string(),
1493            )
1494            .create_async()
1495            .await;
1496
1497        let desc = make_http_test_descriptor(&server.url());
1498        let client = ConfigurableExchangeClient::new(desc);
1499        let ticker = client.fetch_ticker("BTCUSDT").await.unwrap();
1500        assert_eq!(ticker.pair, "BTC/USDT");
1501        assert_eq!(ticker.last_price, Some(50100.5));
1502        assert_eq!(ticker.high_24h, Some(51200.0));
1503        assert_eq!(ticker.low_24h, Some(48800.0));
1504        assert_eq!(ticker.volume_24h, Some(1234.56));
1505        assert_eq!(ticker.quote_volume_24h, Some(62000000.0));
1506        assert_eq!(ticker.best_bid, Some(50095.0));
1507        assert_eq!(ticker.best_ask, Some(50105.0));
1508        mock.assert_async().await;
1509    }
1510
1511    #[tokio::test]
1512    async fn test_fetch_recent_trades_via_http() {
1513        let mut server = mockito::Server::new_async().await;
1514        let mock = server
1515            .mock("GET", "/api/v1/trades")
1516            .match_query(mockito::Matcher::AllOf(vec![
1517                mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
1518                mockito::Matcher::UrlEncoded("limit".into(), "10".into()),
1519            ]))
1520            .with_status(200)
1521            .with_body(
1522                serde_json::json!([
1523                    {
1524                        "id": "trade-1",
1525                        "price": "50000.0",
1526                        "qty": "0.5",
1527                        "quoteQty": "25000.0",
1528                        "time": "1700000000000",
1529                        "isBuyerMaker": true
1530                    },
1531                    {
1532                        "id": "trade-2",
1533                        "price": "50001.0",
1534                        "qty": "1.0",
1535                        "quoteQty": "50001.0",
1536                        "time": "1700000001000",
1537                        "isBuyerMaker": false
1538                    }
1539                ])
1540                .to_string(),
1541            )
1542            .create_async()
1543            .await;
1544
1545        let desc = make_http_test_descriptor(&server.url());
1546        let client = ConfigurableExchangeClient::new(desc);
1547        let trades = client.fetch_recent_trades("BTCUSDT", 10).await.unwrap();
1548        assert_eq!(trades.len(), 2);
1549        assert_eq!(trades[0].price, 50000.0);
1550        assert_eq!(trades[0].quantity, 0.5);
1551        assert_eq!(trades[0].quote_quantity, Some(25000.0));
1552        assert_eq!(trades[0].timestamp_ms, 1700000000000);
1553        assert_eq!(trades[0].id.as_deref(), Some("trade-1"));
1554        assert_eq!(trades[0].side, TradeSide::Sell);
1555        assert_eq!(trades[1].price, 50001.0);
1556        assert_eq!(trades[1].quantity, 1.0);
1557        assert_eq!(trades[1].side, TradeSide::Buy);
1558        mock.assert_async().await;
1559    }
1560
1561    #[tokio::test]
1562    async fn test_fetch_order_book_http_error() {
1563        let mut server = mockito::Server::new_async().await;
1564        let mock = server
1565            .mock("GET", "/api/v1/depth")
1566            .match_query(mockito::Matcher::UrlEncoded(
1567                "symbol".into(),
1568                "BTCUSDT".into(),
1569            ))
1570            .with_status(500)
1571            .with_body("Internal Server Error")
1572            .create_async()
1573            .await;
1574
1575        let desc = make_http_test_descriptor(&server.url());
1576        let client = ConfigurableExchangeClient::new(desc);
1577        let err = client.fetch_order_book("BTCUSDT").await.unwrap_err();
1578        let err_msg = err.to_string();
1579        assert!(
1580            err_msg.contains("API error: HTTP 500"),
1581            "expected error message to contain 'API error: HTTP 500', got: {}",
1582            err_msg
1583        );
1584        mock.assert_async().await;
1585    }
1586
1587    #[tokio::test]
1588    async fn test_fetch_order_book_no_capability() {
1589        let desc = make_test_descriptor();
1590        let client = ConfigurableExchangeClient::new(desc);
1591        let err = client.fetch_order_book("BTCUSDT").await.unwrap_err();
1592        let err_msg = err.to_string();
1593        assert!(
1594            err_msg.contains("does not support order book"),
1595            "expected error message to contain 'does not support order book', got: {}",
1596            err_msg
1597        );
1598    }
1599
1600    #[tokio::test]
1601    async fn test_fetch_order_book_via_post() {
1602        use crate::market::descriptor::*;
1603        let mut server = mockito::Server::new_async().await;
1604        let mock = server
1605            .mock("POST", "/api/v1/depth")
1606            .match_body(mockito::Matcher::Json(serde_json::json!({
1607                "symbol": "BTCUSDT"
1608            })))
1609            .with_status(200)
1610            .with_body(
1611                serde_json::json!({
1612                    "asks": [["50100.0", "2.0"]],
1613                    "bids": [["50000.0", "1.0"]]
1614                })
1615                .to_string(),
1616            )
1617            .create_async()
1618            .await;
1619
1620        let mut desc = make_http_test_descriptor(&server.url());
1621        desc.capabilities.order_book = Some(EndpointDescriptor {
1622            path: "/api/v1/depth".to_string(),
1623            method: HttpMethod::POST,
1624            params: std::collections::HashMap::new(),
1625            request_body: Some(serde_json::json!({
1626                "symbol": "{pair}"
1627            })),
1628            response_root: None,
1629            interval_map: std::collections::HashMap::new(),
1630            response: ResponseMapping {
1631                asks_key: Some("asks".to_string()),
1632                bids_key: Some("bids".to_string()),
1633                level_format: Some("positional".to_string()),
1634                ..Default::default()
1635            },
1636        });
1637
1638        let client = ConfigurableExchangeClient::new(desc);
1639        let book = client.fetch_order_book("BTCUSDT").await.unwrap();
1640        assert_eq!(book.asks.len(), 1);
1641        assert_eq!(book.bids.len(), 1);
1642        assert_eq!(book.asks[0].price, 50100.0);
1643        assert_eq!(book.bids[0].price, 50000.0);
1644        mock.assert_async().await;
1645    }
1646
1647    #[tokio::test]
1648    async fn test_fetch_order_book_missing_asks_key() {
1649        let mut server = mockito::Server::new_async().await;
1650        let mock = server
1651            .mock("GET", "/api/v1/depth")
1652            .match_query(mockito::Matcher::UrlEncoded(
1653                "symbol".into(),
1654                "BTCUSDT".into(),
1655            ))
1656            .with_status(200)
1657            .with_body(
1658                serde_json::json!({
1659                    "bids": [["50000.0", "1.0"]]
1660                })
1661                .to_string(),
1662            )
1663            .create_async()
1664            .await;
1665
1666        let desc = make_http_test_descriptor(&server.url());
1667        let client = ConfigurableExchangeClient::new(desc);
1668        let err = client.fetch_order_book("BTCUSDT").await.unwrap_err();
1669        let err_msg = err.to_string();
1670        assert!(
1671            err_msg.contains("missing 'asks'"),
1672            "expected error message to contain 'missing \\'asks\\'', got: {}",
1673            err_msg
1674        );
1675        mock.assert_async().await;
1676    }
1677
1678    #[tokio::test]
1679    async fn test_fetch_order_book_missing_bids_key() {
1680        let mut server = mockito::Server::new_async().await;
1681        let mock = server
1682            .mock("GET", "/api/v1/depth")
1683            .match_query(mockito::Matcher::UrlEncoded(
1684                "symbol".into(),
1685                "BTCUSDT".into(),
1686            ))
1687            .with_status(200)
1688            .with_body(
1689                serde_json::json!({
1690                    "asks": [["50010.0", "1.5"]]
1691                })
1692                .to_string(),
1693            )
1694            .create_async()
1695            .await;
1696
1697        let desc = make_http_test_descriptor(&server.url());
1698        let client = ConfigurableExchangeClient::new(desc);
1699        let err = client.fetch_order_book("BTCUSDT").await.unwrap_err();
1700        let err_msg = err.to_string();
1701        assert!(
1702            err_msg.contains("missing 'bids'"),
1703            "expected error message to contain 'missing \\'bids\\'', got: {}",
1704            err_msg
1705        );
1706        mock.assert_async().await;
1707    }
1708
1709    #[tokio::test]
1710    async fn test_fetch_ticker_with_filter() {
1711        use crate::market::descriptor::*;
1712        let mut server = mockito::Server::new_async().await;
1713        let mock = server
1714            .mock("GET", "/api/v1/tickers")
1715            .match_query(mockito::Matcher::UrlEncoded(
1716                "symbol".into(),
1717                "BTCUSDT".into(),
1718            ))
1719            .with_status(200)
1720            .with_body(
1721                serde_json::json!([
1722                    {"symbol": "ETHUSDT", "lastPrice": "3000.0"},
1723                    {"symbol": "BTCUSDT", "lastPrice": "50100.5", "highPrice": "51200.0"},
1724                    {"symbol": "BNBUSDT", "lastPrice": "400.0"}
1725                ])
1726                .to_string(),
1727            )
1728            .create_async()
1729            .await;
1730
1731        let mut desc = make_http_test_descriptor(&server.url());
1732        desc.capabilities.ticker = Some(EndpointDescriptor {
1733            path: "/api/v1/tickers".to_string(),
1734            method: HttpMethod::GET,
1735            params: [("symbol".to_string(), "{pair}".to_string())]
1736                .into_iter()
1737                .collect(),
1738            request_body: None,
1739            response_root: None,
1740            interval_map: std::collections::HashMap::new(),
1741            response: ResponseMapping {
1742                filter: Some(FilterConfig {
1743                    field: "symbol".to_string(),
1744                    value: "{pair}".to_string(),
1745                }),
1746                last_price: Some("lastPrice".to_string()),
1747                high_24h: Some("highPrice".to_string()),
1748                ..Default::default()
1749            },
1750        });
1751
1752        let client = ConfigurableExchangeClient::new(desc);
1753        let ticker = client.fetch_ticker("BTCUSDT").await.unwrap();
1754        assert_eq!(ticker.pair, "BTC/USDT");
1755        assert_eq!(ticker.last_price, Some(50100.5));
1756        assert_eq!(ticker.high_24h, Some(51200.0));
1757        mock.assert_async().await;
1758    }
1759
1760    #[tokio::test]
1761    async fn test_fetch_ticker_filter_no_match() {
1762        use crate::market::descriptor::*;
1763        let mut server = mockito::Server::new_async().await;
1764        let mock = server
1765            .mock("GET", "/api/v1/tickers")
1766            .match_query(mockito::Matcher::UrlEncoded(
1767                "symbol".into(),
1768                "BTCUSDT".into(),
1769            ))
1770            .with_status(200)
1771            .with_body(
1772                serde_json::json!([
1773                    {"symbol": "ETHUSDT", "lastPrice": "3000.0"},
1774                    {"symbol": "BNBUSDT", "lastPrice": "400.0"}
1775                ])
1776                .to_string(),
1777            )
1778            .create_async()
1779            .await;
1780
1781        let mut desc = make_http_test_descriptor(&server.url());
1782        desc.capabilities.ticker = Some(EndpointDescriptor {
1783            path: "/api/v1/tickers".to_string(),
1784            method: HttpMethod::GET,
1785            params: [("symbol".to_string(), "{pair}".to_string())]
1786                .into_iter()
1787                .collect(),
1788            request_body: None,
1789            response_root: None,
1790            interval_map: std::collections::HashMap::new(),
1791            response: ResponseMapping {
1792                filter: Some(FilterConfig {
1793                    field: "symbol".to_string(),
1794                    value: "{pair}".to_string(),
1795                }),
1796                last_price: Some("lastPrice".to_string()),
1797                ..Default::default()
1798            },
1799        });
1800
1801        let client = ConfigurableExchangeClient::new(desc);
1802        let err = client.fetch_ticker("BTCUSDT").await.unwrap_err();
1803        let err_msg = err.to_string();
1804        assert!(
1805            err_msg.contains("no ticker found for pair"),
1806            "expected error message to contain 'no ticker found for pair', got: {}",
1807            err_msg
1808        );
1809        mock.assert_async().await;
1810    }
1811
1812    #[tokio::test]
1813    async fn test_fetch_trades_non_array_response() {
1814        let mut server = mockito::Server::new_async().await;
1815        let mock = server
1816            .mock("GET", "/api/v1/trades")
1817            .match_query(mockito::Matcher::AllOf(vec![
1818                mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
1819                mockito::Matcher::UrlEncoded("limit".into(), "10".into()),
1820            ]))
1821            .with_status(200)
1822            .with_body(
1823                serde_json::json!({
1824                    "trades": [{"price": "50000", "qty": "1"}]
1825                })
1826                .to_string(),
1827            )
1828            .create_async()
1829            .await;
1830
1831        let desc = make_http_test_descriptor(&server.url());
1832        let client = ConfigurableExchangeClient::new(desc);
1833        let err = client.fetch_recent_trades("BTCUSDT", 10).await.unwrap_err();
1834        let err_msg = err.to_string();
1835        assert!(
1836            err_msg.contains("expected array for trades"),
1837            "expected error message to contain 'expected array for trades', got: {}",
1838            err_msg
1839        );
1840        mock.assert_async().await;
1841    }
1842
1843    #[tokio::test]
1844    async fn test_fetch_with_custom_headers() {
1845        let mut server = mockito::Server::new_async().await;
1846        let mut headers = std::collections::HashMap::new();
1847        headers.insert("X-Api-Key".to_string(), "test123".to_string());
1848        let mock = server
1849            .mock("GET", "/api/v1/ticker")
1850            .match_header("x-api-key", "test123")
1851            .match_query(mockito::Matcher::UrlEncoded(
1852                "symbol".into(),
1853                "BTCUSDT".into(),
1854            ))
1855            .with_status(200)
1856            .with_body(
1857                serde_json::json!({
1858                    "lastPrice": "50100.5",
1859                    "highPrice": "51200.0",
1860                    "lowPrice": "48800.0",
1861                    "volume": "1234.56",
1862                    "quoteVolume": "62000000.0",
1863                    "bidPrice": "50095.0",
1864                    "askPrice": "50105.0"
1865                })
1866                .to_string(),
1867            )
1868            .create_async()
1869            .await;
1870
1871        let mut desc = make_http_test_descriptor(&server.url());
1872        desc.headers = headers;
1873        desc.capabilities.ticker.as_mut().unwrap().response = ResponseMapping {
1874            last_price: Some("lastPrice".to_string()),
1875            high_24h: Some("highPrice".to_string()),
1876            low_24h: Some("lowPrice".to_string()),
1877            volume_24h: Some("volume".to_string()),
1878            quote_volume_24h: Some("quoteVolume".to_string()),
1879            best_bid: Some("bidPrice".to_string()),
1880            best_ask: Some("askPrice".to_string()),
1881            ..Default::default()
1882        };
1883
1884        let client = ConfigurableExchangeClient::new(desc);
1885        let ticker = client.fetch_ticker("BTCUSDT").await.unwrap();
1886        assert_eq!(ticker.last_price, Some(50100.5));
1887        mock.assert_async().await;
1888    }
1889
1890    #[tokio::test]
1891    async fn test_fetch_trades_no_capability() {
1892        let desc = make_test_descriptor();
1893        let client = ConfigurableExchangeClient::new(desc);
1894        let err = client.fetch_recent_trades("BTCUSDT", 10).await.unwrap_err();
1895        let err_msg = err.to_string();
1896        assert!(
1897            err_msg.contains("does not support trades"),
1898            "expected error message to contain 'does not support trades', got: {}",
1899            err_msg
1900        );
1901    }
1902
1903    #[test]
1904    fn test_navigate_root_index_out_of_bounds() {
1905        let desc = make_test_descriptor();
1906        let client = ConfigurableExchangeClient::new(desc);
1907        let json = serde_json::json!({"data": [1, 2]});
1908        let result = client.navigate_root(&json, Some("data.5"));
1909        assert!(result.is_err());
1910        assert!(result.unwrap_err().to_string().contains("out of bounds"));
1911    }
1912
1913    #[test]
1914    fn test_navigate_root_missing_key() {
1915        let desc = make_test_descriptor();
1916        let client = ConfigurableExchangeClient::new(desc);
1917        let json = serde_json::json!({"data": {"nested": 1}});
1918        let result = client.navigate_root(&json, Some("data.missing_key"));
1919        assert!(result.is_err());
1920        assert!(result.unwrap_err().to_string().contains("missing key"));
1921    }
1922
1923    #[test]
1924    fn test_interpolate_json_array() {
1925        let desc = make_test_descriptor();
1926        let client = ConfigurableExchangeClient::new(desc);
1927        let template = serde_json::json!(["{pair}", "{limit}", 42]);
1928        let result = client.interpolate_json(&template, "BTC_USDT", "100");
1929        assert_eq!(result, serde_json::json!(["BTC_USDT", "100", 42]));
1930    }
1931
1932    #[test]
1933    fn test_interpolate_json_passthrough() {
1934        let desc = make_test_descriptor();
1935        let client = ConfigurableExchangeClient::new(desc);
1936        assert_eq!(
1937            client.interpolate_json(&serde_json::json!(42), "BTC", "100"),
1938            serde_json::json!(42)
1939        );
1940        assert_eq!(
1941            client.interpolate_json(&serde_json::json!(true), "BTC", "100"),
1942            serde_json::json!(true)
1943        );
1944        assert_eq!(
1945            client.interpolate_json(&serde_json::json!(null), "BTC", "100"),
1946            serde_json::json!(null)
1947        );
1948    }
1949
1950    // =================================================================
1951    // OHLC / kline tests
1952    // =================================================================
1953
1954    fn make_ohlc_test_descriptor(base_url: &str) -> VenueDescriptor {
1955        use crate::market::descriptor::*;
1956        VenueDescriptor {
1957            id: "ohlc_mock".to_string(),
1958            name: "OHLC Mock".to_string(),
1959            base_url: base_url.to_string(),
1960            timeout_secs: Some(5),
1961            rate_limit_per_sec: None,
1962            symbol: SymbolConfig {
1963                template: "{base}{quote}".to_string(),
1964                default_quote: "USDT".to_string(),
1965                case: SymbolCase::Upper,
1966            },
1967            headers: std::collections::HashMap::new(),
1968            capabilities: CapabilitySet {
1969                order_book: None,
1970                ticker: None,
1971                trades: None,
1972                ohlc: Some(EndpointDescriptor {
1973                    path: "/api/v1/klines".to_string(),
1974                    method: HttpMethod::GET,
1975                    params: [
1976                        ("symbol".to_string(), "{pair}".to_string()),
1977                        ("interval".to_string(), "{interval}".to_string()),
1978                        ("limit".to_string(), "{limit}".to_string()),
1979                    ]
1980                    .into_iter()
1981                    .collect(),
1982                    request_body: None,
1983                    response_root: None,
1984                    interval_map: std::collections::HashMap::new(),
1985                    response: ResponseMapping {
1986                        ohlc_format: Some("array_of_arrays".to_string()),
1987                        ohlc_fields: Some(vec![
1988                            "open_time".to_string(),
1989                            "open".to_string(),
1990                            "high".to_string(),
1991                            "low".to_string(),
1992                            "close".to_string(),
1993                            "volume".to_string(),
1994                            "close_time".to_string(),
1995                        ]),
1996                        ..Default::default()
1997                    },
1998                }),
1999            },
2000        }
2001    }
2002
2003    #[tokio::test]
2004    async fn test_fetch_ohlc_array_of_arrays() {
2005        let mut server = mockito::Server::new_async().await;
2006        let mock = server
2007            .mock("GET", "/api/v1/klines")
2008            .match_query(mockito::Matcher::AllOf(vec![
2009                mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2010                mockito::Matcher::UrlEncoded("interval".into(), "1h".into()),
2011                mockito::Matcher::UrlEncoded("limit".into(), "3".into()),
2012            ]))
2013            .with_status(200)
2014            .with_body(
2015                serde_json::json!([
2016                    [
2017                        1700000000000u64,
2018                        "50000.0",
2019                        "50500.0",
2020                        "49800.0",
2021                        "50200.0",
2022                        "100.5",
2023                        1700003599999u64
2024                    ],
2025                    [
2026                        1700003600000u64,
2027                        "50200.0",
2028                        "50800.0",
2029                        "50100.0",
2030                        "50700.0",
2031                        "120.3",
2032                        1700007199999u64
2033                    ],
2034                    [
2035                        1700007200000u64,
2036                        "50700.0",
2037                        "51000.0",
2038                        "50600.0",
2039                        "50900.0",
2040                        "95.7",
2041                        1700010799999u64
2042                    ]
2043                ])
2044                .to_string(),
2045            )
2046            .create_async()
2047            .await;
2048
2049        let desc = make_ohlc_test_descriptor(&server.url());
2050        let client = ConfigurableExchangeClient::new(desc);
2051        let candles = client.fetch_ohlc("BTCUSDT", "1h", 3).await.unwrap();
2052
2053        assert_eq!(candles.len(), 3);
2054        assert_eq!(candles[0].open_time, 1700000000000);
2055        assert_eq!(candles[0].open, 50000.0);
2056        assert_eq!(candles[0].high, 50500.0);
2057        assert_eq!(candles[0].low, 49800.0);
2058        assert_eq!(candles[0].close, 50200.0);
2059        assert_eq!(candles[0].volume, 100.5);
2060        assert_eq!(candles[0].close_time, 1700003599999);
2061        assert_eq!(candles[2].open, 50700.0);
2062        mock.assert_async().await;
2063    }
2064
2065    #[tokio::test]
2066    async fn test_fetch_ohlc_object_format() {
2067        use crate::market::descriptor::*;
2068        let mut server = mockito::Server::new_async().await;
2069        let mock = server
2070            .mock("GET", "/api/v1/candles")
2071            .match_query(mockito::Matcher::AllOf(vec![
2072                mockito::Matcher::UrlEncoded("symbol".into(), "ETHUSDT".into()),
2073                mockito::Matcher::UrlEncoded("interval".into(), "15m".into()),
2074                mockito::Matcher::UrlEncoded("limit".into(), "2".into()),
2075            ]))
2076            .with_status(200)
2077            .with_body(
2078                serde_json::json!([
2079                    {
2080                        "ts": 1700000000000u64,
2081                        "o": "3000.0",
2082                        "h": "3050.0",
2083                        "l": "2980.0",
2084                        "c": "3020.0",
2085                        "vol": "500.0",
2086                        "ct": 1700000899999u64
2087                    },
2088                    {
2089                        "ts": 1700000900000u64,
2090                        "o": "3020.0",
2091                        "h": "3080.0",
2092                        "l": "3010.0",
2093                        "c": "3060.0",
2094                        "vol": "420.0",
2095                        "ct": 1700001799999u64
2096                    }
2097                ])
2098                .to_string(),
2099            )
2100            .create_async()
2101            .await;
2102
2103        let desc = VenueDescriptor {
2104            id: "ohlc_obj_mock".to_string(),
2105            name: "OHLC Obj Mock".to_string(),
2106            base_url: server.url(),
2107            timeout_secs: Some(5),
2108            rate_limit_per_sec: None,
2109            symbol: SymbolConfig {
2110                template: "{base}{quote}".to_string(),
2111                default_quote: "USDT".to_string(),
2112                case: SymbolCase::Upper,
2113            },
2114            headers: std::collections::HashMap::new(),
2115            capabilities: CapabilitySet {
2116                order_book: None,
2117                ticker: None,
2118                trades: None,
2119                ohlc: Some(EndpointDescriptor {
2120                    path: "/api/v1/candles".to_string(),
2121                    method: HttpMethod::GET,
2122                    params: [
2123                        ("symbol".to_string(), "{pair}".to_string()),
2124                        ("interval".to_string(), "{interval}".to_string()),
2125                        ("limit".to_string(), "{limit}".to_string()),
2126                    ]
2127                    .into_iter()
2128                    .collect(),
2129                    request_body: None,
2130                    response_root: None,
2131                    interval_map: std::collections::HashMap::new(),
2132                    response: ResponseMapping {
2133                        ohlc_format: Some("objects".to_string()),
2134                        open_time: Some("ts".to_string()),
2135                        open: Some("o".to_string()),
2136                        high: Some("h".to_string()),
2137                        low: Some("l".to_string()),
2138                        close: Some("c".to_string()),
2139                        ohlc_volume: Some("vol".to_string()),
2140                        close_time: Some("ct".to_string()),
2141                        ..Default::default()
2142                    },
2143                }),
2144            },
2145        };
2146
2147        let client = ConfigurableExchangeClient::new(desc);
2148        let candles = client.fetch_ohlc("ETHUSDT", "15m", 2).await.unwrap();
2149
2150        assert_eq!(candles.len(), 2);
2151        assert_eq!(candles[0].open_time, 1700000000000);
2152        assert_eq!(candles[0].open, 3000.0);
2153        assert_eq!(candles[0].high, 3050.0);
2154        assert_eq!(candles[0].low, 2980.0);
2155        assert_eq!(candles[0].close, 3020.0);
2156        assert_eq!(candles[0].volume, 500.0);
2157        assert_eq!(candles[1].close, 3060.0);
2158        mock.assert_async().await;
2159    }
2160
2161    #[tokio::test]
2162    async fn test_fetch_ohlc_no_capability() {
2163        let desc = make_test_descriptor();
2164        let client = ConfigurableExchangeClient::new(desc);
2165        let err = client.fetch_ohlc("BTCUSDT", "1h", 100).await.unwrap_err();
2166        let msg = err.to_string();
2167        assert!(
2168            msg.contains("does not support OHLC"),
2169            "expected OHLC error, got: {}",
2170            msg
2171        );
2172    }
2173
2174    /// Verifies that `interval_map` translates canonical intervals to venue-specific
2175    /// names before sending the HTTP request (e.g., Biconomy "1h" → "hour").
2176    #[tokio::test]
2177    async fn test_fetch_ohlc_interval_map() {
2178        use crate::market::descriptor::*;
2179        let mut server = mockito::Server::new_async().await;
2180        // Expect the mapped interval "hour" rather than the canonical "1h"
2181        let mock = server
2182            .mock("GET", "/api/v1/kline")
2183            .match_query(mockito::Matcher::AllOf(vec![
2184                mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2185                mockito::Matcher::UrlEncoded("type".into(), "hour".into()),
2186                mockito::Matcher::UrlEncoded("size".into(), "2".into()),
2187            ]))
2188            .with_status(200)
2189            .with_body(
2190                serde_json::json!([
2191                    [
2192                        1700000000000u64,
2193                        "50000.0",
2194                        "50500.0",
2195                        "49800.0",
2196                        "50200.0",
2197                        "100.5"
2198                    ],
2199                    [
2200                        1700003600000u64,
2201                        "50200.0",
2202                        "50800.0",
2203                        "50100.0",
2204                        "50700.0",
2205                        "120.3"
2206                    ]
2207                ])
2208                .to_string(),
2209            )
2210            .create_async()
2211            .await;
2212
2213        let desc = VenueDescriptor {
2214            id: "interval_map_test".to_string(),
2215            name: "Interval Map Test".to_string(),
2216            base_url: server.url(),
2217            timeout_secs: Some(5),
2218            rate_limit_per_sec: None,
2219            symbol: SymbolConfig {
2220                template: "{base}{quote}".to_string(),
2221                default_quote: "USDT".to_string(),
2222                case: SymbolCase::Upper,
2223            },
2224            headers: std::collections::HashMap::new(),
2225            capabilities: CapabilitySet {
2226                order_book: None,
2227                ticker: None,
2228                trades: None,
2229                ohlc: Some(EndpointDescriptor {
2230                    path: "/api/v1/kline".to_string(),
2231                    method: HttpMethod::GET,
2232                    params: [
2233                        ("symbol".to_string(), "{pair}".to_string()),
2234                        ("type".to_string(), "{interval}".to_string()),
2235                        ("size".to_string(), "{limit}".to_string()),
2236                    ]
2237                    .into_iter()
2238                    .collect(),
2239                    request_body: None,
2240                    response_root: None,
2241                    interval_map: [
2242                        ("1m".to_string(), "1min".to_string()),
2243                        ("5m".to_string(), "5min".to_string()),
2244                        ("1h".to_string(), "hour".to_string()),
2245                        ("1d".to_string(), "day".to_string()),
2246                    ]
2247                    .into_iter()
2248                    .collect(),
2249                    response: ResponseMapping {
2250                        ohlc_format: Some("array_of_arrays".to_string()),
2251                        ohlc_fields: Some(vec![
2252                            "open_time".to_string(),
2253                            "open".to_string(),
2254                            "high".to_string(),
2255                            "low".to_string(),
2256                            "close".to_string(),
2257                            "volume".to_string(),
2258                        ]),
2259                        ..Default::default()
2260                    },
2261                }),
2262            },
2263        };
2264
2265        let client = ConfigurableExchangeClient::new(desc);
2266        let candles = client.fetch_ohlc("BTCUSDT", "1h", 2).await.unwrap();
2267        assert_eq!(candles.len(), 2);
2268        assert_eq!(candles[0].open, 50000.0);
2269        assert_eq!(candles[1].close, 50700.0);
2270        mock.assert_async().await;
2271    }
2272
2273    /// When the interval is not in the map, the canonical value passes through.
2274    #[tokio::test]
2275    async fn test_fetch_ohlc_interval_map_passthrough() {
2276        use crate::market::descriptor::*;
2277        let mut server = mockito::Server::new_async().await;
2278        // "15m" is not in the interval_map, so it should pass through unchanged
2279        let mock = server
2280            .mock("GET", "/api/v1/kline")
2281            .match_query(mockito::Matcher::AllOf(vec![
2282                mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2283                mockito::Matcher::UrlEncoded("type".into(), "15m".into()),
2284                mockito::Matcher::UrlEncoded("size".into(), "1".into()),
2285            ]))
2286            .with_status(200)
2287            .with_body(
2288                serde_json::json!([[
2289                    1700000000000u64,
2290                    "50000.0",
2291                    "50500.0",
2292                    "49800.0",
2293                    "50200.0",
2294                    "100.5"
2295                ]])
2296                .to_string(),
2297            )
2298            .create_async()
2299            .await;
2300
2301        let desc = VenueDescriptor {
2302            id: "passthrough_test".to_string(),
2303            name: "Passthrough Test".to_string(),
2304            base_url: server.url(),
2305            timeout_secs: Some(5),
2306            rate_limit_per_sec: None,
2307            symbol: SymbolConfig {
2308                template: "{base}{quote}".to_string(),
2309                default_quote: "USDT".to_string(),
2310                case: SymbolCase::Upper,
2311            },
2312            headers: std::collections::HashMap::new(),
2313            capabilities: CapabilitySet {
2314                order_book: None,
2315                ticker: None,
2316                trades: None,
2317                ohlc: Some(EndpointDescriptor {
2318                    path: "/api/v1/kline".to_string(),
2319                    method: HttpMethod::GET,
2320                    params: [
2321                        ("symbol".to_string(), "{pair}".to_string()),
2322                        ("type".to_string(), "{interval}".to_string()),
2323                        ("size".to_string(), "{limit}".to_string()),
2324                    ]
2325                    .into_iter()
2326                    .collect(),
2327                    request_body: None,
2328                    response_root: None,
2329                    // Only "1h" → "hour" mapped; "15m" should pass through as-is
2330                    interval_map: [("1h".to_string(), "hour".to_string())]
2331                        .into_iter()
2332                        .collect(),
2333                    response: ResponseMapping {
2334                        ohlc_format: Some("array_of_arrays".to_string()),
2335                        ohlc_fields: Some(vec![
2336                            "open_time".to_string(),
2337                            "open".to_string(),
2338                            "high".to_string(),
2339                            "low".to_string(),
2340                            "close".to_string(),
2341                            "volume".to_string(),
2342                        ]),
2343                        ..Default::default()
2344                    },
2345                }),
2346            },
2347        };
2348
2349        let client = ConfigurableExchangeClient::new(desc);
2350        let candles = client.fetch_ohlc("BTCUSDT", "15m", 1).await.unwrap();
2351        assert_eq!(candles.len(), 1);
2352        mock.assert_async().await;
2353    }
2354
2355    #[tokio::test]
2356    async fn test_fetch_ohlc_non_array_response() {
2357        let mut server = mockito::Server::new_async().await;
2358        let mock = server
2359            .mock("GET", "/api/v1/klines")
2360            .match_query(mockito::Matcher::AllOf(vec![
2361                mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2362                mockito::Matcher::UrlEncoded("interval".into(), "1h".into()),
2363                mockito::Matcher::UrlEncoded("limit".into(), "100".into()),
2364            ]))
2365            .with_status(200)
2366            .with_body(serde_json::json!({"error": "not an array"}).to_string())
2367            .create_async()
2368            .await;
2369
2370        let desc = make_ohlc_test_descriptor(&server.url());
2371        let client = ConfigurableExchangeClient::new(desc);
2372        let err = client.fetch_ohlc("BTCUSDT", "1h", 100).await.unwrap_err();
2373        let msg = err.to_string();
2374        assert!(
2375            msg.contains("expected array for OHLC"),
2376            "expected array error, got: {}",
2377            msg
2378        );
2379        mock.assert_async().await;
2380    }
2381
2382    #[tokio::test]
2383    async fn test_fetch_ohlc_empty_array() {
2384        let mut server = mockito::Server::new_async().await;
2385        let mock = server
2386            .mock("GET", "/api/v1/klines")
2387            .match_query(mockito::Matcher::AllOf(vec![
2388                mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2389                mockito::Matcher::UrlEncoded("interval".into(), "1d".into()),
2390                mockito::Matcher::UrlEncoded("limit".into(), "10".into()),
2391            ]))
2392            .with_status(200)
2393            .with_body("[]")
2394            .create_async()
2395            .await;
2396
2397        let desc = make_ohlc_test_descriptor(&server.url());
2398        let client = ConfigurableExchangeClient::new(desc);
2399        let candles = client.fetch_ohlc("BTCUSDT", "1d", 10).await.unwrap();
2400        assert!(candles.is_empty());
2401        mock.assert_async().await;
2402    }
2403
2404    #[tokio::test]
2405    async fn test_fetch_ohlc_skips_malformed_inner_items() {
2406        let mut server = mockito::Server::new_async().await;
2407        let mock = server
2408            .mock("GET", "/api/v1/klines")
2409            .match_query(mockito::Matcher::AllOf(vec![
2410                mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2411                mockito::Matcher::UrlEncoded("interval".into(), "1h".into()),
2412                mockito::Matcher::UrlEncoded("limit".into(), "5".into()),
2413            ]))
2414            .with_status(200)
2415            .with_body(
2416                serde_json::json!([
2417                    "not an array",
2418                    [
2419                        1700000000000u64,
2420                        "50000.0",
2421                        "50500.0",
2422                        "49800.0",
2423                        "50200.0",
2424                        "100.5",
2425                        1700003599999u64
2426                    ],
2427                    42
2428                ])
2429                .to_string(),
2430            )
2431            .create_async()
2432            .await;
2433
2434        let desc = make_ohlc_test_descriptor(&server.url());
2435        let client = ConfigurableExchangeClient::new(desc);
2436        let candles = client.fetch_ohlc("BTCUSDT", "1h", 5).await.unwrap();
2437        // Only the valid inner array should be parsed
2438        assert_eq!(candles.len(), 1);
2439        assert_eq!(candles[0].open, 50000.0);
2440        mock.assert_async().await;
2441    }
2442
2443    #[tokio::test]
2444    async fn test_fetch_ohlc_with_response_root() {
2445        let mut server = mockito::Server::new_async().await;
2446        let mock = server
2447            .mock("GET", "/api/v1/klines")
2448            .match_query(mockito::Matcher::AllOf(vec![
2449                mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2450                mockito::Matcher::UrlEncoded("interval".into(), "4h".into()),
2451                mockito::Matcher::UrlEncoded("limit".into(), "2".into()),
2452            ]))
2453            .with_status(200)
2454            .with_body(
2455                serde_json::json!({
2456                    "result": [
2457                        [1700000000000u64, "50000.0", "50500.0", "49800.0", "50200.0", "100.5", 1700003599999u64],
2458                        [1700003600000u64, "50200.0", "50800.0", "50100.0", "50700.0", "120.3", 1700007199999u64]
2459                    ]
2460                })
2461                .to_string(),
2462            )
2463            .create_async()
2464            .await;
2465
2466        let mut desc = make_ohlc_test_descriptor(&server.url());
2467        desc.capabilities.ohlc.as_mut().unwrap().response_root = Some("result".to_string());
2468
2469        let client = ConfigurableExchangeClient::new(desc);
2470        let candles = client.fetch_ohlc("BTCUSDT", "4h", 2).await.unwrap();
2471        assert_eq!(candles.len(), 2);
2472        mock.assert_async().await;
2473    }
2474
2475    #[test]
2476    fn test_interpolate_value_full_with_interval() {
2477        let desc = make_test_descriptor();
2478        let client = ConfigurableExchangeClient::new(desc);
2479        let result =
2480            client.interpolate_value_full("{pair}_{interval}_{limit}", "BTCUSDT", "50", "1h");
2481        assert_eq!(result, "BTCUSDT_1h_50");
2482    }
2483
2484    #[test]
2485    fn test_interpolate_json_full_with_interval() {
2486        let desc = make_test_descriptor();
2487        let client = ConfigurableExchangeClient::new(desc);
2488        let template = serde_json::json!({
2489            "symbol": "{pair}",
2490            "interval": "{interval}",
2491            "limit": "{limit}"
2492        });
2493        let result = client.interpolate_json_full(&template, "ETHUSDT", "100", "15m");
2494        assert_eq!(result["symbol"], "ETHUSDT");
2495        assert_eq!(result["interval"], "15m");
2496        assert_eq!(result["limit"], "100");
2497    }
2498
2499    #[tokio::test]
2500    async fn test_fetch_ohlc_via_post_method() {
2501        use crate::market::descriptor::*;
2502        let mut server = mockito::Server::new_async().await;
2503        let mock = server
2504            .mock("POST", "/api/v1/klines")
2505            .with_status(200)
2506            .with_body(
2507                serde_json::json!([[
2508                    1700000000000u64,
2509                    "50000.0",
2510                    "50500.0",
2511                    "49800.0",
2512                    "50200.0",
2513                    "100.5",
2514                    1700003599999u64
2515                ]])
2516                .to_string(),
2517            )
2518            .create_async()
2519            .await;
2520
2521        let desc = VenueDescriptor {
2522            id: "post_ohlc".to_string(),
2523            name: "POST OHLC".to_string(),
2524            base_url: server.url(),
2525            timeout_secs: Some(5),
2526            rate_limit_per_sec: None,
2527            symbol: SymbolConfig {
2528                template: "{base}{quote}".to_string(),
2529                default_quote: "USDT".to_string(),
2530                case: SymbolCase::Upper,
2531            },
2532            headers: std::collections::HashMap::new(),
2533            capabilities: CapabilitySet {
2534                order_book: None,
2535                ticker: None,
2536                trades: None,
2537                ohlc: Some(EndpointDescriptor {
2538                    path: "/api/v1/klines".to_string(),
2539                    method: HttpMethod::POST,
2540                    params: std::collections::HashMap::new(),
2541                    request_body: Some(serde_json::json!({
2542                        "symbol": "{pair}",
2543                        "interval": "{interval}",
2544                        "limit": "{limit}"
2545                    })),
2546                    response_root: None,
2547                    interval_map: std::collections::HashMap::new(),
2548                    response: ResponseMapping {
2549                        ohlc_format: Some("array_of_arrays".to_string()),
2550                        ohlc_fields: Some(vec![
2551                            "open_time".to_string(),
2552                            "open".to_string(),
2553                            "high".to_string(),
2554                            "low".to_string(),
2555                            "close".to_string(),
2556                            "volume".to_string(),
2557                            "close_time".to_string(),
2558                        ]),
2559                        ..Default::default()
2560                    },
2561                }),
2562            },
2563        };
2564
2565        let client = ConfigurableExchangeClient::new(desc);
2566        let candles = client.fetch_ohlc("BTCUSDT", "1h", 1).await.unwrap();
2567        assert_eq!(candles.len(), 1);
2568        assert_eq!(candles[0].open, 50000.0);
2569        mock.assert_async().await;
2570    }
2571
2572    #[tokio::test]
2573    async fn test_fetch_ohlc_post_http_error() {
2574        use crate::market::descriptor::*;
2575        let mut server = mockito::Server::new_async().await;
2576        let mock = server
2577            .mock("POST", "/api/v1/klines")
2578            .with_status(500)
2579            .with_body("Internal Server Error")
2580            .create_async()
2581            .await;
2582
2583        let desc = VenueDescriptor {
2584            id: "post_ohlc_err".to_string(),
2585            name: "POST OHLC Err".to_string(),
2586            base_url: server.url(),
2587            timeout_secs: Some(5),
2588            rate_limit_per_sec: None,
2589            symbol: SymbolConfig {
2590                template: "{base}{quote}".to_string(),
2591                default_quote: "USDT".to_string(),
2592                case: SymbolCase::Upper,
2593            },
2594            headers: std::collections::HashMap::new(),
2595            capabilities: CapabilitySet {
2596                order_book: None,
2597                ticker: None,
2598                trades: None,
2599                ohlc: Some(EndpointDescriptor {
2600                    path: "/api/v1/klines".to_string(),
2601                    method: HttpMethod::POST,
2602                    params: std::collections::HashMap::new(),
2603                    request_body: None,
2604                    response_root: None,
2605                    interval_map: std::collections::HashMap::new(),
2606                    response: ResponseMapping {
2607                        ohlc_format: Some("array_of_arrays".to_string()),
2608                        ..Default::default()
2609                    },
2610                }),
2611            },
2612        };
2613
2614        let client = ConfigurableExchangeClient::new(desc);
2615        let err = client.fetch_ohlc("BTCUSDT", "1h", 100).await.unwrap_err();
2616        assert!(err.to_string().contains("API error"));
2617        mock.assert_async().await;
2618    }
2619
2620    #[tokio::test]
2621    async fn test_fetch_ohlc_get_http_error() {
2622        let mut server = mockito::Server::new_async().await;
2623        let mock = server
2624            .mock("GET", "/api/v1/klines")
2625            .match_query(mockito::Matcher::AllOf(vec![
2626                mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2627                mockito::Matcher::UrlEncoded("interval".into(), "1h".into()),
2628                mockito::Matcher::UrlEncoded("limit".into(), "100".into()),
2629            ]))
2630            .with_status(429)
2631            .with_body("Rate limited")
2632            .create_async()
2633            .await;
2634
2635        let desc = make_ohlc_test_descriptor(&server.url());
2636        let client = ConfigurableExchangeClient::new(desc);
2637        let err = client.fetch_ohlc("BTCUSDT", "1h", 100).await.unwrap_err();
2638        assert!(err.to_string().contains("API error"));
2639        mock.assert_async().await;
2640    }
2641
2642    #[tokio::test]
2643    async fn test_fetch_ohlc_with_items_key() {
2644        use crate::market::descriptor::*;
2645        let mut server = mockito::Server::new_async().await;
2646        let mock = server
2647            .mock("GET", "/api/v1/candles")
2648            .match_query(mockito::Matcher::AllOf(vec![
2649                mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
2650                mockito::Matcher::UrlEncoded("interval".into(), "1h".into()),
2651                mockito::Matcher::UrlEncoded("limit".into(), "2".into()),
2652            ]))
2653            .with_status(200)
2654            .with_body(
2655                serde_json::json!({
2656                    "data": [
2657                        {"ts": 1700000000000u64, "o": "100.0", "h": "110.0", "l": "90.0", "c": "105.0", "vol": "1000.0", "ct": 1700003599999u64},
2658                        {"ts": 1700003600000u64, "o": "105.0", "h": "115.0", "l": "100.0", "c": "110.0", "vol": "800.0", "ct": 1700007199999u64}
2659                    ]
2660                })
2661                .to_string(),
2662            )
2663            .create_async()
2664            .await;
2665
2666        let desc = VenueDescriptor {
2667            id: "items_key_ohlc".to_string(),
2668            name: "Items Key OHLC".to_string(),
2669            base_url: server.url(),
2670            timeout_secs: Some(5),
2671            rate_limit_per_sec: None,
2672            symbol: SymbolConfig {
2673                template: "{base}{quote}".to_string(),
2674                default_quote: "USDT".to_string(),
2675                case: SymbolCase::Upper,
2676            },
2677            headers: std::collections::HashMap::new(),
2678            capabilities: CapabilitySet {
2679                order_book: None,
2680                ticker: None,
2681                trades: None,
2682                ohlc: Some(EndpointDescriptor {
2683                    path: "/api/v1/candles".to_string(),
2684                    method: HttpMethod::GET,
2685                    params: [
2686                        ("symbol".to_string(), "{pair}".to_string()),
2687                        ("interval".to_string(), "{interval}".to_string()),
2688                        ("limit".to_string(), "{limit}".to_string()),
2689                    ]
2690                    .into_iter()
2691                    .collect(),
2692                    request_body: None,
2693                    response_root: None,
2694                    interval_map: std::collections::HashMap::new(),
2695                    response: ResponseMapping {
2696                        items_key: Some("data".to_string()),
2697                        ohlc_format: Some("objects".to_string()),
2698                        open_time: Some("ts".to_string()),
2699                        open: Some("o".to_string()),
2700                        high: Some("h".to_string()),
2701                        low: Some("l".to_string()),
2702                        close: Some("c".to_string()),
2703                        ohlc_volume: Some("vol".to_string()),
2704                        close_time: Some("ct".to_string()),
2705                        ..Default::default()
2706                    },
2707                }),
2708            },
2709        };
2710
2711        let client = ConfigurableExchangeClient::new(desc);
2712        let candles = client.fetch_ohlc("BTCUSDT", "1h", 2).await.unwrap();
2713        assert_eq!(candles.len(), 2);
2714        assert_eq!(candles[0].open, 100.0);
2715        assert_eq!(candles[1].close, 110.0);
2716        mock.assert_async().await;
2717    }
2718}