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