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    #[tokio::test]
387    async fn test_handle_trades_multiple_venues() {
388        for venue in &["mexc", "okx", "bybit"] {
389            let req = TradesRequest {
390                venue: venue.to_string(),
391                pair: "BTC".to_string(),
392                limit: 3,
393            };
394            let response = handle_trades(test_state(), Json(req)).await.into_response();
395            let status = response.status();
396            assert!(
397                status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR,
398                "Venue {} trades returned unexpected status {}",
399                venue,
400                status
401            );
402        }
403    }
404
405    #[tokio::test]
406    async fn test_handle_ohlc_multiple_venues() {
407        for venue in &["mexc", "okx", "bybit"] {
408            let req = OhlcRequest {
409                venue: venue.to_string(),
410                pair: "BTC".to_string(),
411                interval: "1h".to_string(),
412                limit: 3,
413            };
414            let response = handle_ohlc(test_state(), Json(req)).await.into_response();
415            let status = response.status();
416            assert!(
417                status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR,
418                "Venue {} ohlc returned unexpected status {}",
419                venue,
420                status
421            );
422        }
423    }
424
425    #[tokio::test]
426    async fn test_handle_trades_error_response_structure() {
427        let req = TradesRequest {
428            venue: "nonexistent_venue_xyz".to_string(),
429            pair: "BTC".to_string(),
430            limit: 50,
431        };
432        let response = handle_trades(test_state(), Json(req)).await.into_response();
433        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
434        let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
435            .await
436            .unwrap();
437        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
438        assert!(json.get("error").is_some());
439    }
440
441    #[tokio::test]
442    async fn test_handle_ohlc_error_response_structure() {
443        let req = OhlcRequest {
444            venue: "nonexistent_venue_xyz".to_string(),
445            pair: "BTC".to_string(),
446            interval: "1h".to_string(),
447            limit: 100,
448        };
449        let response = handle_ohlc(test_state(), Json(req)).await.into_response();
450        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
451        let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
452            .await
453            .unwrap();
454        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
455        assert!(json.get("error").is_some());
456    }
457
458    // =================================================================
459    // Replies from a mock venue (#39: no live API in tests)
460    // =================================================================
461
462    async fn body_json(response: Response) -> serde_json::Value {
463        let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
464            .await
465            .unwrap();
466        serde_json::from_slice(&body).unwrap()
467    }
468
469    fn symbol_btcusdt() -> mockito::Matcher {
470        mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into())
471    }
472
473    #[tokio::test]
474    async fn test_snapshot_reply_has_book_ticker_and_the_asked_trades() {
475        let mut server = mockito::Server::new_async().await;
476        server
477            .mock("GET", "/api/v3/depth")
478            .match_query(symbol_btcusdt())
479            .with_body(r#"{"bids":[["0.999","100"]],"asks":[["1.001","200"]]}"#)
480            .create_async()
481            .await;
482        server
483            .mock("GET", "/api/v3/ticker/24hr")
484            .match_query(symbol_btcusdt())
485            .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"}"#)
486            .create_async()
487            .await;
488        // The request's trades_limit reaches the venue (it was ignored).
489        let trades = server
490            .mock("GET", "/api/v3/trades")
491            .match_query(mockito::Matcher::AllOf(vec![
492                symbol_btcusdt(),
493                mockito::Matcher::UrlEncoded("limit".into(), "7".into()),
494            ]))
495            .with_body(r#"[{"id":9,"price":"1.0","qty":"3","quoteQty":"3","time":1700000000000,"isBuyerMaker":false}]"#)
496            .create_async()
497            .await;
498        let exchange = scope::market::testing::venue_at("binance", &server.url());
499        let req = SnapshotRequest {
500            venue: "binance".to_string(),
501            pair: "BTC".to_string(),
502            trades_limit: 7,
503        };
504        let json = body_json(snapshot_response(&exchange, req).await).await;
505        trades.assert_async().await;
506        assert_eq!(json["venue"], "binance");
507        assert_eq!(json["pair"], "BTCUSDT");
508        assert!(json["order_book"].is_object(), "{json}");
509        assert_eq!(json["ticker"]["last_price"], 1.0);
510        assert_eq!(json["recent_trades"][0]["side"], "buy");
511        assert_eq!(json["recent_trades"][0]["id"], "9");
512    }
513
514    #[tokio::test]
515    async fn test_trades_reply_lists_the_trades() {
516        let mut server = mockito::Server::new_async().await;
517        server
518            .mock("GET", "/api/v3/trades")
519            .match_query(mockito::Matcher::Any)
520            .with_body(r#"[{"id":1,"price":"1.5","qty":"2","quoteQty":"3","time":1700000000000,"isBuyerMaker":true}]"#)
521            .create_async()
522            .await;
523        let exchange = scope::market::testing::venue_at("binance", &server.url());
524        let req = TradesRequest {
525            venue: "binance".to_string(),
526            pair: "BTC".to_string(),
527            limit: 5,
528        };
529        let response = trades_response(&exchange, req).await;
530        assert_eq!(response.status(), StatusCode::OK);
531        let json = body_json(response).await;
532        assert_eq!(json["trades"][0]["side"], "sell");
533        assert_eq!(json["trades"][0]["price"], 1.5);
534        assert_eq!(json["trades"][0]["quote_quantity"], 3.0);
535    }
536
537    #[tokio::test]
538    async fn test_ohlc_reply_lists_the_candles() {
539        let mut server = mockito::Server::new_async().await;
540        server
541            .mock("GET", "/api/v3/klines")
542            .match_query(mockito::Matcher::Any)
543            .with_body(r#"[[1700000000000,"1.0","1.5","0.5","1.25","1000.5",1700003599999]]"#)
544            .create_async()
545            .await;
546        let exchange = scope::market::testing::venue_at("binance", &server.url());
547        let req = OhlcRequest {
548            venue: "binance".to_string(),
549            pair: "BTC".to_string(),
550            interval: "1h".to_string(),
551            limit: 1,
552        };
553        let json = body_json(ohlc_response(&exchange, req).await).await;
554        assert_eq!(json["interval"], "1h");
555        assert_eq!(json["candles"][0]["close"], 1.25);
556        assert_eq!(json["candles"][0]["close_time"], 1_700_003_599_999u64);
557    }
558
559    // A venue failure is the venue's error status, not a 200 with nulls.
560    #[tokio::test]
561    async fn test_trades_reply_reports_a_venue_failure() {
562        let mut server = mockito::Server::new_async().await;
563        server
564            .mock("GET", "/api/v3/trades")
565            .match_query(mockito::Matcher::Any)
566            .with_status(500)
567            .create_async()
568            .await;
569        let exchange = scope::market::testing::venue_at("binance", &server.url());
570        let req = TradesRequest {
571            venue: "binance".to_string(),
572            pair: "BTC".to_string(),
573            limit: 5,
574        };
575        let response = trades_response(&exchange, req).await;
576        assert_ne!(response.status(), StatusCode::OK);
577        assert!(
578            body_json(response).await["error"]
579                .as_str()
580                .unwrap()
581                .contains("binance")
582        );
583    }
584}