1use crate::AppState;
4use axum::Json;
5use axum::extract::State;
6use axum::http::StatusCode;
7use axum::response::IntoResponse;
8use scope::market::{HealthOverrides, MarketSummary, VenueRegistry, order_book_from_analytics};
9use scope_cli::cli::crawl::{self, Period};
10use serde::Deserialize;
11use std::sync::Arc;
12
13#[derive(Debug, Deserialize)]
15pub struct MarketRequest {
16 #[serde(default = "default_pair")]
18 pub pair: String,
19 #[serde(default = "default_venue")]
21 pub market_venue: String,
22 #[serde(default = "default_chain")]
24 pub chain: String,
25 #[serde(flatten)]
28 pub health: HealthOverrides,
29}
30
31fn default_pair() -> String {
32 "USDC".to_string()
33}
34fn default_venue() -> String {
35 "binance".to_string()
36}
37fn default_chain() -> String {
38 "ethereum".to_string()
39}
40
41fn summary_to_json(summary: &MarketSummary) -> serde_json::Value {
43 let exec_buy = summary.execution_10k_buy.as_ref().map(|e| {
44 serde_json::json!({
45 "notional_usdt": e.notional_usdt,
46 "vwap": e.vwap,
47 "slippage_bps": e.slippage_bps,
48 "fillable": e.fillable,
49 })
50 });
51 let exec_sell = summary.execution_10k_sell.as_ref().map(|e| {
52 serde_json::json!({
53 "notional_usdt": e.notional_usdt,
54 "vwap": e.vwap,
55 "slippage_bps": e.slippage_bps,
56 "fillable": e.fillable,
57 })
58 });
59
60 serde_json::json!({
61 "pair": summary.pair,
62 "peg_target": summary.peg_target,
63 "best_bid": summary.best_bid,
64 "best_ask": summary.best_ask,
65 "mid_price": summary.mid_price,
66 "spread": summary.spread,
67 "volume_24h": summary.volume_24h,
68 "bid_depth": summary.bid_depth,
69 "ask_depth": summary.ask_depth,
70 "bid_outliers": summary.bid_outliers,
71 "ask_outliers": summary.ask_outliers,
72 "healthy": summary.healthy,
73 "execution_10k_buy": exec_buy,
74 "execution_10k_sell": exec_sell,
75 "bids": summary.bids.iter().take(20).map(|l| {
76 serde_json::json!({"price": l.price, "quantity": l.quantity, "value": l.value()})
77 }).collect::<Vec<_>>(),
78 "asks": summary.asks.iter().take(20).map(|l| {
79 serde_json::json!({"price": l.price, "quantity": l.quantity, "value": l.value()})
80 }).collect::<Vec<_>>(),
81 "checks": summary.checks.iter().map(|c| match c {
82 scope::market::HealthCheck::Pass(msg) => serde_json::json!({"status": "pass", "message": msg}),
83 scope::market::HealthCheck::Fail(msg) => serde_json::json!({"status": "fail", "message": msg}),
84 }).collect::<Vec<_>>(),
85 })
86}
87
88pub async fn handle(
90 State(state): State<Arc<AppState>>,
91 Json(req): Json<MarketRequest>,
92) -> impl IntoResponse {
93 let venue_id = &req.market_venue;
94
95 let thresholds = state.config.market.health.with_overrides(&req.health);
96
97 if !is_dex_venue(venue_id) {
98 let registry = match VenueRegistry::load() {
100 Ok(r) => r,
101 Err(e) => {
102 return (
103 StatusCode::INTERNAL_SERVER_ERROR,
104 Json(serde_json::json!({ "error": format!("Registry error: {e}") })),
105 )
106 .into_response();
107 }
108 };
109 let exchange = match registry.create_exchange_client(venue_id) {
110 Ok(c) => c,
111 Err(e) => {
112 return (
113 StatusCode::BAD_REQUEST,
114 Json(serde_json::json!({ "error": e.to_string() })),
115 )
116 .into_response();
117 }
118 };
119
120 let pair = exchange.format_pair(&req.pair);
121 match exchange.fetch_order_book(&pair).await {
122 Ok(book) => {
123 let volume_24h = if exchange.has_ticker() {
124 exchange
125 .fetch_ticker(&pair)
126 .await
127 .ok()
128 .and_then(|t| t.quote_volume_24h.or(t.volume_24h))
129 } else {
130 None
131 };
132 let summary = MarketSummary::from_order_book(&book, &thresholds, volume_24h);
133 Json(summary_to_json(&summary)).into_response()
134 }
135 Err(e) => (
136 StatusCode::INTERNAL_SERVER_ERROR,
137 Json(serde_json::json!({ "error": e.to_string() })),
138 )
139 .into_response(),
140 }
141 } else {
142 let venue_chain = dex_venue_to_chain(venue_id);
144
145 match crawl::fetch_analytics_for_input(
146 &req.pair,
147 venue_chain,
148 Period::Hour24,
149 10,
150 &state.factory,
151 None,
152 )
153 .await
154 {
155 Ok(analytics) => {
156 if analytics.dex_pairs.is_empty() {
157 return (
158 StatusCode::NOT_FOUND,
159 Json(serde_json::json!({ "error": "No DEX pairs found" })),
160 )
161 .into_response();
162 }
163 let best_pair = analytics
164 .dex_pairs
165 .iter()
166 .max_by(|a, b| {
167 a.liquidity_usd
168 .partial_cmp(&b.liquidity_usd)
169 .unwrap_or(std::cmp::Ordering::Equal)
170 })
171 .unwrap();
172 let book =
173 order_book_from_analytics(venue_chain, best_pair, &analytics.token.symbol);
174 let summary =
175 MarketSummary::from_order_book(&book, &thresholds, Some(best_pair.volume_24h));
176 Json(summary_to_json(&summary)).into_response()
177 }
178 Err(e) => (
179 StatusCode::INTERNAL_SERVER_ERROR,
180 Json(serde_json::json!({ "error": e.to_string() })),
181 )
182 .into_response(),
183 }
184 }
185}
186
187fn is_dex_venue(venue: &str) -> bool {
189 matches!(venue.to_lowercase().as_str(), "ethereum" | "eth" | "solana")
190}
191
192fn dex_venue_to_chain(venue: &str) -> &str {
194 match venue.to_lowercase().as_str() {
195 "ethereum" | "eth" => "ethereum",
196 "solana" => "solana",
197 _ => "ethereum",
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 #[test]
206 fn test_deserialize_full() {
207 let json = serde_json::json!({
208 "pair": "DAI",
209 "market_venue": "binance",
210 "chain": "polygon",
211 "peg": 1.0,
212 "min_levels": 10,
213 "min_depth": 5000.0,
214 "peg_range": 0.002
215 });
216 let req: MarketRequest = serde_json::from_value(json).unwrap();
217 assert_eq!(req.pair, "DAI");
218 assert_eq!(req.market_venue, "binance");
219 assert_eq!(req.chain, "polygon");
220 assert_eq!(req.health.peg_target, Some(1.0));
221 assert_eq!(req.health.min_levels, Some(10));
222 assert_eq!(req.health.min_depth, Some(5000.0));
223 assert_eq!(req.health.peg_range, Some(0.002));
224 }
225
226 #[test]
227 fn test_deserialize_minimal() {
228 let json = serde_json::json!({});
229 let req: MarketRequest = serde_json::from_value(json).unwrap();
230 assert_eq!(req.pair, "USDC");
231 assert_eq!(req.market_venue, "binance");
232 assert_eq!(req.chain, "ethereum");
233 assert_eq!(req.health, HealthOverrides::default());
235 }
236
237 #[test]
238 fn test_all_defaults() {
239 assert_eq!(default_pair(), "USDC");
240 assert_eq!(default_venue(), "binance");
241 assert_eq!(default_chain(), "ethereum");
242 }
243
244 #[test]
245 fn test_new_thresholds_accepted() {
246 let json = serde_json::json!({
247 "max_spread_pct": 1.5,
248 "min_top3_depth": 500.0,
249 "min_top10_depth": 4000.0,
250 "min_bid_ask_ratio": 0.5,
251 "max_bid_ask_ratio": 2.0
252 });
253 let req: MarketRequest = serde_json::from_value(json).unwrap();
254 assert_eq!(req.health.max_spread_pct, Some(1.5));
255 assert_eq!(req.health.min_top3_depth, Some(500.0));
256 assert_eq!(req.health.min_top10_depth, Some(4000.0));
257 assert_eq!(req.health.min_bid_ask_ratio, Some(0.5));
258 assert_eq!(req.health.max_bid_ask_ratio, Some(2.0));
259 }
260
261 #[test]
262 fn test_request_overrides_config() {
263 let mut config = scope::config::Config::default();
264 config.market.health.min_levels = 20;
265 config.market.health.max_spread_pct = 5.0;
266 let req: MarketRequest =
267 serde_json::from_value(serde_json::json!({ "max_spread_pct": 1.0 })).unwrap();
268 let t = config.market.health.with_overrides(&req.health);
269 assert_eq!(t.max_spread_pct, 1.0);
270 assert_eq!(t.min_levels, 20);
271 }
272
273 #[test]
274 fn test_custom_thresholds() {
275 let json = serde_json::json!({
276 "min_levels": 20,
277 "min_depth": 10000.0,
278 "peg_range": 0.005
279 });
280 let req: MarketRequest = serde_json::from_value(json).unwrap();
281 assert_eq!(req.health.min_levels, Some(20));
282 assert_eq!(req.health.min_depth, Some(10000.0));
283 assert_eq!(req.health.peg_range, Some(0.005));
284 assert_eq!(req.pair, "USDC");
286 assert_eq!(req.market_venue, "binance");
287 assert_eq!(req.chain, "ethereum");
288 assert_eq!(req.health.peg_target, None);
289 }
290
291 #[tokio::test]
292 async fn test_handle_market_cex() {
293 use crate::AppState;
294 use axum::extract::State;
295 use axum::response::IntoResponse;
296 use scope::chains::DefaultClientFactory;
297 use scope::config::Config;
298
299 let config = Config::default();
300 let http: std::sync::Arc<dyn scope::http::HttpClient> =
301 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
302 let factory = DefaultClientFactory {
303 chains_config: config.chains.clone(),
304 http,
305 };
306 let state = std::sync::Arc::new(AppState { config, factory });
307 let req = MarketRequest {
308 pair: "USDC".to_string(),
309 market_venue: "binance".to_string(),
310 chain: "ethereum".to_string(),
311 health: HealthOverrides::default(),
312 };
313 let response = handle(State(state), axum::Json(req)).await.into_response();
314 let status = response.status();
315 assert!(status.is_success() || status.is_client_error() || status.is_server_error());
316 }
317
318 #[tokio::test]
319 async fn test_handle_market_dex() {
320 use crate::AppState;
321 use axum::extract::State;
322 use axum::response::IntoResponse;
323 use scope::chains::DefaultClientFactory;
324 use scope::config::Config;
325
326 let config = Config::default();
327 let http: std::sync::Arc<dyn scope::http::HttpClient> =
328 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
329 let factory = DefaultClientFactory {
330 chains_config: config.chains.clone(),
331 http,
332 };
333 let state = std::sync::Arc::new(AppState { config, factory });
334 let req = MarketRequest {
335 pair: "USDC".to_string(),
336 market_venue: "eth".to_string(),
337 chain: "ethereum".to_string(),
338 health: HealthOverrides::default(),
339 };
340 let response = handle(State(state), axum::Json(req)).await.into_response();
341 let status = response.status();
342 assert!(status.is_success() || status.is_client_error() || status.is_server_error());
343 }
344
345 #[tokio::test]
346 async fn test_handle_market_with_cex_venue() {
347 use crate::AppState;
348 use axum::extract::State;
349 use axum::response::IntoResponse;
350 use scope::chains::DefaultClientFactory;
351 use scope::config::Config;
352
353 let config = Config::default();
354 let http: std::sync::Arc<dyn scope::http::HttpClient> =
355 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
356 let factory = DefaultClientFactory {
357 chains_config: config.chains.clone(),
358 http,
359 };
360 let state = std::sync::Arc::new(AppState { config, factory });
361 let req = MarketRequest {
362 pair: "BTC".to_string(),
363 market_venue: "binance".to_string(),
364 chain: "ethereum".to_string(),
365 health: HealthOverrides::default(),
366 };
367 let response = handle(State(state), axum::Json(req)).await.into_response();
368 let status = response.status();
369 assert!(
370 status.is_success() || status.is_server_error(),
371 "Unexpected status: {}",
372 status
373 );
374 }
375
376 #[test]
377 fn test_is_dex_venue() {
378 assert!(is_dex_venue("eth"));
379 assert!(is_dex_venue("ethereum"));
380 assert!(is_dex_venue("solana"));
381 assert!(!is_dex_venue("binance"));
382 assert!(!is_dex_venue("mexc"));
383 }
384
385 #[test]
386 fn test_dex_venue_to_chain() {
387 assert_eq!(dex_venue_to_chain("eth"), "ethereum");
388 assert_eq!(dex_venue_to_chain("ethereum"), "ethereum");
389 assert_eq!(dex_venue_to_chain("solana"), "solana");
390 assert_eq!(dex_venue_to_chain("unknown"), "ethereum");
391 }
392
393 #[test]
394 fn test_market_request_debug() {
395 let req = MarketRequest {
396 pair: "USDC".to_string(),
397 market_venue: "binance".to_string(),
398 chain: "ethereum".to_string(),
399 health: HealthOverrides::default(),
400 };
401 let debug = format!("{:?}", req);
402 assert!(debug.contains("MarketRequest"));
403 }
404
405 #[tokio::test]
406 async fn test_handle_market_invalid_venue_bad_request() {
407 use crate::AppState;
408 use axum::extract::State;
409 use axum::response::IntoResponse;
410 use scope::chains::DefaultClientFactory;
411 use scope::config::Config;
412
413 let config = Config::default();
414 let http: std::sync::Arc<dyn scope::http::HttpClient> =
415 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
416 let factory = DefaultClientFactory {
417 chains_config: config.chains.clone(),
418 http,
419 };
420 let state = std::sync::Arc::new(AppState { config, factory });
421 let req = MarketRequest {
422 pair: "USDC".to_string(),
423 market_venue: "nonexistent_venue_xyz".to_string(),
424 chain: "ethereum".to_string(),
425 health: HealthOverrides::default(),
426 };
427 let response = handle(State(state), axum::Json(req)).await.into_response();
428 let status = response.status();
429 assert_eq!(status, axum::http::StatusCode::BAD_REQUEST);
431 }
432
433 #[tokio::test]
434 async fn test_handle_market_success_json_structure() {
435 use crate::AppState;
436 use axum::body;
437 use axum::extract::State;
438 use axum::response::IntoResponse;
439 use scope::chains::DefaultClientFactory;
440 use scope::config::Config;
441
442 let config = Config::default();
443 let http: std::sync::Arc<dyn scope::http::HttpClient> =
444 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
445 let factory = DefaultClientFactory {
446 chains_config: config.chains.clone(),
447 http,
448 };
449 let state = std::sync::Arc::new(AppState { config, factory });
450 let req = MarketRequest {
451 pair: "USDC".to_string(),
452 market_venue: "binance".to_string(),
453 chain: "ethereum".to_string(),
454 health: HealthOverrides::default(),
455 };
456 let response = handle(State(state), axum::Json(req)).await.into_response();
457 if response.status().is_success() {
458 let body_bytes = body::to_bytes(response.into_body(), 1_000_000)
459 .await
460 .unwrap();
461 let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
462 assert!(json.get("pair").is_some());
463 assert!(json.get("peg_target").is_some());
464 assert!(json.get("best_bid").is_some());
465 assert!(json.get("best_ask").is_some());
466 assert!(json.get("healthy").is_some());
467 assert!(json.get("checks").is_some());
468 }
469 }
470
471 #[test]
472 fn test_is_dex_venue_case_insensitive() {
473 assert!(is_dex_venue("ETHEREUM"));
474 assert!(is_dex_venue("SOLANA"));
475 assert!(!is_dex_venue("BINANCE"));
476 }
477
478 #[test]
479 fn test_summary_to_json_with_execution_estimates() {
480 use scope::market::OrderBookLevel;
481
482 let book = scope::market::OrderBook {
483 pair: "USDC/USDT".to_string(),
484 bids: vec![
485 OrderBookLevel {
486 price: 0.9999,
487 quantity: 20_000.0,
488 },
489 OrderBookLevel {
490 price: 0.9998,
491 quantity: 10_000.0,
492 },
493 ],
494 asks: vec![
495 OrderBookLevel {
496 price: 1.0001,
497 quantity: 20_000.0,
498 },
499 OrderBookLevel {
500 price: 1.0002,
501 quantity: 10_000.0,
502 },
503 ],
504 };
505 let thresholds = scope::market::HealthThresholds::default();
506 let summary =
507 scope::market::MarketSummary::from_order_book(&book, &thresholds, Some(50_000.0));
508 let json = summary_to_json(&summary);
509
510 assert_eq!(json["pair"], "USDC/USDT");
511 assert!(json["best_bid"].as_f64().unwrap() > 0.0);
512 assert!(json["best_ask"].as_f64().unwrap() > 0.0);
513 assert!(json.get("healthy").is_some());
514 assert!(!json["checks"].as_array().unwrap().is_empty());
515 assert!(json.get("execution_10k_buy").is_some());
516 assert!(json.get("execution_10k_sell").is_some());
517 assert!(json.get("bids").is_some());
518 assert!(json.get("asks").is_some());
519 }
520}