Skip to main content

scope_web/api/
market.rs

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