Skip to main content

scope/web/api/
market.rs

1//! Market summary API handler.
2
3use 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/// Request body for market summary.
14#[derive(Debug, Deserialize)]
15pub struct MarketRequest {
16    /// Token symbol (e.g., "USDC", "PUSD"). Default: "USDC".
17    #[serde(default = "default_pair")]
18    pub pair: String,
19    /// Market venue: "binance", "biconomy", "eth", "solana".
20    #[serde(default = "default_venue")]
21    pub market_venue: String,
22    /// Chain for DEX venues.
23    #[serde(default = "default_chain")]
24    pub chain: String,
25    /// Peg target (default: 1.0).
26    #[serde(default = "default_peg")]
27    pub peg: f64,
28    /// Min order book levels per side.
29    #[serde(default = "default_min_levels")]
30    pub min_levels: usize,
31    /// Min depth per side in quote terms.
32    #[serde(default = "default_min_depth")]
33    pub min_depth: f64,
34    /// Peg range for outlier filtering.
35    #[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
61/// Converts a MarketSummary to a JSON Value.
62fn 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
108/// POST /api/market/summary — Peg and order book health.
109pub 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        // CEX venue — use the venue registry
126        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        // DEX venue: fetch analytics then synthesize order book
171        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
219/// Whether the venue string refers to a DEX venue.
220fn is_dex_venue(venue: &str) -> bool {
221    matches!(venue.to_lowercase().as_str(), "ethereum" | "eth" | "solana")
222}
223
224/// Resolve DEX venue name to a canonical chain name.
225fn 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        // Other fields should use defaults
294        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}