Skip to main content

scope_web/api/
exchange.rs

1//! Exchange snapshot API handler.
2//!
3//! POST /api/exchange/snapshot — Fetches full market snapshot (order book, ticker,
4//! recent trades) for a given venue and pair.
5
6use super::{error_response, status_for};
7use crate::AppState;
8use axum::Json;
9use axum::extract::State;
10use axum::response::{IntoResponse, Response};
11use scope::market::{ExchangeClient, Pair, VenueRegistry};
12use serde::Deserialize;
13use std::sync::Arc;
14
15/// Request body for exchange snapshot.
16#[derive(Debug, Deserialize)]
17pub struct SnapshotRequest {
18    /// Venue ID (e.g., "binance", "mexc").
19    pub venue: String,
20    /// Pair or base symbol, read by `Pair::parse` (e.g., "BTC", "ETH_BTC", "OPUSDT").
21    #[serde(default = "default_pair")]
22    pub pair: String,
23    /// Maximum number of recent trades to fetch.
24    #[serde(default = "default_trades_limit")]
25    pub trades_limit: u32,
26}
27
28/// The client for `venue`. An unknown venue is InvalidInput, which the one
29/// status map makes a 400.
30fn venue_client(state: &AppState, venue: &str) -> scope::Result<ExchangeClient> {
31    VenueRegistry::load()
32        .and_then(|r| r.create_exchange_client_with_http(venue, state.factory.http()))
33}
34
35fn default_pair() -> String {
36    "BTC".to_string()
37}
38
39fn default_trades_limit() -> u32 {
40    scope::market::exchange::DEFAULT_SNAPSHOT_TRADES
41}
42
43/// POST /api/exchange/snapshot — Full market snapshot.
44pub async fn handle(
45    State(state): State<Arc<AppState>>,
46    Json(req): Json<SnapshotRequest>,
47) -> impl IntoResponse {
48    match venue_client(&state, &req.venue) {
49        Ok(exchange) => snapshot_response(&exchange, req).await,
50        Err(e) => error_response(status_for(&e), e.to_string()),
51    }
52}
53
54/// The `snapshot` reply from `exchange`.
55async fn snapshot_response(exchange: &ExchangeClient, req: SnapshotRequest) -> Response {
56    let pair = exchange.venue_symbol(&Pair::parse(&req.pair));
57    let snapshot = exchange
58        .fetch_market_snapshot(&pair, req.trades_limit)
59        .await;
60
61    let order_book_json = snapshot.order_book.as_ref().map(|b| b.to_json(None));
62
63    let ticker_json = snapshot.ticker.as_ref();
64
65    let trades_json = snapshot.recent_trades.as_ref();
66
67    let output = serde_json::json!({
68        "venue": req.venue,
69        "pair": pair,
70        "order_book": order_book_json,
71        "ticker": ticker_json,
72        "recent_trades": trades_json,
73    });
74
75    Json(output).into_response()
76}
77
78// =============================================================================
79// POST /api/exchange/trades
80// =============================================================================
81
82/// Request body for exchange trades.
83#[derive(Debug, Deserialize)]
84pub struct TradesRequest {
85    /// Venue ID (e.g., "binance", "mexc").
86    pub venue: String,
87    /// Pair or base symbol, read by `Pair::parse` (e.g., "BTC", "ETH_BTC", "OPUSDT").
88    #[serde(default = "default_pair")]
89    pub pair: String,
90    /// Maximum number of trades to return.
91    #[serde(default = "default_trades_limit")]
92    pub limit: u32,
93}
94
95/// POST /api/exchange/trades — Recent trades for a venue/pair.
96pub async fn handle_trades(
97    State(state): State<Arc<AppState>>,
98    Json(req): Json<TradesRequest>,
99) -> impl IntoResponse {
100    match venue_client(&state, &req.venue) {
101        Ok(exchange) => trades_response(&exchange, req).await,
102        Err(e) => error_response(status_for(&e), e.to_string()),
103    }
104}
105
106/// The `trades` reply from `exchange`.
107async fn trades_response(exchange: &ExchangeClient, req: TradesRequest) -> Response {
108    let pair = exchange.venue_symbol(&Pair::parse(&req.pair));
109    match exchange.fetch_recent_trades(&pair, req.limit).await {
110        Ok(trades) => Json(serde_json::json!({
111            "venue": req.venue,
112            "pair": pair,
113            "trades": trades,
114        }))
115        .into_response(),
116        Err(e) => error_response(status_for(&e), e.to_string()),
117    }
118}
119
120// =============================================================================
121// POST /api/exchange/ohlc
122// =============================================================================
123
124/// Request body for exchange OHLC.
125#[derive(Debug, Deserialize)]
126pub struct OhlcRequest {
127    /// Venue ID (e.g., "binance", "mexc").
128    pub venue: String,
129    /// Pair or base symbol, read by `Pair::parse` (e.g., "BTC", "ETH_BTC", "OPUSDT").
130    #[serde(default = "default_pair")]
131    pub pair: String,
132    /// Candle interval (e.g., "1m", "1h", "1d").
133    #[serde(default = "default_interval")]
134    pub interval: String,
135    /// Maximum number of candles to return.
136    #[serde(default = "default_ohlc_limit")]
137    pub limit: u32,
138}
139
140fn default_interval() -> String {
141    "1h".to_string()
142}
143
144fn default_ohlc_limit() -> u32 {
145    100
146}
147
148/// POST /api/exchange/ohlc — OHLC candlestick data for a venue/pair.
149pub async fn handle_ohlc(
150    State(state): State<Arc<AppState>>,
151    Json(req): Json<OhlcRequest>,
152) -> impl IntoResponse {
153    match venue_client(&state, &req.venue) {
154        Ok(exchange) => ohlc_response(&exchange, req).await,
155        Err(e) => error_response(status_for(&e), e.to_string()),
156    }
157}
158
159/// The `ohlc` reply from `exchange`.
160async fn ohlc_response(exchange: &ExchangeClient, req: OhlcRequest) -> Response {
161    let pair = exchange.venue_symbol(&Pair::parse(&req.pair));
162    match exchange.fetch_ohlc(&pair, &req.interval, req.limit).await {
163        Ok(candles) => Json(serde_json::json!({
164            "venue": req.venue,
165            "pair": pair,
166            "interval": req.interval,
167            "candles": candles,
168        }))
169        .into_response(),
170        Err(e) => error_response(status_for(&e), e.to_string()),
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use axum::http::StatusCode;
178
179    /// State for the handlers: venues are looked up for real, with a
180    /// native transport.
181    fn test_state() -> State<Arc<AppState>> {
182        State(Arc::new(AppState::new(
183            scope::Config::default(),
184            scope::chains::mocks::MockClientFactory::new(),
185        )))
186    }
187    use scope::market::{BookSource, HealthThresholds, MarketSummary, OrderBook, OrderBookLevel};
188
189    #[test]
190    fn test_order_book_json_spread_matches_the_market_summary() {
191        // The top level of each side has zero quantity: not a real order.
192        let level = |price, quantity| OrderBookLevel { price, quantity };
193        let book = OrderBook {
194            pair: "X/USDT".to_string(),
195            source: BookSource::Exchange,
196            bids: vec![level(1.0, 0.0), level(0.999, 500.0)],
197            asks: vec![level(1.0001, 0.0), level(1.001, 500.0)],
198        };
199        let summary = MarketSummary::from_order_book(&book, &HealthThresholds::default(), None);
200        let json = book.to_json(None);
201        assert_eq!(json["spread"].as_f64(), summary.spread);
202        assert_eq!(json["best_bid"].as_f64(), summary.best_bid);
203        assert_eq!(json["mid_price"].as_f64(), summary.mid_price);
204        assert!(json["spread"].as_f64().unwrap() > 0.0019);
205    }
206
207    #[test]
208    fn test_snapshot_request_empty_pair() {
209        let json = serde_json::json!({"venue": "binance", "pair": ""});
210        let req: SnapshotRequest = serde_json::from_value(json).unwrap();
211        assert_eq!(req.pair, "");
212    }
213
214    #[test]
215    fn test_snapshot_request_large_limit() {
216        let json = serde_json::json!({"venue": "x", "trades_limit": 1000});
217        let req: SnapshotRequest = serde_json::from_value(json).unwrap();
218        assert_eq!(req.trades_limit, 1000);
219    }
220
221    #[test]
222    fn test_deserialize_full() {
223        let json = serde_json::json!({
224            "venue": "binance",
225            "pair": "USDC",
226            "trades_limit": 20
227        });
228        let req: SnapshotRequest = serde_json::from_value(json).unwrap();
229        assert_eq!(req.venue, "binance");
230        assert_eq!(req.pair, "USDC");
231        assert_eq!(req.trades_limit, 20);
232    }
233
234    #[test]
235    fn test_deserialize_minimal() {
236        let json = serde_json::json!({
237            "venue": "mexc"
238        });
239        let req: SnapshotRequest = serde_json::from_value(json).unwrap();
240        assert_eq!(req.venue, "mexc");
241        assert_eq!(req.pair, "BTC");
242        assert_eq!(req.trades_limit, 50);
243    }
244
245    #[test]
246    fn test_defaults() {
247        assert_eq!(default_pair(), "BTC");
248        assert_eq!(default_trades_limit(), 50);
249    }
250
251    #[tokio::test]
252    async fn test_handle_unknown_venue() {
253        let req = SnapshotRequest {
254            venue: "nonexistent_venue_xyz".to_string(),
255            pair: "BTC".to_string(),
256            trades_limit: 50,
257        };
258        let response = handle(test_state(), Json(req)).await.into_response();
259        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
260    }
261
262    #[test]
263    fn test_snapshot_request_deserialization_with_defaults() {
264        // Only venue provided, pair and trades_limit should use defaults
265        let json = serde_json::json!({"venue": "kraken"});
266        let req: SnapshotRequest = serde_json::from_value(json).unwrap();
267        assert_eq!(req.venue, "kraken");
268        assert_eq!(req.pair, "BTC");
269        assert_eq!(req.trades_limit, 50);
270    }
271
272    #[test]
273    fn test_trades_request_deserialization() {
274        let json = serde_json::json!({
275            "venue": "binance",
276            "pair": "USDC",
277            "limit": 25
278        });
279        let req: TradesRequest = serde_json::from_value(json).unwrap();
280        assert_eq!(req.venue, "binance");
281        assert_eq!(req.pair, "USDC");
282        assert_eq!(req.limit, 25);
283    }
284
285    #[test]
286    fn test_trades_request_defaults() {
287        let json = serde_json::json!({"venue": "kraken"});
288        let req: TradesRequest = serde_json::from_value(json).unwrap();
289        assert_eq!(req.venue, "kraken");
290        assert_eq!(req.pair, "BTC");
291        assert_eq!(req.limit, 50);
292    }
293
294    #[test]
295    fn test_ohlc_request_deserialization() {
296        let json = serde_json::json!({
297            "venue": "binance",
298            "pair": "ETH",
299            "interval": "4h",
300            "limit": 200
301        });
302        let req: OhlcRequest = serde_json::from_value(json).unwrap();
303        assert_eq!(req.venue, "binance");
304        assert_eq!(req.pair, "ETH");
305        assert_eq!(req.interval, "4h");
306        assert_eq!(req.limit, 200);
307    }
308
309    #[test]
310    fn test_ohlc_request_defaults() {
311        let json = serde_json::json!({"venue": "mexc"});
312        let req: OhlcRequest = serde_json::from_value(json).unwrap();
313        assert_eq!(req.venue, "mexc");
314        assert_eq!(req.pair, "BTC");
315        assert_eq!(req.interval, "1h");
316        assert_eq!(req.limit, 100);
317    }
318
319    #[test]
320    fn test_snapshot_request_debug() {
321        let req = SnapshotRequest {
322            venue: "test".to_string(),
323            pair: "ETH".to_string(),
324            trades_limit: 100,
325        };
326        let debug = format!("{:?}", req);
327        assert!(debug.contains("SnapshotRequest"));
328    }
329
330    #[tokio::test]
331    async fn test_handle_trades_unknown_venue() {
332        let req = TradesRequest {
333            venue: "nonexistent_venue_xyz".to_string(),
334            pair: "BTC".to_string(),
335            limit: 50,
336        };
337        let response = handle_trades(test_state(), Json(req)).await.into_response();
338        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
339        let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
340            .await
341            .unwrap();
342        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
343        assert!(json["error"].as_str().unwrap().contains("Unknown venue"));
344    }
345
346    #[tokio::test]
347    async fn test_handle_ohlc_unknown_venue() {
348        let req = OhlcRequest {
349            venue: "nonexistent_venue_xyz".to_string(),
350            pair: "BTC".to_string(),
351            interval: "1h".to_string(),
352            limit: 100,
353        };
354        let response = handle_ohlc(test_state(), Json(req)).await.into_response();
355        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
356        let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
357            .await
358            .unwrap();
359        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
360        assert!(json["error"].as_str().unwrap().contains("Unknown venue"));
361    }
362
363    #[test]
364    fn test_trades_request_debug() {
365        let req = TradesRequest {
366            venue: "test".to_string(),
367            pair: "ETH".to_string(),
368            limit: 10,
369        };
370        let debug = format!("{:?}", req);
371        assert!(debug.contains("TradesRequest"));
372    }
373
374    #[test]
375    fn test_ohlc_request_debug() {
376        let req = OhlcRequest {
377            venue: "test".to_string(),
378            pair: "ETH".to_string(),
379            interval: "4h".to_string(),
380            limit: 50,
381        };
382        let debug = format!("{:?}", req);
383        assert!(debug.contains("OhlcRequest"));
384    }
385
386    // Each venue's own reply shape, served by a mock: the venue lookup
387    // reads the test home, which other tests repoint while they run, and
388    // the live APIs answer as they please.
389    const VENUE_TRADES: [(&str, &str, &str); 3] = [
390        (
391            "mexc",
392            "/api/v3/trades",
393            r#"[{"id":1,"price":"1.5","qty":"2","quoteQty":"3","time":1700000000000,"isBuyerMaker":false}]"#,
394        ),
395        (
396            "okx",
397            "/api/v5/market/trades",
398            r#"{"data":[{"tradeId":"1","px":"1.5","sz":"2","ts":"1700000000000","side":"buy"}]}"#,
399        ),
400        (
401            "bybit",
402            "/v5/market/recent-trade",
403            r#"{"result":{"list":[{"execId":"1","price":"1.5","size":"2","time":"1700000000000","side":"Buy"}]}}"#,
404        ),
405    ];
406
407    const VENUE_OHLC: [(&str, &str, &str); 3] = [
408        (
409            "mexc",
410            "/api/v3/klines",
411            r#"[[1700000000000,"1.0","1.5","0.5","1.25","1000.5",1700003599999]]"#,
412        ),
413        (
414            "okx",
415            "/api/v5/market/candles",
416            r#"{"data":[["1700000000000","1.0","1.5","0.5","1.25","1000.5"]]}"#,
417        ),
418        (
419            "bybit",
420            "/v5/market/kline",
421            r#"{"result":{"list":[["1700000000000","1.0","1.5","0.5","1.25","1000.5"]]}}"#,
422        ),
423    ];
424
425    #[tokio::test]
426    async fn test_handle_trades_multiple_venues() {
427        for (venue, path, body) in VENUE_TRADES {
428            let mut server = mockito::Server::new_async().await;
429            server
430                .mock("GET", path)
431                .match_query(mockito::Matcher::Any)
432                .with_body(body)
433                .create_async()
434                .await;
435            let exchange = scope::market::testing::venue_at(venue, &server.url());
436            let req = TradesRequest {
437                venue: venue.to_string(),
438                pair: "BTC".to_string(),
439                limit: 3,
440            };
441            let response = trades_response(&exchange, req).await;
442            assert_eq!(response.status(), StatusCode::OK, "{venue} trades");
443            let json = body_json(response).await;
444            assert_eq!(json["trades"][0]["price"], 1.5, "{venue}: {json}");
445            assert_eq!(json["trades"][0]["side"], "buy", "{venue}: {json}");
446        }
447    }
448
449    #[tokio::test]
450    async fn test_handle_ohlc_multiple_venues() {
451        for (venue, path, body) in VENUE_OHLC {
452            let mut server = mockito::Server::new_async().await;
453            server
454                .mock("GET", path)
455                .match_query(mockito::Matcher::Any)
456                .with_body(body)
457                .create_async()
458                .await;
459            let exchange = scope::market::testing::venue_at(venue, &server.url());
460            let req = OhlcRequest {
461                venue: venue.to_string(),
462                pair: "BTC".to_string(),
463                interval: "1h".to_string(),
464                limit: 3,
465            };
466            let response = ohlc_response(&exchange, req).await;
467            assert_eq!(response.status(), StatusCode::OK, "{venue} ohlc");
468            let json = body_json(response).await;
469            assert_eq!(json["candles"][0]["close"], 1.25, "{venue}: {json}");
470        }
471    }
472
473    // A venue that fails is a mapped error status, on every venue.
474    #[tokio::test]
475    async fn test_handle_multiple_venues_report_a_venue_failure() {
476        for ((venue, trades_path, _), (_, ohlc_path, _)) in VENUE_TRADES.into_iter().zip(VENUE_OHLC)
477        {
478            let mut server = mockito::Server::new_async().await;
479            for path in [trades_path, ohlc_path] {
480                server
481                    .mock("GET", path)
482                    .match_query(mockito::Matcher::Any)
483                    .with_status(500)
484                    .create_async()
485                    .await;
486            }
487            let exchange = scope::market::testing::venue_at(venue, &server.url());
488            let trades = TradesRequest {
489                venue: venue.to_string(),
490                pair: "BTC".to_string(),
491                limit: 3,
492            };
493            let status = trades_response(&exchange, trades).await.status();
494            assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR, "{venue} trades");
495            let ohlc = OhlcRequest {
496                venue: venue.to_string(),
497                pair: "BTC".to_string(),
498                interval: "1h".to_string(),
499                limit: 3,
500            };
501            let status = ohlc_response(&exchange, ohlc).await.status();
502            assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR, "{venue} ohlc");
503        }
504    }
505
506    #[tokio::test]
507    async fn test_handle_trades_error_response_structure() {
508        let req = TradesRequest {
509            venue: "nonexistent_venue_xyz".to_string(),
510            pair: "BTC".to_string(),
511            limit: 50,
512        };
513        let response = handle_trades(test_state(), Json(req)).await.into_response();
514        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
515        let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
516            .await
517            .unwrap();
518        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
519        assert!(json.get("error").is_some());
520    }
521
522    #[tokio::test]
523    async fn test_handle_ohlc_error_response_structure() {
524        let req = OhlcRequest {
525            venue: "nonexistent_venue_xyz".to_string(),
526            pair: "BTC".to_string(),
527            interval: "1h".to_string(),
528            limit: 100,
529        };
530        let response = handle_ohlc(test_state(), Json(req)).await.into_response();
531        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
532        let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
533            .await
534            .unwrap();
535        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
536        assert!(json.get("error").is_some());
537    }
538
539    // =================================================================
540    // Replies from a mock venue (#39: no live API in tests)
541    // =================================================================
542
543    async fn body_json(response: Response) -> serde_json::Value {
544        let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
545            .await
546            .unwrap();
547        serde_json::from_slice(&body).unwrap()
548    }
549
550    fn symbol_btcusdt() -> mockito::Matcher {
551        mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into())
552    }
553
554    #[tokio::test]
555    async fn test_snapshot_reply_has_book_ticker_and_the_asked_trades() {
556        let mut server = mockito::Server::new_async().await;
557        server
558            .mock("GET", "/api/v3/depth")
559            .match_query(symbol_btcusdt())
560            .with_body(r#"{"bids":[["0.999","100"]],"asks":[["1.001","200"]]}"#)
561            .create_async()
562            .await;
563        server
564            .mock("GET", "/api/v3/ticker/24hr")
565            .match_query(symbol_btcusdt())
566            .with_body(r#"{"lastPrice":"1.0","highPrice":"1.1","lowPrice":"0.9","volume":"10","quoteVolume":"10","priceChangePercent":"0.5","bidPrice":"0.999","askPrice":"1.001"}"#)
567            .create_async()
568            .await;
569        // The request's trades_limit reaches the venue (it was ignored).
570        let trades = server
571            .mock("GET", "/api/v3/trades")
572            .match_query(mockito::Matcher::AllOf(vec![
573                symbol_btcusdt(),
574                mockito::Matcher::UrlEncoded("limit".into(), "7".into()),
575            ]))
576            .with_body(r#"[{"id":9,"price":"1.0","qty":"3","quoteQty":"3","time":1700000000000,"isBuyerMaker":false}]"#)
577            .create_async()
578            .await;
579        let exchange = scope::market::testing::venue_at("binance", &server.url());
580        let req = SnapshotRequest {
581            venue: "binance".to_string(),
582            pair: "BTC".to_string(),
583            trades_limit: 7,
584        };
585        let json = body_json(snapshot_response(&exchange, req).await).await;
586        trades.assert_async().await;
587        assert_eq!(json["venue"], "binance");
588        assert_eq!(json["pair"], "BTCUSDT");
589        assert!(json["order_book"].is_object(), "{json}");
590        assert_eq!(json["ticker"]["last_price"], 1.0);
591        assert_eq!(json["recent_trades"][0]["side"], "buy");
592        assert_eq!(json["recent_trades"][0]["id"], "9");
593    }
594
595    #[tokio::test]
596    async fn test_trades_reply_lists_the_trades() {
597        let mut server = mockito::Server::new_async().await;
598        server
599            .mock("GET", "/api/v3/trades")
600            .match_query(mockito::Matcher::Any)
601            .with_body(r#"[{"id":1,"price":"1.5","qty":"2","quoteQty":"3","time":1700000000000,"isBuyerMaker":true}]"#)
602            .create_async()
603            .await;
604        let exchange = scope::market::testing::venue_at("binance", &server.url());
605        let req = TradesRequest {
606            venue: "binance".to_string(),
607            pair: "BTC".to_string(),
608            limit: 5,
609        };
610        let response = trades_response(&exchange, req).await;
611        assert_eq!(response.status(), StatusCode::OK);
612        let json = body_json(response).await;
613        assert_eq!(json["trades"][0]["side"], "sell");
614        assert_eq!(json["trades"][0]["price"], 1.5);
615        assert_eq!(json["trades"][0]["quote_quantity"], 3.0);
616    }
617
618    #[tokio::test]
619    async fn test_ohlc_reply_lists_the_candles() {
620        let mut server = mockito::Server::new_async().await;
621        server
622            .mock("GET", "/api/v3/klines")
623            .match_query(mockito::Matcher::Any)
624            .with_body(r#"[[1700000000000,"1.0","1.5","0.5","1.25","1000.5",1700003599999]]"#)
625            .create_async()
626            .await;
627        let exchange = scope::market::testing::venue_at("binance", &server.url());
628        let req = OhlcRequest {
629            venue: "binance".to_string(),
630            pair: "BTC".to_string(),
631            interval: "1h".to_string(),
632            limit: 1,
633        };
634        let json = body_json(ohlc_response(&exchange, req).await).await;
635        assert_eq!(json["interval"], "1h");
636        assert_eq!(json["candles"][0]["close"], 1.25);
637        assert_eq!(json["candles"][0]["close_time"], 1_700_003_599_999u64);
638    }
639
640    // A venue failure is the venue's error status, not a 200 with nulls.
641    #[tokio::test]
642    async fn test_trades_reply_reports_a_venue_failure() {
643        let mut server = mockito::Server::new_async().await;
644        server
645            .mock("GET", "/api/v3/trades")
646            .match_query(mockito::Matcher::Any)
647            .with_status(500)
648            .create_async()
649            .await;
650        let exchange = scope::market::testing::venue_at("binance", &server.url());
651        let req = TradesRequest {
652            venue: "binance".to_string(),
653            pair: "BTC".to_string(),
654            limit: 5,
655        };
656        let response = trades_response(&exchange, req).await;
657        assert_ne!(response.status(), StatusCode::OK);
658        assert!(
659            body_json(response).await["error"]
660                .as_str()
661                .unwrap()
662                .contains("binance")
663        );
664    }
665}