1use 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#[derive(Debug, Deserialize)]
15pub struct TokenHealthRequest {
16 pub token: String,
18 #[serde(default = "default_chain")]
20 pub chain: String,
21 #[serde(default)]
23 pub with_market: bool,
24 #[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
37pub async fn handle(
39 State(state): State<Arc<AppState>>,
40 Json(req): Json<TokenHealthRequest>,
41) -> impl IntoResponse {
42 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 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 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 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 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
162fn is_dex_venue(venue: &str) -> bool {
164 matches!(venue.to_lowercase().as_str(), "ethereum" | "eth" | "solana")
165}
166
167fn 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(), };
297 let response = handle(State(state), axum::Json(req)).await.into_response();
298 let status = response.status();
299 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"); }
326}