1use 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#[derive(Debug, Deserialize)]
17pub struct SnapshotRequest {
18 pub venue: String,
20 #[serde(default = "default_pair")]
22 pub pair: String,
23 #[serde(default = "default_trades_limit")]
25 pub trades_limit: u32,
26}
27
28fn 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
43pub 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
54async 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#[derive(Debug, Deserialize)]
84pub struct TradesRequest {
85 pub venue: String,
87 #[serde(default = "default_pair")]
89 pub pair: String,
90 #[serde(default = "default_trades_limit")]
92 pub limit: u32,
93}
94
95pub 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
106async 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#[derive(Debug, Deserialize)]
126pub struct OhlcRequest {
127 pub venue: String,
129 #[serde(default = "default_pair")]
131 pub pair: String,
132 #[serde(default = "default_interval")]
134 pub interval: String,
135 #[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
148pub 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
159async 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 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 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 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 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 #[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 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 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 #[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}