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 #[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 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 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 #[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}