1use super::{error_response, status_for};
7use axum::Json;
8use axum::response::{IntoResponse, Response};
9use scope::market::{ExchangeClient, Pair, VenueRegistry};
10use serde::Deserialize;
11
12#[derive(Debug, Deserialize)]
14pub struct SnapshotRequest {
15 pub venue: String,
17 #[serde(default = "default_pair")]
19 pub pair: String,
20 #[serde(default = "default_trades_limit")]
22 pub trades_limit: u32,
23}
24
25fn 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
39pub 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
47async 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#[derive(Debug, Deserialize)]
77pub struct TradesRequest {
78 pub venue: String,
80 #[serde(default = "default_pair")]
82 pub pair: String,
83 #[serde(default = "default_trades_limit")]
85 pub limit: u32,
86}
87
88pub 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
96async 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#[derive(Debug, Deserialize)]
116pub struct OhlcRequest {
117 pub venue: String,
119 #[serde(default = "default_pair")]
121 pub pair: String,
122 #[serde(default = "default_interval")]
124 pub interval: String,
125 #[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
138pub 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
146async 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 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 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 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 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 #[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}