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        )
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
218/// Whether the venue string refers to a DEX venue.
219fn is_dex_venue(venue: &str) -> bool {
220    matches!(venue.to_lowercase().as_str(), "ethereum" | "eth" | "solana")
221}
222
223/// Resolve DEX venue name to a canonical chain name.
224fn 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        // Other fields should use defaults
293        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}