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    )
50    .await
51    {
52        Ok(a) => a,
53        Err(e) => {
54            return (
55                StatusCode::INTERNAL_SERVER_ERROR,
56                Json(serde_json::json!({ "error": e.to_string() })),
57            )
58                .into_response();
59        }
60    };
61
62    // Optionally fetch market data
63    let venue_id = &req.market_venue;
64    let market_summary = if req.with_market {
65        let thresholds = HealthThresholds {
66            peg_target: 1.0,
67            peg_range: 0.001,
68            min_levels: 6,
69            min_depth: 3000.0,
70            min_bid_ask_ratio: 0.2,
71            max_bid_ask_ratio: 5.0,
72        };
73
74        if !is_dex_venue(venue_id) {
75            // CEX venue — use venue registry
76            let summary = if let Ok(registry) = VenueRegistry::load() {
77                if let Ok(exchange) = registry.create_exchange_client(venue_id) {
78                    let pair = exchange.format_pair(&analytics.token.symbol);
79                    match exchange.fetch_order_book(&pair).await {
80                        Ok(book) => {
81                            let volume_24h = if exchange.has_ticker() {
82                                exchange
83                                    .fetch_ticker(&pair)
84                                    .await
85                                    .ok()
86                                    .and_then(|t| t.quote_volume_24h.or(t.volume_24h))
87                            } else {
88                                None
89                            };
90                            Some(MarketSummary::from_order_book(
91                                &book,
92                                1.0,
93                                &thresholds,
94                                volume_24h,
95                            ))
96                        }
97                        Err(_) => None,
98                    }
99                } else {
100                    None
101                }
102            } else {
103                None
104            };
105            summary
106        } else {
107            // DEX venue
108            let venue_chain = dex_venue_to_chain(venue_id);
109            if analytics.chain.eq_ignore_ascii_case(venue_chain) && !analytics.dex_pairs.is_empty()
110            {
111                let best_pair = analytics
112                    .dex_pairs
113                    .iter()
114                    .max_by(|a, b| {
115                        a.liquidity_usd
116                            .partial_cmp(&b.liquidity_usd)
117                            .unwrap_or(std::cmp::Ordering::Equal)
118                    })
119                    .unwrap();
120                let book =
121                    order_book_from_analytics(&analytics.chain, best_pair, &analytics.token.symbol);
122                let volume_24h = Some(best_pair.volume_24h);
123                Some(MarketSummary::from_order_book(
124                    &book,
125                    1.0,
126                    &thresholds,
127                    volume_24h,
128                ))
129            } else {
130                None
131            }
132        }
133    } else {
134        None
135    };
136
137    // Build combined JSON
138    let market_json = market_summary.map(|m| {
139        serde_json::json!({
140            "pair": m.pair,
141            "peg_target": m.peg_target,
142            "best_bid": m.best_bid,
143            "best_ask": m.best_ask,
144            "mid_price": m.mid_price,
145            "spread": m.spread,
146            "bid_depth": m.bid_depth,
147            "ask_depth": m.ask_depth,
148            "healthy": m.healthy,
149            "volume_24h": m.volume_24h,
150            "checks": m.checks.iter().map(|c| match c {
151                crate::market::HealthCheck::Pass(msg) => serde_json::json!({"status": "pass", "message": msg}),
152                crate::market::HealthCheck::Fail(msg) => serde_json::json!({"status": "fail", "message": msg}),
153            }).collect::<Vec<_>>()
154        })
155    });
156
157    Json(serde_json::json!({
158        "analytics": analytics,
159        "market": market_json,
160    }))
161    .into_response()
162}
163
164/// Whether the venue string refers to a DEX venue.
165fn is_dex_venue(venue: &str) -> bool {
166    matches!(venue.to_lowercase().as_str(), "ethereum" | "eth" | "solana")
167}
168
169/// Resolve DEX venue name to a canonical chain name.
170fn dex_venue_to_chain(venue: &str) -> &str {
171    match venue.to_lowercase().as_str() {
172        "ethereum" | "eth" => "ethereum",
173        "solana" => "solana",
174        _ => "ethereum",
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn test_deserialize_full() {
184        let json = serde_json::json!({
185            "token": "USDC",
186            "chain": "polygon",
187            "with_market": true,
188            "market_venue": "biconomy"
189        });
190        let req: TokenHealthRequest = serde_json::from_value(json).unwrap();
191        assert_eq!(req.token, "USDC");
192        assert_eq!(req.chain, "polygon");
193        assert!(req.with_market);
194        assert_eq!(req.market_venue, "biconomy");
195    }
196
197    #[test]
198    fn test_deserialize_minimal() {
199        let json = serde_json::json!({
200            "token": "USDC"
201        });
202        let req: TokenHealthRequest = serde_json::from_value(json).unwrap();
203        assert_eq!(req.token, "USDC");
204        assert_eq!(req.chain, "ethereum");
205        assert!(!req.with_market);
206        assert_eq!(req.market_venue, "binance");
207    }
208
209    #[test]
210    fn test_defaults() {
211        assert_eq!(default_chain(), "ethereum");
212        assert_eq!(default_venue(), "binance");
213    }
214
215    #[test]
216    fn test_with_market_flag() {
217        let json = serde_json::json!({
218            "token": "USDC",
219            "with_market": true
220        });
221        let req: TokenHealthRequest = serde_json::from_value(json).unwrap();
222        assert!(req.with_market);
223
224        let json_false = serde_json::json!({
225            "token": "USDC",
226            "with_market": false
227        });
228        let req_false: TokenHealthRequest = serde_json::from_value(json_false).unwrap();
229        assert!(!req_false.with_market);
230    }
231
232    #[tokio::test]
233    async fn test_handle_token_health_direct() {
234        use crate::chains::DefaultClientFactory;
235        use crate::config::Config;
236        use crate::web::AppState;
237        use axum::extract::State;
238        use axum::response::IntoResponse;
239
240        let config = Config::default();
241        let factory = DefaultClientFactory {
242            chains_config: config.chains.clone(),
243        };
244        let state = std::sync::Arc::new(AppState { config, factory });
245        let req = TokenHealthRequest {
246            token: "USDC".to_string(),
247            chain: "ethereum".to_string(),
248            with_market: false,
249            market_venue: "binance".to_string(),
250        };
251        let response = handle(State(state), axum::Json(req)).await.into_response();
252        let status = response.status();
253        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
254    }
255
256    #[tokio::test]
257    async fn test_handle_token_health_with_market() {
258        use crate::chains::DefaultClientFactory;
259        use crate::config::Config;
260        use crate::web::AppState;
261        use axum::extract::State;
262        use axum::response::IntoResponse;
263
264        let config = Config::default();
265        let factory = DefaultClientFactory {
266            chains_config: config.chains.clone(),
267        };
268        let state = std::sync::Arc::new(AppState { config, factory });
269        let req = TokenHealthRequest {
270            token: "USDC".to_string(),
271            chain: "ethereum".to_string(),
272            with_market: true,
273            market_venue: "eth".to_string(),
274        };
275        let response = handle(State(state), axum::Json(req)).await.into_response();
276        let status = response.status();
277        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
278    }
279}