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