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