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