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 crate::market::VenueRegistry;
7use axum::Json;
8use axum::http::StatusCode;
9use axum::response::IntoResponse;
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    /// Base token symbol (e.g., "BTC", "USDC").
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
25fn default_pair() -> String {
26    "BTC".to_string()
27}
28
29fn default_trades_limit() -> u32 {
30    50
31}
32
33/// POST /api/exchange/snapshot — Full market snapshot.
34pub async fn handle(Json(req): Json<SnapshotRequest>) -> impl IntoResponse {
35    let registry = match VenueRegistry::load() {
36        Ok(r) => r,
37        Err(e) => {
38            return (
39                StatusCode::INTERNAL_SERVER_ERROR,
40                Json(serde_json::json!({ "error": format!("Registry error: {e}") })),
41            )
42                .into_response();
43        }
44    };
45
46    let exchange = match registry.create_exchange_client(&req.venue) {
47        Ok(c) => c,
48        Err(e) => {
49            return (
50                StatusCode::BAD_REQUEST,
51                Json(serde_json::json!({ "error": e.to_string() })),
52            )
53                .into_response();
54        }
55    };
56
57    let pair = exchange.format_pair(&req.pair);
58    let snapshot = exchange.fetch_market_snapshot(&pair).await;
59
60    let order_book_json = snapshot.order_book.as_ref().map(|book| {
61        serde_json::json!({
62            "pair": book.pair,
63            "best_bid": book.best_bid(),
64            "best_ask": book.best_ask(),
65            "mid_price": book.mid_price(),
66            "spread": book.spread(),
67            "bid_depth": book.bid_depth(),
68            "ask_depth": book.ask_depth(),
69            "bids": book.bids.iter().map(|l| {
70                serde_json::json!({"price": l.price, "quantity": l.quantity, "value": l.value()})
71            }).collect::<Vec<_>>(),
72            "asks": book.asks.iter().map(|l| {
73                serde_json::json!({"price": l.price, "quantity": l.quantity, "value": l.value()})
74            }).collect::<Vec<_>>(),
75        })
76    });
77
78    let ticker_json = snapshot.ticker.as_ref().map(|t| {
79        serde_json::json!({
80            "pair": t.pair,
81            "last_price": t.last_price,
82            "high_24h": t.high_24h,
83            "low_24h": t.low_24h,
84            "volume_24h": t.volume_24h,
85            "quote_volume_24h": t.quote_volume_24h,
86            "best_bid": t.best_bid,
87            "best_ask": t.best_ask,
88        })
89    });
90
91    let trades_json = snapshot.recent_trades.as_ref().map(|trades| {
92        trades
93            .iter()
94            .map(|t| {
95                serde_json::json!({
96                    "price": t.price,
97                    "quantity": t.quantity,
98                    "quote_quantity": t.quote_quantity,
99                    "timestamp_ms": t.timestamp_ms,
100                    "side": match t.side {
101                        crate::market::TradeSide::Buy => "buy",
102                        crate::market::TradeSide::Sell => "sell",
103                    },
104                    "id": t.id,
105                })
106            })
107            .collect::<Vec<_>>()
108    });
109
110    let output = serde_json::json!({
111        "venue": req.venue,
112        "pair": pair,
113        "order_book": order_book_json,
114        "ticker": ticker_json,
115        "recent_trades": trades_json,
116    });
117
118    Json(output).into_response()
119}
120
121// =============================================================================
122// POST /api/exchange/trades
123// =============================================================================
124
125/// Request body for exchange trades.
126#[derive(Debug, Deserialize)]
127pub struct TradesRequest {
128    /// Venue ID (e.g., "binance", "mexc").
129    pub venue: String,
130    /// Base token symbol (e.g., "BTC", "USDC").
131    #[serde(default = "default_pair")]
132    pub pair: String,
133    /// Maximum number of trades to return.
134    #[serde(default = "default_trades_limit")]
135    pub limit: u32,
136}
137
138/// POST /api/exchange/trades — Recent trades for a venue/pair.
139pub async fn handle_trades(Json(req): Json<TradesRequest>) -> impl IntoResponse {
140    let registry = match VenueRegistry::load() {
141        Ok(r) => r,
142        Err(e) => {
143            return (
144                StatusCode::INTERNAL_SERVER_ERROR,
145                Json(serde_json::json!({ "error": format!("Registry error: {e}") })),
146            )
147                .into_response();
148        }
149    };
150
151    let exchange = match registry.create_exchange_client(&req.venue) {
152        Ok(c) => c,
153        Err(e) => {
154            return (
155                StatusCode::BAD_REQUEST,
156                Json(serde_json::json!({ "error": e.to_string() })),
157            )
158                .into_response();
159        }
160    };
161
162    let pair = exchange.format_pair(&req.pair);
163    match exchange.fetch_recent_trades(&pair, req.limit).await {
164        Ok(trades) => {
165            let json_trades: Vec<serde_json::Value> = trades
166                .iter()
167                .map(|t| {
168                    serde_json::json!({
169                        "price": t.price,
170                        "quantity": t.quantity,
171                        "quote_quantity": t.quote_quantity,
172                        "timestamp_ms": t.timestamp_ms,
173                        "side": match t.side {
174                            crate::market::TradeSide::Buy => "buy",
175                            crate::market::TradeSide::Sell => "sell",
176                        },
177                        "id": t.id,
178                    })
179                })
180                .collect();
181            Json(serde_json::json!({
182                "venue": req.venue,
183                "pair": pair,
184                "trades": json_trades,
185            }))
186            .into_response()
187        }
188        Err(e) => (
189            StatusCode::INTERNAL_SERVER_ERROR,
190            Json(serde_json::json!({ "error": e.to_string() })),
191        )
192            .into_response(),
193    }
194}
195
196// =============================================================================
197// POST /api/exchange/ohlc
198// =============================================================================
199
200/// Request body for exchange OHLC.
201#[derive(Debug, Deserialize)]
202pub struct OhlcRequest {
203    /// Venue ID (e.g., "binance", "mexc").
204    pub venue: String,
205    /// Base token symbol (e.g., "BTC", "USDC").
206    #[serde(default = "default_pair")]
207    pub pair: String,
208    /// Candle interval (e.g., "1m", "1h", "1d").
209    #[serde(default = "default_interval")]
210    pub interval: String,
211    /// Maximum number of candles to return.
212    #[serde(default = "default_ohlc_limit")]
213    pub limit: u32,
214}
215
216fn default_interval() -> String {
217    "1h".to_string()
218}
219
220fn default_ohlc_limit() -> u32 {
221    100
222}
223
224/// POST /api/exchange/ohlc — OHLC candlestick data for a venue/pair.
225pub async fn handle_ohlc(Json(req): Json<OhlcRequest>) -> impl IntoResponse {
226    let registry = match VenueRegistry::load() {
227        Ok(r) => r,
228        Err(e) => {
229            return (
230                StatusCode::INTERNAL_SERVER_ERROR,
231                Json(serde_json::json!({ "error": format!("Registry error: {e}") })),
232            )
233                .into_response();
234        }
235    };
236
237    let exchange = match registry.create_exchange_client(&req.venue) {
238        Ok(c) => c,
239        Err(e) => {
240            return (
241                StatusCode::BAD_REQUEST,
242                Json(serde_json::json!({ "error": e.to_string() })),
243            )
244                .into_response();
245        }
246    };
247
248    let pair = exchange.format_pair(&req.pair);
249    match exchange.fetch_ohlc(&pair, &req.interval, req.limit).await {
250        Ok(candles) => {
251            let json_candles: Vec<serde_json::Value> = candles
252                .iter()
253                .map(|c| {
254                    serde_json::json!({
255                        "open_time": c.open_time,
256                        "open": c.open,
257                        "high": c.high,
258                        "low": c.low,
259                        "close": c.close,
260                        "volume": c.volume,
261                        "close_time": c.close_time,
262                    })
263                })
264                .collect();
265            Json(serde_json::json!({
266                "venue": req.venue,
267                "pair": pair,
268                "interval": req.interval,
269                "candles": json_candles,
270            }))
271            .into_response()
272        }
273        Err(e) => (
274            StatusCode::INTERNAL_SERVER_ERROR,
275            Json(serde_json::json!({ "error": e.to_string() })),
276        )
277            .into_response(),
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use axum::http::StatusCode;
285
286    #[test]
287    fn test_snapshot_request_empty_pair() {
288        let json = serde_json::json!({"venue": "binance", "pair": ""});
289        let req: SnapshotRequest = serde_json::from_value(json).unwrap();
290        assert_eq!(req.pair, "");
291    }
292
293    #[test]
294    fn test_snapshot_request_large_limit() {
295        let json = serde_json::json!({"venue": "x", "trades_limit": 1000});
296        let req: SnapshotRequest = serde_json::from_value(json).unwrap();
297        assert_eq!(req.trades_limit, 1000);
298    }
299
300    #[test]
301    fn test_deserialize_full() {
302        let json = serde_json::json!({
303            "venue": "binance",
304            "pair": "USDC",
305            "trades_limit": 20
306        });
307        let req: SnapshotRequest = serde_json::from_value(json).unwrap();
308        assert_eq!(req.venue, "binance");
309        assert_eq!(req.pair, "USDC");
310        assert_eq!(req.trades_limit, 20);
311    }
312
313    #[test]
314    fn test_deserialize_minimal() {
315        let json = serde_json::json!({
316            "venue": "mexc"
317        });
318        let req: SnapshotRequest = serde_json::from_value(json).unwrap();
319        assert_eq!(req.venue, "mexc");
320        assert_eq!(req.pair, "BTC");
321        assert_eq!(req.trades_limit, 50);
322    }
323
324    #[test]
325    fn test_defaults() {
326        assert_eq!(default_pair(), "BTC");
327        assert_eq!(default_trades_limit(), 50);
328    }
329
330    #[tokio::test]
331    async fn test_handle_unknown_venue() {
332        let req = SnapshotRequest {
333            venue: "nonexistent_venue_xyz".to_string(),
334            pair: "BTC".to_string(),
335            trades_limit: 50,
336        };
337        let response = handle(Json(req)).await.into_response();
338        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
339    }
340
341    #[test]
342    fn test_snapshot_request_deserialization_with_defaults() {
343        // Only venue provided, pair and trades_limit should use defaults
344        let json = serde_json::json!({"venue": "kraken"});
345        let req: SnapshotRequest = serde_json::from_value(json).unwrap();
346        assert_eq!(req.venue, "kraken");
347        assert_eq!(req.pair, "BTC");
348        assert_eq!(req.trades_limit, 50);
349    }
350
351    #[test]
352    fn test_trades_request_deserialization() {
353        let json = serde_json::json!({
354            "venue": "binance",
355            "pair": "USDC",
356            "limit": 25
357        });
358        let req: TradesRequest = serde_json::from_value(json).unwrap();
359        assert_eq!(req.venue, "binance");
360        assert_eq!(req.pair, "USDC");
361        assert_eq!(req.limit, 25);
362    }
363
364    #[test]
365    fn test_trades_request_defaults() {
366        let json = serde_json::json!({"venue": "kraken"});
367        let req: TradesRequest = serde_json::from_value(json).unwrap();
368        assert_eq!(req.venue, "kraken");
369        assert_eq!(req.pair, "BTC");
370        assert_eq!(req.limit, 50);
371    }
372
373    #[test]
374    fn test_ohlc_request_deserialization() {
375        let json = serde_json::json!({
376            "venue": "binance",
377            "pair": "ETH",
378            "interval": "4h",
379            "limit": 200
380        });
381        let req: OhlcRequest = serde_json::from_value(json).unwrap();
382        assert_eq!(req.venue, "binance");
383        assert_eq!(req.pair, "ETH");
384        assert_eq!(req.interval, "4h");
385        assert_eq!(req.limit, 200);
386    }
387
388    #[test]
389    fn test_ohlc_request_defaults() {
390        let json = serde_json::json!({"venue": "mexc"});
391        let req: OhlcRequest = serde_json::from_value(json).unwrap();
392        assert_eq!(req.venue, "mexc");
393        assert_eq!(req.pair, "BTC");
394        assert_eq!(req.interval, "1h");
395        assert_eq!(req.limit, 100);
396    }
397
398    #[test]
399    fn test_snapshot_request_debug() {
400        let req = SnapshotRequest {
401            venue: "test".to_string(),
402            pair: "ETH".to_string(),
403            trades_limit: 100,
404        };
405        let debug = format!("{:?}", req);
406        assert!(debug.contains("SnapshotRequest"));
407    }
408
409    #[tokio::test]
410    async fn test_handle_valid_venue_graceful_failure() {
411        // Uses a real venue (binance) — VenueRegistry loads built-in descriptors.
412        // The actual API call will likely fail in CI (no network / timeouts),
413        // but the handler catches errors gracefully and still returns 200 with null fields.
414        let req = SnapshotRequest {
415            venue: "binance".to_string(),
416            pair: "BTC".to_string(),
417            trades_limit: 5,
418        };
419        let response = handle(Json(req)).await.into_response();
420        // Should succeed even if exchange API is unreachable
421        let status = response.status();
422        assert!(
423            status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR,
424            "Expected 200 or 500, got {}",
425            status
426        );
427
428        if status == StatusCode::OK {
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_eq!(json["venue"], "binance");
434            assert!(json["pair"].is_string());
435            // order_book/ticker/trades may be null (API failed) or populated (API succeeded)
436        }
437    }
438
439    #[tokio::test]
440    async fn test_handle_multiple_venues() {
441        // Test with several built-in venues to exercise the handler broadly
442        for venue in &["mexc", "okx", "bybit", "coinbase"] {
443            let req = SnapshotRequest {
444                venue: venue.to_string(),
445                pair: "ETH".to_string(),
446                trades_limit: 5,
447            };
448            let response = handle(Json(req)).await.into_response();
449            let status = response.status();
450            assert!(
451                status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR,
452                "Venue {} returned unexpected status {}",
453                venue,
454                status
455            );
456        }
457    }
458
459    // =================================================================
460    // Trades endpoint tests
461    // =================================================================
462
463    #[tokio::test]
464    async fn test_handle_trades_unknown_venue() {
465        let req = TradesRequest {
466            venue: "nonexistent_venue_xyz".to_string(),
467            pair: "BTC".to_string(),
468            limit: 50,
469        };
470        let response = handle_trades(Json(req)).await.into_response();
471        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
472        let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
473            .await
474            .unwrap();
475        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
476        assert!(json["error"].as_str().unwrap().contains("Unknown venue"));
477    }
478
479    #[tokio::test]
480    async fn test_handle_trades_valid_venue() {
481        let req = TradesRequest {
482            venue: "binance".to_string(),
483            pair: "BTC".to_string(),
484            limit: 5,
485        };
486        let response = handle_trades(Json(req)).await.into_response();
487        let status = response.status();
488        // May succeed (200 with trades) or fail gracefully (500 due to network)
489        assert!(
490            status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR,
491            "Expected 200 or 500, got {}",
492            status
493        );
494    }
495
496    // =================================================================
497    // OHLC endpoint tests
498    // =================================================================
499
500    #[tokio::test]
501    async fn test_handle_ohlc_unknown_venue() {
502        let req = OhlcRequest {
503            venue: "nonexistent_venue_xyz".to_string(),
504            pair: "BTC".to_string(),
505            interval: "1h".to_string(),
506            limit: 100,
507        };
508        let response = handle_ohlc(Json(req)).await.into_response();
509        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
510        let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
511            .await
512            .unwrap();
513        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
514        assert!(json["error"].as_str().unwrap().contains("Unknown venue"));
515    }
516
517    #[tokio::test]
518    async fn test_handle_ohlc_valid_venue() {
519        let req = OhlcRequest {
520            venue: "binance".to_string(),
521            pair: "BTC".to_string(),
522            interval: "1h".to_string(),
523            limit: 5,
524        };
525        let response = handle_ohlc(Json(req)).await.into_response();
526        let status = response.status();
527        assert!(
528            status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR,
529            "Expected 200 or 500, got {}",
530            status
531        );
532    }
533
534    #[test]
535    fn test_trades_request_debug() {
536        let req = TradesRequest {
537            venue: "test".to_string(),
538            pair: "ETH".to_string(),
539            limit: 10,
540        };
541        let debug = format!("{:?}", req);
542        assert!(debug.contains("TradesRequest"));
543    }
544
545    #[test]
546    fn test_ohlc_request_debug() {
547        let req = OhlcRequest {
548            venue: "test".to_string(),
549            pair: "ETH".to_string(),
550            interval: "4h".to_string(),
551            limit: 50,
552        };
553        let debug = format!("{:?}", req);
554        assert!(debug.contains("OhlcRequest"));
555    }
556
557    #[tokio::test]
558    async fn test_handle_trades_multiple_venues() {
559        for venue in &["mexc", "okx", "bybit"] {
560            let req = TradesRequest {
561                venue: venue.to_string(),
562                pair: "BTC".to_string(),
563                limit: 3,
564            };
565            let response = handle_trades(Json(req)).await.into_response();
566            let status = response.status();
567            assert!(
568                status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR,
569                "Venue {} trades returned unexpected status {}",
570                venue,
571                status
572            );
573        }
574    }
575
576    #[tokio::test]
577    async fn test_handle_ohlc_multiple_venues() {
578        for venue in &["mexc", "okx", "bybit"] {
579            let req = OhlcRequest {
580                venue: venue.to_string(),
581                pair: "BTC".to_string(),
582                interval: "1h".to_string(),
583                limit: 3,
584            };
585            let response = handle_ohlc(Json(req)).await.into_response();
586            let status = response.status();
587            assert!(
588                status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR,
589                "Venue {} ohlc returned unexpected status {}",
590                venue,
591                status
592            );
593        }
594    }
595}