Skip to main content

scope/web/api/
market.rs

1//! Market summary API handler.
2
3use crate::cli::crawl::{self, Period};
4use crate::market::{
5    BinanceClient, HealthThresholds, MarketSummary, MarketVenue, order_book_from_analytics,
6};
7use crate::web::AppState;
8use axum::Json;
9use axum::extract::State;
10use axum::http::StatusCode;
11use axum::response::IntoResponse;
12use serde::Deserialize;
13use std::sync::Arc;
14
15/// Request body for market summary.
16#[derive(Debug, Deserialize)]
17pub struct MarketRequest {
18    /// Token symbol (e.g., "USDC", "PUSD"). Default: "USDC".
19    #[serde(default = "default_pair")]
20    pub pair: String,
21    /// Market venue: "binance", "biconomy", "eth", "solana".
22    #[serde(default = "default_venue")]
23    pub market_venue: String,
24    /// Chain for DEX venues.
25    #[serde(default = "default_chain")]
26    pub chain: String,
27    /// Peg target (default: 1.0).
28    #[serde(default = "default_peg")]
29    pub peg: f64,
30    /// Min order book levels per side.
31    #[serde(default = "default_min_levels")]
32    pub min_levels: usize,
33    /// Min depth per side in quote terms.
34    #[serde(default = "default_min_depth")]
35    pub min_depth: f64,
36    /// Peg range for outlier filtering.
37    #[serde(default = "default_peg_range")]
38    pub peg_range: f64,
39}
40
41fn default_pair() -> String {
42    "USDC".to_string()
43}
44fn default_venue() -> String {
45    "binance".to_string()
46}
47fn default_chain() -> String {
48    "ethereum".to_string()
49}
50fn default_peg() -> f64 {
51    1.0
52}
53fn default_min_levels() -> usize {
54    6
55}
56fn default_min_depth() -> f64 {
57    3000.0
58}
59fn default_peg_range() -> f64 {
60    0.001
61}
62
63/// Converts a MarketSummary to a JSON Value.
64fn summary_to_json(summary: &MarketSummary) -> serde_json::Value {
65    serde_json::json!({
66        "pair": summary.pair,
67        "peg_target": summary.peg_target,
68        "best_bid": summary.best_bid,
69        "best_ask": summary.best_ask,
70        "mid_price": summary.mid_price,
71        "spread": summary.spread,
72        "volume_24h": summary.volume_24h,
73        "bid_depth": summary.bid_depth,
74        "ask_depth": summary.ask_depth,
75        "bid_outliers": summary.bid_outliers,
76        "ask_outliers": summary.ask_outliers,
77        "healthy": summary.healthy,
78        "checks": summary.checks.iter().map(|c| match c {
79            crate::market::HealthCheck::Pass(msg) => serde_json::json!({"status": "pass", "message": msg}),
80            crate::market::HealthCheck::Fail(msg) => serde_json::json!({"status": "fail", "message": msg}),
81        }).collect::<Vec<_>>(),
82    })
83}
84
85/// POST /api/market/summary — Peg and order book health.
86pub async fn handle(
87    State(state): State<Arc<AppState>>,
88    Json(req): Json<MarketRequest>,
89) -> impl IntoResponse {
90    let venue: MarketVenue = match req.market_venue.as_str() {
91        "biconomy" => MarketVenue::Biconomy,
92        "eth" | "ethereum" => MarketVenue::Ethereum,
93        "solana" | "sol" => MarketVenue::Solana,
94        _ => MarketVenue::Binance,
95    };
96
97    let thresholds = HealthThresholds {
98        peg_target: req.peg,
99        peg_range: req.peg_range,
100        min_levels: req.min_levels,
101        min_depth: req.min_depth,
102        min_bid_ask_ratio: 0.2,
103        max_bid_ask_ratio: 5.0,
104    };
105
106    if venue.is_cex() {
107        let pair = venue.format_pair(&req.pair);
108        let client_opt = venue.create_client();
109        let Some(client) = client_opt else {
110            return (
111                StatusCode::BAD_REQUEST,
112                Json(serde_json::json!({ "error": "Failed to create market client" })),
113            )
114                .into_response();
115        };
116
117        match client.fetch_order_book(&pair).await {
118            Ok(book) => {
119                let volume_24h = match venue {
120                    MarketVenue::Binance => BinanceClient::default_url()
121                        .fetch_24h_volume(&pair)
122                        .await
123                        .ok()
124                        .flatten(),
125                    _ => None,
126                };
127                let summary =
128                    MarketSummary::from_order_book(&book, req.peg, &thresholds, volume_24h);
129                Json(summary_to_json(&summary)).into_response()
130            }
131            Err(e) => (
132                StatusCode::INTERNAL_SERVER_ERROR,
133                Json(serde_json::json!({ "error": e.to_string() })),
134            )
135                .into_response(),
136        }
137    } else {
138        // DEX venue: fetch analytics then synthesize order book
139        let venue_chain = match venue {
140            MarketVenue::Ethereum => "ethereum",
141            MarketVenue::Solana => "solana",
142            _ => &req.chain,
143        };
144
145        match crawl::fetch_analytics_for_input(
146            &req.pair,
147            venue_chain,
148            Period::Hour24,
149            10,
150            &state.factory,
151        )
152        .await
153        {
154            Ok(analytics) => {
155                if analytics.dex_pairs.is_empty() {
156                    return (
157                        StatusCode::NOT_FOUND,
158                        Json(serde_json::json!({ "error": "No DEX pairs found" })),
159                    )
160                        .into_response();
161                }
162                let best_pair = analytics
163                    .dex_pairs
164                    .iter()
165                    .max_by(|a, b| {
166                        a.liquidity_usd
167                            .partial_cmp(&b.liquidity_usd)
168                            .unwrap_or(std::cmp::Ordering::Equal)
169                    })
170                    .unwrap();
171                let book =
172                    order_book_from_analytics(venue_chain, best_pair, &analytics.token.symbol);
173                let summary = MarketSummary::from_order_book(
174                    &book,
175                    req.peg,
176                    &thresholds,
177                    Some(best_pair.volume_24h),
178                );
179                Json(summary_to_json(&summary)).into_response()
180            }
181            Err(e) => (
182                StatusCode::INTERNAL_SERVER_ERROR,
183                Json(serde_json::json!({ "error": e.to_string() })),
184            )
185                .into_response(),
186        }
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn test_deserialize_full() {
196        let json = serde_json::json!({
197            "pair": "PUSD",
198            "market_venue": "biconomy",
199            "chain": "polygon",
200            "peg": 1.0,
201            "min_levels": 10,
202            "min_depth": 5000.0,
203            "peg_range": 0.002
204        });
205        let req: MarketRequest = serde_json::from_value(json).unwrap();
206        assert_eq!(req.pair, "PUSD");
207        assert_eq!(req.market_venue, "biconomy");
208        assert_eq!(req.chain, "polygon");
209        assert_eq!(req.peg, 1.0);
210        assert_eq!(req.min_levels, 10);
211        assert_eq!(req.min_depth, 5000.0);
212        assert_eq!(req.peg_range, 0.002);
213    }
214
215    #[test]
216    fn test_deserialize_minimal() {
217        let json = serde_json::json!({});
218        let req: MarketRequest = serde_json::from_value(json).unwrap();
219        assert_eq!(req.pair, "USDC");
220        assert_eq!(req.market_venue, "binance");
221        assert_eq!(req.chain, "ethereum");
222        assert_eq!(req.peg, 1.0);
223        assert_eq!(req.min_levels, 6);
224        assert_eq!(req.min_depth, 3000.0);
225        assert_eq!(req.peg_range, 0.001);
226    }
227
228    #[test]
229    fn test_all_defaults() {
230        assert_eq!(default_pair(), "USDC");
231        assert_eq!(default_venue(), "binance");
232        assert_eq!(default_chain(), "ethereum");
233        assert_eq!(default_peg(), 1.0);
234        assert_eq!(default_min_levels(), 6);
235        assert_eq!(default_min_depth(), 3000.0);
236        assert_eq!(default_peg_range(), 0.001);
237    }
238
239    #[test]
240    fn test_custom_thresholds() {
241        let json = serde_json::json!({
242            "min_levels": 20,
243            "min_depth": 10000.0,
244            "peg_range": 0.005
245        });
246        let req: MarketRequest = serde_json::from_value(json).unwrap();
247        assert_eq!(req.min_levels, 20);
248        assert_eq!(req.min_depth, 10000.0);
249        assert_eq!(req.peg_range, 0.005);
250        // Other fields should use defaults
251        assert_eq!(req.pair, "USDC");
252        assert_eq!(req.market_venue, "binance");
253        assert_eq!(req.chain, "ethereum");
254        assert_eq!(req.peg, 1.0);
255    }
256
257    #[tokio::test]
258    async fn test_handle_market_cex() {
259        use crate::chains::DefaultClientFactory;
260        use crate::config::Config;
261        use crate::web::AppState;
262        use axum::extract::State;
263        use axum::response::IntoResponse;
264
265        let config = Config::default();
266        let factory = DefaultClientFactory {
267            chains_config: config.chains.clone(),
268        };
269        let state = std::sync::Arc::new(AppState { config, factory });
270        let req = MarketRequest {
271            pair: "USDC".to_string(),
272            market_venue: "binance".to_string(),
273            chain: "ethereum".to_string(),
274            peg: 1.0,
275            min_levels: 6,
276            min_depth: 3000.0,
277            peg_range: 0.001,
278        };
279        let response = handle(State(state), axum::Json(req)).await.into_response();
280        let status = response.status();
281        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
282    }
283
284    #[tokio::test]
285    async fn test_handle_market_dex() {
286        use crate::chains::DefaultClientFactory;
287        use crate::config::Config;
288        use crate::web::AppState;
289        use axum::extract::State;
290        use axum::response::IntoResponse;
291
292        let config = Config::default();
293        let factory = DefaultClientFactory {
294            chains_config: config.chains.clone(),
295        };
296        let state = std::sync::Arc::new(AppState { config, factory });
297        let req = MarketRequest {
298            pair: "USDC".to_string(),
299            market_venue: "eth".to_string(),
300            chain: "ethereum".to_string(),
301            peg: 1.0,
302            min_levels: 6,
303            min_depth: 3000.0,
304            peg_range: 0.001,
305        };
306        let response = handle(State(state), axum::Json(req)).await.into_response();
307        let status = response.status();
308        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
309    }
310}