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 )
180 .await
181 {
182 Ok(analytics) => {
183 if analytics.dex_pairs.is_empty() {
184 return (
185 StatusCode::NOT_FOUND,
186 Json(serde_json::json!({ "error": "No DEX pairs found" })),
187 )
188 .into_response();
189 }
190 let best_pair = analytics
191 .dex_pairs
192 .iter()
193 .max_by(|a, b| {
194 a.liquidity_usd
195 .partial_cmp(&b.liquidity_usd)
196 .unwrap_or(std::cmp::Ordering::Equal)
197 })
198 .unwrap();
199 let book =
200 order_book_from_analytics(venue_chain, best_pair, &analytics.token.symbol);
201 let summary = MarketSummary::from_order_book(
202 &book,
203 req.peg,
204 &thresholds,
205 Some(best_pair.volume_24h),
206 );
207 Json(summary_to_json(&summary)).into_response()
208 }
209 Err(e) => (
210 StatusCode::INTERNAL_SERVER_ERROR,
211 Json(serde_json::json!({ "error": e.to_string() })),
212 )
213 .into_response(),
214 }
215 }
216}
217
218fn is_dex_venue(venue: &str) -> bool {
220 matches!(venue.to_lowercase().as_str(), "ethereum" | "eth" | "solana")
221}
222
223fn dex_venue_to_chain(venue: &str) -> &str {
225 match venue.to_lowercase().as_str() {
226 "ethereum" | "eth" => "ethereum",
227 "solana" => "solana",
228 _ => "ethereum",
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[test]
237 fn test_deserialize_full() {
238 let json = serde_json::json!({
239 "pair": "PUSD",
240 "market_venue": "biconomy",
241 "chain": "polygon",
242 "peg": 1.0,
243 "min_levels": 10,
244 "min_depth": 5000.0,
245 "peg_range": 0.002
246 });
247 let req: MarketRequest = serde_json::from_value(json).unwrap();
248 assert_eq!(req.pair, "PUSD");
249 assert_eq!(req.market_venue, "biconomy");
250 assert_eq!(req.chain, "polygon");
251 assert_eq!(req.peg, 1.0);
252 assert_eq!(req.min_levels, 10);
253 assert_eq!(req.min_depth, 5000.0);
254 assert_eq!(req.peg_range, 0.002);
255 }
256
257 #[test]
258 fn test_deserialize_minimal() {
259 let json = serde_json::json!({});
260 let req: MarketRequest = serde_json::from_value(json).unwrap();
261 assert_eq!(req.pair, "USDC");
262 assert_eq!(req.market_venue, "binance");
263 assert_eq!(req.chain, "ethereum");
264 assert_eq!(req.peg, 1.0);
265 assert_eq!(req.min_levels, 6);
266 assert_eq!(req.min_depth, 3000.0);
267 assert_eq!(req.peg_range, 0.001);
268 }
269
270 #[test]
271 fn test_all_defaults() {
272 assert_eq!(default_pair(), "USDC");
273 assert_eq!(default_venue(), "binance");
274 assert_eq!(default_chain(), "ethereum");
275 assert_eq!(default_peg(), 1.0);
276 assert_eq!(default_min_levels(), 6);
277 assert_eq!(default_min_depth(), 3000.0);
278 assert_eq!(default_peg_range(), 0.001);
279 }
280
281 #[test]
282 fn test_custom_thresholds() {
283 let json = serde_json::json!({
284 "min_levels": 20,
285 "min_depth": 10000.0,
286 "peg_range": 0.005
287 });
288 let req: MarketRequest = serde_json::from_value(json).unwrap();
289 assert_eq!(req.min_levels, 20);
290 assert_eq!(req.min_depth, 10000.0);
291 assert_eq!(req.peg_range, 0.005);
292 assert_eq!(req.pair, "USDC");
294 assert_eq!(req.market_venue, "binance");
295 assert_eq!(req.chain, "ethereum");
296 assert_eq!(req.peg, 1.0);
297 }
298
299 #[tokio::test]
300 async fn test_handle_market_cex() {
301 use crate::chains::DefaultClientFactory;
302 use crate::config::Config;
303 use crate::web::AppState;
304 use axum::extract::State;
305 use axum::response::IntoResponse;
306
307 let config = Config::default();
308 let factory = DefaultClientFactory {
309 chains_config: config.chains.clone(),
310 };
311 let state = std::sync::Arc::new(AppState { config, factory });
312 let req = MarketRequest {
313 pair: "USDC".to_string(),
314 market_venue: "binance".to_string(),
315 chain: "ethereum".to_string(),
316 peg: 1.0,
317 min_levels: 6,
318 min_depth: 3000.0,
319 peg_range: 0.001,
320 };
321 let response = handle(State(state), axum::Json(req)).await.into_response();
322 let status = response.status();
323 assert!(status.is_success() || status.is_client_error() || status.is_server_error());
324 }
325
326 #[tokio::test]
327 async fn test_handle_market_dex() {
328 use crate::chains::DefaultClientFactory;
329 use crate::config::Config;
330 use crate::web::AppState;
331 use axum::extract::State;
332 use axum::response::IntoResponse;
333
334 let config = Config::default();
335 let factory = DefaultClientFactory {
336 chains_config: config.chains.clone(),
337 };
338 let state = std::sync::Arc::new(AppState { config, factory });
339 let req = MarketRequest {
340 pair: "USDC".to_string(),
341 market_venue: "eth".to_string(),
342 chain: "ethereum".to_string(),
343 peg: 1.0,
344 min_levels: 6,
345 min_depth: 3000.0,
346 peg_range: 0.001,
347 };
348 let response = handle(State(state), axum::Json(req)).await.into_response();
349 let status = response.status();
350 assert!(status.is_success() || status.is_client_error() || status.is_server_error());
351 }
352}