Skip to main content

scope/web/api/
token_health.rs

1//! Token health API handler.
2
3use crate::cli::crawl::{self, Period};
4use crate::market::{HealthThresholds, MarketSummary, VenueRegistry, order_book_from_analytics};
5use crate::web::AppState;
6use axum::Json;
7use axum::extract::State;
8use axum::http::StatusCode;
9use axum::response::IntoResponse;
10use serde::Deserialize;
11use std::sync::Arc;
12
13/// Request body for token health.
14#[derive(Debug, Deserialize)]
15pub struct TokenHealthRequest {
16    /// Token symbol or contract address.
17    pub token: String,
18    /// Target chain (default: "ethereum").
19    #[serde(default = "default_chain")]
20    pub chain: String,
21    /// Include market/order book data.
22    #[serde(default)]
23    pub with_market: bool,
24    /// Market venue: "binance", "biconomy", "eth", "solana".
25    #[serde(default = "default_venue")]
26    pub market_venue: String,
27}
28
29fn default_chain() -> String {
30    "ethereum".to_string()
31}
32
33fn default_venue() -> String {
34    "binance".to_string()
35}
36
37/// POST /api/token-health — Token health suite.
38pub async fn handle(
39    State(state): State<Arc<AppState>>,
40    Json(req): Json<TokenHealthRequest>,
41) -> impl IntoResponse {
42    // Fetch DEX analytics
43    let analytics = match crawl::fetch_analytics_for_input(
44        &req.token,
45        &req.chain,
46        Period::Hour24,
47        10,
48        &state.factory,
49        None,
50    )
51    .await
52    {
53        Ok(a) => a,
54        Err(e) => {
55            return (
56                StatusCode::INTERNAL_SERVER_ERROR,
57                Json(serde_json::json!({ "error": e.to_string() })),
58            )
59                .into_response();
60        }
61    };
62
63    // Optionally fetch market data
64    let venue_id = &req.market_venue;
65    let market_summary = if req.with_market {
66        let thresholds = HealthThresholds {
67            peg_target: 1.0,
68            peg_range: 0.001,
69            min_levels: 6,
70            min_depth: 3000.0,
71            min_bid_ask_ratio: 0.2,
72            max_bid_ask_ratio: 5.0,
73        };
74
75        if !is_dex_venue(venue_id) {
76            // CEX venue — use venue registry
77            if let Ok(registry) = VenueRegistry::load()
78                && let Ok(exchange) = registry.create_exchange_client(venue_id)
79            {
80                let pair = exchange.format_pair(&analytics.token.symbol);
81                match exchange.fetch_order_book(&pair).await {
82                    Ok(book) => {
83                        let volume_24h = if exchange.has_ticker() {
84                            exchange
85                                .fetch_ticker(&pair)
86                                .await
87                                .ok()
88                                .and_then(|t| t.quote_volume_24h.or(t.volume_24h))
89                        } else {
90                            None
91                        };
92                        Some(MarketSummary::from_order_book(
93                            &book,
94                            1.0,
95                            &thresholds,
96                            volume_24h,
97                        ))
98                    }
99                    Err(_) => None,
100                }
101            } else {
102                None
103            }
104        } else {
105            // DEX venue
106            let venue_chain = dex_venue_to_chain(venue_id);
107            if analytics.chain.eq_ignore_ascii_case(venue_chain) && !analytics.dex_pairs.is_empty()
108            {
109                let best_pair = analytics
110                    .dex_pairs
111                    .iter()
112                    .max_by(|a, b| {
113                        a.liquidity_usd
114                            .partial_cmp(&b.liquidity_usd)
115                            .unwrap_or(std::cmp::Ordering::Equal)
116                    })
117                    .unwrap();
118                let book =
119                    order_book_from_analytics(&analytics.chain, best_pair, &analytics.token.symbol);
120                let volume_24h = Some(best_pair.volume_24h);
121                Some(MarketSummary::from_order_book(
122                    &book,
123                    1.0,
124                    &thresholds,
125                    volume_24h,
126                ))
127            } else {
128                None
129            }
130        }
131    } else {
132        None
133    };
134
135    // Build combined JSON
136    let market_json = market_summary.map(|m| {
137        serde_json::json!({
138            "pair": m.pair,
139            "peg_target": m.peg_target,
140            "best_bid": m.best_bid,
141            "best_ask": m.best_ask,
142            "mid_price": m.mid_price,
143            "spread": m.spread,
144            "bid_depth": m.bid_depth,
145            "ask_depth": m.ask_depth,
146            "healthy": m.healthy,
147            "volume_24h": m.volume_24h,
148            "checks": m.checks.iter().map(|c| match c {
149                crate::market::HealthCheck::Pass(msg) => serde_json::json!({"status": "pass", "message": msg}),
150                crate::market::HealthCheck::Fail(msg) => serde_json::json!({"status": "fail", "message": msg}),
151            }).collect::<Vec<_>>()
152        })
153    });
154
155    Json(serde_json::json!({
156        "analytics": analytics,
157        "market": market_json,
158    }))
159    .into_response()
160}
161
162/// Whether the venue string refers to a DEX venue.
163fn is_dex_venue(venue: &str) -> bool {
164    matches!(venue.to_lowercase().as_str(), "ethereum" | "eth" | "solana")
165}
166
167/// Resolve DEX venue name to a canonical chain name.
168fn dex_venue_to_chain(venue: &str) -> &str {
169    match venue.to_lowercase().as_str() {
170        "ethereum" | "eth" => "ethereum",
171        "solana" => "solana",
172        _ => "ethereum",
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn test_deserialize_full() {
182        let json = serde_json::json!({
183            "token": "USDC",
184            "chain": "polygon",
185            "with_market": true,
186            "market_venue": "biconomy"
187        });
188        let req: TokenHealthRequest = serde_json::from_value(json).unwrap();
189        assert_eq!(req.token, "USDC");
190        assert_eq!(req.chain, "polygon");
191        assert!(req.with_market);
192        assert_eq!(req.market_venue, "biconomy");
193    }
194
195    #[test]
196    fn test_deserialize_minimal() {
197        let json = serde_json::json!({
198            "token": "USDC"
199        });
200        let req: TokenHealthRequest = serde_json::from_value(json).unwrap();
201        assert_eq!(req.token, "USDC");
202        assert_eq!(req.chain, "ethereum");
203        assert!(!req.with_market);
204        assert_eq!(req.market_venue, "binance");
205    }
206
207    #[test]
208    fn test_defaults() {
209        assert_eq!(default_chain(), "ethereum");
210        assert_eq!(default_venue(), "binance");
211    }
212
213    #[test]
214    fn test_with_market_flag() {
215        let json = serde_json::json!({
216            "token": "USDC",
217            "with_market": true
218        });
219        let req: TokenHealthRequest = serde_json::from_value(json).unwrap();
220        assert!(req.with_market);
221
222        let json_false = serde_json::json!({
223            "token": "USDC",
224            "with_market": false
225        });
226        let req_false: TokenHealthRequest = serde_json::from_value(json_false).unwrap();
227        assert!(!req_false.with_market);
228    }
229
230    #[tokio::test]
231    async fn test_handle_token_health_direct() {
232        use crate::chains::DefaultClientFactory;
233        use crate::config::Config;
234        use crate::web::AppState;
235        use axum::extract::State;
236        use axum::response::IntoResponse;
237
238        let config = Config::default();
239        let factory = DefaultClientFactory {
240            chains_config: config.chains.clone(),
241        };
242        let state = std::sync::Arc::new(AppState { config, factory });
243        let req = TokenHealthRequest {
244            token: "USDC".to_string(),
245            chain: "ethereum".to_string(),
246            with_market: false,
247            market_venue: "binance".to_string(),
248        };
249        let response = handle(State(state), axum::Json(req)).await.into_response();
250        let status = response.status();
251        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
252    }
253
254    #[tokio::test]
255    async fn test_handle_token_health_with_market() {
256        use crate::chains::DefaultClientFactory;
257        use crate::config::Config;
258        use crate::web::AppState;
259        use axum::extract::State;
260        use axum::response::IntoResponse;
261
262        let config = Config::default();
263        let factory = DefaultClientFactory {
264            chains_config: config.chains.clone(),
265        };
266        let state = std::sync::Arc::new(AppState { config, factory });
267        let req = TokenHealthRequest {
268            token: "USDC".to_string(),
269            chain: "ethereum".to_string(),
270            with_market: true,
271            market_venue: "eth".to_string(),
272        };
273        let response = handle(State(state), axum::Json(req)).await.into_response();
274        let status = response.status();
275        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
276    }
277
278    #[tokio::test]
279    async fn test_handle_token_health_with_cex_market() {
280        use crate::chains::DefaultClientFactory;
281        use crate::config::Config;
282        use crate::web::AppState;
283        use axum::extract::State;
284        use axum::response::IntoResponse;
285
286        let config = Config::default();
287        let factory = DefaultClientFactory {
288            chains_config: config.chains.clone(),
289        };
290        let state = std::sync::Arc::new(AppState { config, factory });
291        let req = TokenHealthRequest {
292            token: "USDC".to_string(),
293            chain: "ethereum".to_string(),
294            with_market: true,
295            market_venue: "binance".to_string(), // CEX path
296        };
297        let response = handle(State(state), axum::Json(req)).await.into_response();
298        let status = response.status();
299        // Either succeeds (200) or fails gracefully (500)
300        assert!(status.is_success() || status.is_server_error());
301    }
302
303    #[test]
304    fn test_is_dex_venue() {
305        assert!(is_dex_venue("ethereum"));
306        assert!(is_dex_venue("eth"));
307        assert!(is_dex_venue("Ethereum"));
308        assert!(is_dex_venue("ETH"));
309        assert!(is_dex_venue("solana"));
310        assert!(is_dex_venue("Solana"));
311        assert!(!is_dex_venue("binance"));
312        assert!(!is_dex_venue("mexc"));
313        assert!(!is_dex_venue("okx"));
314        assert!(!is_dex_venue(""));
315    }
316
317    #[test]
318    fn test_dex_venue_to_chain() {
319        assert_eq!(dex_venue_to_chain("ethereum"), "ethereum");
320        assert_eq!(dex_venue_to_chain("eth"), "ethereum");
321        assert_eq!(dex_venue_to_chain("Ethereum"), "ethereum");
322        assert_eq!(dex_venue_to_chain("solana"), "solana");
323        assert_eq!(dex_venue_to_chain("Solana"), "solana");
324        assert_eq!(dex_venue_to_chain("unknown"), "ethereum"); // default
325    }
326}