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": "PUSD",
241 "market_venue": "biconomy",
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, "PUSD");
250 assert_eq!(req.market_venue, "biconomy");
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 factory = DefaultClientFactory {
310 chains_config: config.chains.clone(),
311 };
312 let state = std::sync::Arc::new(AppState { config, factory });
313 let req = MarketRequest {
314 pair: "USDC".to_string(),
315 market_venue: "binance".to_string(),
316 chain: "ethereum".to_string(),
317 peg: 1.0,
318 min_levels: 6,
319 min_depth: 3000.0,
320 peg_range: 0.001,
321 };
322 let response = handle(State(state), axum::Json(req)).await.into_response();
323 let status = response.status();
324 assert!(status.is_success() || status.is_client_error() || status.is_server_error());
325 }
326
327 #[tokio::test]
328 async fn test_handle_market_dex() {
329 use crate::chains::DefaultClientFactory;
330 use crate::config::Config;
331 use crate::web::AppState;
332 use axum::extract::State;
333 use axum::response::IntoResponse;
334
335 let config = Config::default();
336 let factory = DefaultClientFactory {
337 chains_config: config.chains.clone(),
338 };
339 let state = std::sync::Arc::new(AppState { config, factory });
340 let req = MarketRequest {
341 pair: "USDC".to_string(),
342 market_venue: "eth".to_string(),
343 chain: "ethereum".to_string(),
344 peg: 1.0,
345 min_levels: 6,
346 min_depth: 3000.0,
347 peg_range: 0.001,
348 };
349 let response = handle(State(state), axum::Json(req)).await.into_response();
350 let status = response.status();
351 assert!(status.is_success() || status.is_client_error() || status.is_server_error());
352 }
353
354 #[tokio::test]
355 async fn test_handle_market_with_cex_venue() {
356 use crate::chains::DefaultClientFactory;
357 use crate::config::Config;
358 use crate::web::AppState;
359 use axum::extract::State;
360 use axum::response::IntoResponse;
361
362 let config = Config::default();
363 let factory = DefaultClientFactory {
364 chains_config: config.chains.clone(),
365 };
366 let state = std::sync::Arc::new(AppState { config, factory });
367 let req = MarketRequest {
368 pair: "BTC".to_string(),
369 market_venue: "binance".to_string(),
370 chain: "ethereum".to_string(),
371 peg: 1.0,
372 min_levels: 1,
373 min_depth: 50.0,
374 peg_range: 0.01,
375 };
376 let response = handle(State(state), axum::Json(req)).await.into_response();
377 let status = response.status();
378 assert!(
379 status.is_success() || status.is_server_error(),
380 "Unexpected status: {}",
381 status
382 );
383 }
384
385 #[test]
386 fn test_is_dex_venue() {
387 assert!(is_dex_venue("eth"));
388 assert!(is_dex_venue("ethereum"));
389 assert!(is_dex_venue("solana"));
390 assert!(!is_dex_venue("binance"));
391 assert!(!is_dex_venue("mexc"));
392 }
393
394 #[test]
395 fn test_dex_venue_to_chain() {
396 assert_eq!(dex_venue_to_chain("eth"), "ethereum");
397 assert_eq!(dex_venue_to_chain("ethereum"), "ethereum");
398 assert_eq!(dex_venue_to_chain("solana"), "solana");
399 assert_eq!(dex_venue_to_chain("unknown"), "ethereum");
400 }
401
402 #[test]
403 fn test_market_request_debug() {
404 let req = MarketRequest {
405 pair: "USDC".to_string(),
406 market_venue: "binance".to_string(),
407 chain: "ethereum".to_string(),
408 peg: 1.0,
409 min_levels: 6,
410 min_depth: 3000.0,
411 peg_range: 0.001,
412 };
413 let debug = format!("{:?}", req);
414 assert!(debug.contains("MarketRequest"));
415 }
416}