Skip to main content

scope_web/api/
market.rs

1//! Market summary API handler.
2
3use super::{error_response, status_for};
4use crate::AppState;
5use axum::Json;
6use axum::extract::State;
7use axum::response::IntoResponse;
8use scope::market::{HealthOverrides, MarketSummary, MarketVenue, Pair, fetch_summary};
9use scope_cli::cli::market::CrawlDexSource;
10use scope_cli::cli::output::Output;
11use serde::Deserialize;
12use std::sync::Arc;
13
14/// Request body for market summary.
15#[derive(Debug, Deserialize)]
16pub struct MarketRequest {
17    /// Pair or base symbol, read by `Pair::parse` ("DAI_USDC", "OPUSDT", "USDC"). Default: "USDC".
18    #[serde(default = "super::default_pair")]
19    pub pair: String,
20    /// Market venue; see scope::market::VENUE_HELP.
21    #[serde(default = "super::default_venue")]
22    pub market_venue: String,
23    /// Chain check for a DEX venue: when set, it must name the venue's chain.
24    /// The venue already names its chain. Ignored for CEX venues.
25    #[serde(default)]
26    pub chain: Option<String>,
27    /// Health threshold overrides (top-level JSON keys, e.g. `peg`,
28    /// `min_levels`, `max_spread_pct`). Unset keys use config `market.health`.
29    #[serde(flatten)]
30    pub health: HealthOverrides,
31}
32
33/// POST /api/market/summary — Peg and order book health. The pair and venue
34/// are read exactly as the CLI reads them (core `Pair` and `MarketVenue`).
35/// Errors map through `status_for`: an unknown venue or a chain mismatch is
36/// 400, no DEX pairs is 404.
37pub async fn handle(
38    State(state): State<Arc<AppState>>,
39    Json(req): Json<MarketRequest>,
40) -> impl IntoResponse {
41    match market_summary(&state, &req).await {
42        Ok(summary) => Json(summary.to_json()).into_response(),
43        Err(e) => error_response(status_for(&e), e.to_string()),
44    }
45}
46
47async fn market_summary(
48    state: &AppState,
49    req: &MarketRequest,
50) -> scope::error::Result<MarketSummary> {
51    let venue = MarketVenue::from_name(&req.market_venue)?;
52    if let Some(chain) = &req.chain {
53        venue.require_chain(chain)?;
54    }
55    let thresholds = state.config.market.health.with_overrides(&req.health);
56    let out = Output::stdio();
57    let dex = CrawlDexSource {
58        factory: state.factory.as_ref(),
59        out: &out,
60    };
61    fetch_summary(&venue, &Pair::parse(&req.pair), &thresholds, &dex).await
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67    use scope::market::DexVenue;
68
69    #[test]
70    fn test_deserialize_full() {
71        let json = serde_json::json!({
72            "pair": "DAI",
73            "market_venue": "binance",
74            "chain": "polygon",
75            "peg": 1.0,
76            "min_levels": 10,
77            "min_depth": 5000.0,
78            "peg_range": 0.002
79        });
80        let req: MarketRequest = serde_json::from_value(json).unwrap();
81        assert_eq!(req.pair, "DAI");
82        assert_eq!(req.market_venue, "binance");
83        assert_eq!(req.chain.as_deref(), Some("polygon"));
84        assert_eq!(req.health.peg_target, Some(1.0));
85        assert_eq!(req.health.min_levels, Some(10));
86        assert_eq!(req.health.min_depth, Some(5000.0));
87        assert_eq!(req.health.peg_range, Some(0.002));
88    }
89
90    #[test]
91    fn test_deserialize_minimal() {
92        let json = serde_json::json!({});
93        let req: MarketRequest = serde_json::from_value(json).unwrap();
94        assert_eq!(req.pair, "USDC");
95        assert_eq!(req.market_venue, "binance");
96        assert_eq!(req.chain, None);
97        // No threshold keys: every value comes from config `market.health`.
98        assert_eq!(req.health, HealthOverrides::default());
99    }
100
101    #[test]
102    fn test_all_defaults() {
103        assert_eq!(super::super::default_pair(), "USDC");
104        assert_eq!(super::super::default_venue(), "binance");
105    }
106
107    #[test]
108    fn test_new_thresholds_accepted() {
109        let json = serde_json::json!({
110            "max_spread_pct": 1.5,
111            "min_top3_depth": 500.0,
112            "min_top10_depth": 4000.0,
113            "min_bid_ask_ratio": 0.5,
114            "max_bid_ask_ratio": 2.0
115        });
116        let req: MarketRequest = serde_json::from_value(json).unwrap();
117        assert_eq!(req.health.max_spread_pct, Some(1.5));
118        assert_eq!(req.health.min_top3_depth, Some(500.0));
119        assert_eq!(req.health.min_top10_depth, Some(4000.0));
120        assert_eq!(req.health.min_bid_ask_ratio, Some(0.5));
121        assert_eq!(req.health.max_bid_ask_ratio, Some(2.0));
122    }
123
124    #[test]
125    fn test_request_accepts_amm_step_pct() {
126        // #37: the AMM step is a request field like the other thresholds.
127        let req: MarketRequest =
128            serde_json::from_value(serde_json::json!({ "amm_step_pct": 0.05 })).unwrap();
129        let t = scope::config::Config::default()
130            .market
131            .health
132            .with_overrides(&req.health);
133        assert_eq!(t.amm_step_pct, 0.05);
134    }
135
136    /// H6: with `peg_target: 0.92` in the config, a market request and a
137    /// token-health request that send no peg both judge against 0.92.
138    #[test]
139    fn test_configured_peg_applies_to_both_panels() {
140        let mut config = scope::config::Config::default();
141        config.market.health.peg_target = 0.92;
142        let market: MarketRequest =
143            serde_json::from_value(serde_json::json!({ "pair": "EURC" })).unwrap();
144        let health: super::super::token_health::TokenHealthRequest =
145            serde_json::from_value(serde_json::json!({ "token": "EURC", "with_market": true }))
146                .unwrap();
147        let from_market = config.market.health.with_overrides(&market.health);
148        let from_health = config.market.health.with_overrides(&health.health);
149        assert_eq!(from_market.peg_target, 0.92);
150        assert_eq!(from_health.peg_target, 0.92);
151    }
152
153    #[test]
154    fn test_request_overrides_config() {
155        let mut config = scope::config::Config::default();
156        config.market.health.min_levels = 20;
157        config.market.health.max_spread_pct = 5.0;
158        let req: MarketRequest =
159            serde_json::from_value(serde_json::json!({ "max_spread_pct": 1.0 })).unwrap();
160        let t = config.market.health.with_overrides(&req.health);
161        assert_eq!(t.max_spread_pct, 1.0);
162        assert_eq!(t.min_levels, 20);
163    }
164
165    #[test]
166    fn test_custom_thresholds() {
167        let json = serde_json::json!({
168            "min_levels": 20,
169            "min_depth": 10000.0,
170            "peg_range": 0.005
171        });
172        let req: MarketRequest = serde_json::from_value(json).unwrap();
173        assert_eq!(req.health.min_levels, Some(20));
174        assert_eq!(req.health.min_depth, Some(10000.0));
175        assert_eq!(req.health.peg_range, Some(0.005));
176        // Other fields should use defaults
177        assert_eq!(req.pair, "USDC");
178        assert_eq!(req.market_venue, "binance");
179        assert_eq!(req.chain, None);
180        assert_eq!(req.health.peg_target, None);
181    }
182
183    #[tokio::test]
184    async fn test_handle_market_cex() {
185        use crate::AppState;
186        use axum::extract::State;
187        use axum::response::IntoResponse;
188        use scope::chains::DefaultClientFactory;
189        use scope::config::Config;
190
191        let config = Config::default();
192        let http: std::sync::Arc<dyn scope::http::HttpClient> =
193            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
194        let factory = DefaultClientFactory {
195            chains_config: config.chains.clone(),
196            http,
197        };
198        let state = std::sync::Arc::new(AppState::new(config, factory));
199        let req = MarketRequest {
200            pair: "USDC".to_string(),
201            market_venue: "binance".to_string(),
202            chain: None,
203            health: HealthOverrides::default(),
204        };
205        let response = handle(State(state), axum::Json(req)).await.into_response();
206        let status = response.status();
207        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
208    }
209
210    #[tokio::test]
211    async fn test_handle_market_dex() {
212        use crate::AppState;
213        use axum::extract::State;
214        use axum::response::IntoResponse;
215        use scope::chains::DefaultClientFactory;
216        use scope::config::Config;
217
218        let config = Config::default();
219        let http: std::sync::Arc<dyn scope::http::HttpClient> =
220            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
221        let factory = DefaultClientFactory {
222            chains_config: config.chains.clone(),
223            http,
224        };
225        let state = std::sync::Arc::new(AppState::new(config, factory));
226        let req = MarketRequest {
227            pair: "USDC".to_string(),
228            market_venue: "eth".to_string(),
229            chain: None,
230            health: HealthOverrides::default(),
231        };
232        let response = handle(State(state), axum::Json(req)).await.into_response();
233        let status = response.status();
234        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
235    }
236
237    #[tokio::test]
238    async fn test_handle_market_with_cex_venue() {
239        use crate::AppState;
240        use axum::extract::State;
241        use axum::response::IntoResponse;
242        use scope::chains::DefaultClientFactory;
243        use scope::config::Config;
244
245        let config = Config::default();
246        let http: std::sync::Arc<dyn scope::http::HttpClient> =
247            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
248        let factory = DefaultClientFactory {
249            chains_config: config.chains.clone(),
250            http,
251        };
252        let state = std::sync::Arc::new(AppState::new(config, factory));
253        let req = MarketRequest {
254            pair: "BTC".to_string(),
255            market_venue: "binance".to_string(),
256            chain: None,
257            health: HealthOverrides::default(),
258        };
259        let response = handle(State(state), axum::Json(req)).await.into_response();
260        let status = response.status();
261        assert!(
262            status.is_success() || status.is_server_error(),
263            "Unexpected status: {}",
264            status
265        );
266    }
267
268    #[test]
269    fn test_is_dex_venue() {
270        assert!(DexVenue::parse("eth").is_some());
271        assert!(DexVenue::parse("ethereum").is_some());
272        assert!(DexVenue::parse("solana").is_some());
273        assert!(DexVenue::parse("binance").is_none());
274        assert!(DexVenue::parse("mexc").is_none());
275    }
276
277    #[test]
278    fn test_dex_venue_to_chain() {
279        assert_eq!(
280            DexVenue::parse("eth").map(DexVenue::chain),
281            Some("ethereum")
282        );
283        assert_eq!(
284            DexVenue::parse("ethereum").map(DexVenue::chain),
285            Some("ethereum")
286        );
287        assert_eq!(
288            DexVenue::parse("solana").map(DexVenue::chain),
289            Some("solana")
290        );
291        assert_eq!(DexVenue::parse("unknown"), None); // no silent ethereum
292    }
293
294    #[test]
295    fn test_market_request_debug() {
296        let req = MarketRequest {
297            pair: "USDC".to_string(),
298            market_venue: "binance".to_string(),
299            chain: None,
300            health: HealthOverrides::default(),
301        };
302        let debug = format!("{:?}", req);
303        assert!(debug.contains("MarketRequest"));
304    }
305
306    #[tokio::test]
307    async fn test_handle_market_invalid_venue_bad_request() {
308        use crate::AppState;
309        use axum::extract::State;
310        use axum::response::IntoResponse;
311        use scope::chains::DefaultClientFactory;
312        use scope::config::Config;
313
314        let config = Config::default();
315        let http: std::sync::Arc<dyn scope::http::HttpClient> =
316            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
317        let factory = DefaultClientFactory {
318            chains_config: config.chains.clone(),
319            http,
320        };
321        let state = std::sync::Arc::new(AppState::new(config, factory));
322        let req = MarketRequest {
323            pair: "USDC".to_string(),
324            market_venue: "nonexistent_venue_xyz".to_string(),
325            chain: None,
326            health: HealthOverrides::default(),
327        };
328        let response = handle(State(state), axum::Json(req)).await.into_response();
329        let status = response.status();
330        // Unknown venue -> BAD_REQUEST from create_exchange_client
331        assert_eq!(status, axum::http::StatusCode::BAD_REQUEST);
332    }
333
334    #[tokio::test]
335    async fn test_handle_market_success_json_structure() {
336        use crate::AppState;
337        use axum::body;
338        use axum::extract::State;
339        use axum::response::IntoResponse;
340        use scope::chains::DefaultClientFactory;
341        use scope::config::Config;
342
343        let config = Config::default();
344        let http: std::sync::Arc<dyn scope::http::HttpClient> =
345            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
346        let factory = DefaultClientFactory {
347            chains_config: config.chains.clone(),
348            http,
349        };
350        let state = std::sync::Arc::new(AppState::new(config, factory));
351        let req = MarketRequest {
352            pair: "USDC".to_string(),
353            market_venue: "binance".to_string(),
354            chain: None,
355            health: HealthOverrides::default(),
356        };
357        let response = handle(State(state), axum::Json(req)).await.into_response();
358        if response.status().is_success() {
359            let body_bytes = body::to_bytes(response.into_body(), 1_000_000)
360                .await
361                .unwrap();
362            let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
363            assert!(json.get("pair").is_some());
364            assert!(json.get("peg_target").is_some());
365            assert!(json.get("best_bid").is_some());
366            assert!(json.get("best_ask").is_some());
367            assert!(json.get("healthy").is_some());
368            assert!(json.get("checks").is_some());
369        }
370    }
371
372    #[test]
373    fn test_is_dex_venue_case_insensitive() {
374        assert!(DexVenue::parse("ETHEREUM").is_some());
375        assert!(DexVenue::parse("SOLANA").is_some());
376        assert!(DexVenue::parse("BINANCE").is_none());
377    }
378
379    #[test]
380    fn test_summary_json_with_execution_estimates() {
381        use scope::market::OrderBookLevel;
382
383        let book = scope::market::OrderBook {
384            pair: "USDC/USDT".to_string(),
385            source: scope::market::BookSource::Exchange,
386            bids: vec![
387                OrderBookLevel {
388                    price: 0.9999,
389                    quantity: 20_000.0,
390                },
391                OrderBookLevel {
392                    price: 0.9998,
393                    quantity: 10_000.0,
394                },
395            ],
396            asks: vec![
397                OrderBookLevel {
398                    price: 1.0001,
399                    quantity: 20_000.0,
400                },
401                OrderBookLevel {
402                    price: 1.0002,
403                    quantity: 10_000.0,
404                },
405            ],
406        };
407        let thresholds = scope::market::HealthThresholds::default();
408        let summary =
409            scope::market::MarketSummary::from_order_book(&book, &thresholds, Some(50_000.0));
410        let json = summary.to_json();
411
412        assert_eq!(json["pair"], "USDC/USDT");
413        assert!(json["best_bid"].as_f64().unwrap() > 0.0);
414        assert!(json["best_ask"].as_f64().unwrap() > 0.0);
415        assert!(json.get("healthy").is_some());
416        assert!(!json["checks"].as_array().unwrap().is_empty());
417        assert!(json.get("execution_10k_buy").is_some());
418        assert!(json.get("execution_10k_sell").is_some());
419        assert!(json.get("bids").is_some());
420        assert!(json.get("asks").is_some());
421    }
422}