scope/web/api/
token_health.rs1use 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 )
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 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 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 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 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
164fn is_dex_venue(venue: &str) -> bool {
166 matches!(venue.to_lowercase().as_str(), "ethereum" | "eth" | "solana")
167}
168
169fn 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}