1use 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#[derive(Debug, Deserialize)]
16pub struct MarketRequest {
17 #[serde(default = "super::default_pair")]
19 pub pair: String,
20 #[serde(default = "super::default_venue")]
22 pub market_venue: String,
23 #[serde(default)]
26 pub chain: Option<String>,
27 #[serde(flatten)]
30 pub health: HealthOverrides,
31}
32
33pub 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 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 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 #[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 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); }
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 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}