Skip to main content

scope/market/
configurable_client.rs

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