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_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 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 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 #[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 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); }
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 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}