1use crate::AppState;
7use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
8use axum::extract::{Query, State};
9use axum::response::IntoResponse;
10use scope::chains::DexDataSource;
11use scope::chains::dex::DexTokenData;
12use scope::market::{ExchangeClient, Pair, VenueRegistry};
13use serde::Deserialize;
14use std::sync::Arc;
15use std::time::Duration;
16
17#[derive(Debug, Deserialize)]
19pub struct MonitorQuery {
20 pub token: String,
22 #[serde(default = "crate::api::default_chain")]
24 pub chain: String,
25 #[serde(default = "default_refresh")]
27 pub refresh: u64,
28 #[serde(default)]
31 pub venue: Option<String>,
32 #[serde(default)]
35 pub pair: Option<String>,
36}
37
38fn default_refresh() -> u64 {
39 5
40}
41
42fn exchange_client_for(venue: Option<&str>) -> scope::error::Result<Option<ExchangeClient>> {
44 let Some(id) = venue else {
45 return Ok(None);
46 };
47 VenueRegistry::load()?.create_exchange_client(id).map(Some)
48}
49
50pub async fn ws_handler(
52 ws: WebSocketUpgrade,
53 State(state): State<Arc<AppState>>,
54 Query(params): Query<MonitorQuery>,
55) -> impl IntoResponse {
56 ws.on_upgrade(move |socket| handle_socket(socket, state, params))
57}
58
59async fn handle_socket(mut socket: WebSocket, state: Arc<AppState>, params: MonitorQuery) {
64 let dex_client: Box<dyn DexDataSource> = state.factory.create_dex_client();
65 let refresh = Duration::from_secs(params.refresh.max(1));
66
67 let token_input = params.token.clone();
69 let chain = params.chain.clone();
70
71 let exchange_client: Option<ExchangeClient> = match exchange_client_for(params.venue.as_deref())
75 {
76 Ok(client) => client,
77 Err(e) => {
78 let msg = serde_json::json!({"type": "error", "message": e.to_string()});
79 let _ = socket.send(Message::Text(msg.to_string())).await;
80 return;
81 }
82 };
83
84 let exchange_pair: Option<String> = exchange_client.as_ref().map(|ec| {
85 let pair = match params.pair.as_deref() {
86 Some(pair) => Pair::parse(pair),
87 None => Pair::base_only(&token_input),
88 };
89 ec.venue_symbol(&pair)
90 });
91
92 let init_msg = serde_json::json!({
94 "type": "connected",
95 "token": token_input,
96 "chain": chain,
97 "refresh_secs": params.refresh,
98 "venue": params.venue,
99 "exchange_pair": exchange_pair,
100 });
101 if socket
102 .send(Message::Text(init_msg.to_string()))
103 .await
104 .is_err()
105 {
106 return;
107 }
108
109 loop {
110 let data: scope::error::Result<DexTokenData> =
112 dex_client.get_token_data(&chain, &token_input).await;
113
114 let exchange_snapshot = if let (Some(ec), Some(pair)) = (&exchange_client, &exchange_pair) {
116 Some(
117 ec.fetch_market_snapshot(pair, scope::market::exchange::DEFAULT_SNAPSHOT_TRADES)
118 .await,
119 )
120 } else {
121 None
122 };
123
124 let msg = match data {
125 Ok(token_data) => {
126 let mut frame = serde_json::json!({
127 "type": "update",
128 "timestamp": chrono::Utc::now().to_rfc3339(),
129 "token": {
130 "symbol": token_data.symbol,
131 "name": token_data.name,
132 "address": token_data.address,
133 },
134 "price_usd": token_data.price_usd,
135 "price_change_24h": token_data.price_change_24h,
136 "price_change_6h": token_data.price_change_6h,
137 "price_change_1h": token_data.price_change_1h,
138 "volume_24h": token_data.volume_24h,
139 "volume_6h": token_data.volume_6h,
140 "volume_1h": token_data.volume_1h,
141 "liquidity_usd": token_data.liquidity_usd,
142 "market_cap": token_data.market_cap,
143 "buys_24h": token_data.total_buys_24h,
144 "sells_24h": token_data.total_sells_24h,
145 "buys_1h": token_data.total_buys_1h,
146 "sells_1h": token_data.total_sells_1h,
147 "pairs": token_data.pairs.iter().take(5).map(|p| {
148 serde_json::json!({
149 "dex": p.dex_name,
150 "base": p.base_token,
151 "quote": p.quote_token,
152 "price_usd": p.price_usd,
153 "volume_24h": p.volume_24h,
154 "liquidity_usd": p.liquidity_usd,
155 })
156 }).collect::<Vec<_>>(),
157 });
158
159 if let Some(snap) = &exchange_snapshot {
161 attach_exchange_data(&mut frame, snap);
162 }
163
164 frame
165 }
166 Err(e) => {
167 let mut frame = serde_json::json!({
168 "type": "error",
169 "message": e.to_string(),
170 });
171 if let Some(snap) = &exchange_snapshot {
173 attach_exchange_data(&mut frame, snap);
174 }
175 frame
176 }
177 };
178
179 if socket.send(Message::Text(msg.to_string())).await.is_err() {
180 break;
182 }
183
184 tokio::select! {
186 _ = tokio::time::sleep(refresh) => {},
187 msg = socket.recv() => {
188 match msg {
189 Some(Ok(Message::Close(_))) | None => break,
190 Some(Ok(Message::Text(text))) => {
191 if let Ok(cmd) = serde_json::from_str::<serde_json::Value>(&text)
193 && cmd.get("type").and_then(|t| t.as_str()) == Some("ping")
194 {
195 let pong = serde_json::json!({"type": "pong"});
196 let _ = socket.send(Message::Text(pong.to_string())).await;
197 }
198 }
199 _ => {}
200 }
201 }
202 }
203 }
204}
205
206const FRAME_ITEMS: usize = 20;
208
209fn attach_exchange_data(frame: &mut serde_json::Value, snap: &scope::market::MarketSnapshot) {
213 if let Some(book) = &snap.order_book {
214 frame["exchange_order_book"] = book.to_json(Some(FRAME_ITEMS));
215 }
216 if let Some(ticker) = &snap.ticker {
217 frame["exchange_ticker"] = serde_json::json!(ticker);
218 }
219 if let Some(trades) = &snap.recent_trades {
220 let recent: Vec<_> = trades.iter().take(FRAME_ITEMS).collect();
221 frame["exchange_trades"] = serde_json::json!(recent);
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use scope::market::{MarketSnapshot, OrderBook, OrderBookLevel, Ticker, Trade, TradeSide};
229
230 #[test]
231 fn test_default_chain() {
232 assert_eq!(crate::api::default_chain(), "ethereum");
233 }
234
235 #[test]
236 fn test_default_refresh() {
237 assert_eq!(default_refresh(), 5);
238 }
239
240 #[test]
243 fn test_exchange_client_for_unknown_venue_is_a_request_fault() {
244 let err = exchange_client_for(Some("nonexistent_venue_xyz"))
245 .err()
246 .unwrap();
247 assert!(
248 matches!(err, scope::error::ScopeError::InvalidInput(_)),
249 "{err:?}"
250 );
251 assert!(err.to_string().contains("Unknown venue"), "{err}");
252 }
253
254 #[test]
255 fn test_exchange_client_for_none_and_known_venue() {
256 assert!(exchange_client_for(None).unwrap().is_none());
257 assert!(exchange_client_for(Some("binance")).unwrap().is_some());
258 }
259
260 #[test]
261 fn test_deserialize_monitor_query_full() {
262 let json = serde_json::json!({
263 "token": "USDC",
264 "chain": "solana",
265 "refresh": 10
266 });
267 let query: MonitorQuery = serde_json::from_value(json).unwrap();
268 assert_eq!(query.token, "USDC");
269 assert_eq!(query.chain, "solana");
270 assert_eq!(query.refresh, 10);
271 assert!(query.venue.is_none());
272 }
273
274 #[test]
275 fn test_deserialize_monitor_query_minimal() {
276 let json = serde_json::json!({
277 "token": "ETH"
278 });
279 let query: MonitorQuery = serde_json::from_value(json).unwrap();
280 assert_eq!(query.token, "ETH");
281 assert_eq!(query.chain, "ethereum");
282 assert_eq!(query.refresh, 5);
283 assert!(query.venue.is_none());
284 assert!(query.pair.is_none());
285 }
286
287 #[test]
288 fn test_deserialize_monitor_query_with_venue() {
289 let json = serde_json::json!({
290 "token": "BTC",
291 "venue": "binance",
292 "pair": "USDC",
293 "refresh": 10
294 });
295 let query: MonitorQuery = serde_json::from_value(json).unwrap();
296 assert_eq!(query.token, "BTC");
297 assert_eq!(query.venue.as_deref(), Some("binance"));
298 assert_eq!(query.pair.as_deref(), Some("USDC"));
299 }
300
301 #[test]
302 fn test_attach_exchange_data_full() {
303 let snapshot = MarketSnapshot {
304 order_book: Some(OrderBook {
305 pair: "BTC/USDT".to_string(),
306 source: scope::market::BookSource::Exchange,
307 bids: vec![OrderBookLevel {
308 price: 50000.0,
309 quantity: 1.5,
310 }],
311 asks: vec![OrderBookLevel {
312 price: 50010.0,
313 quantity: 2.0,
314 }],
315 }),
316 ticker: Some(Ticker {
317 pair: "BTC/USDT".to_string(),
318 last_price: Some(50005.0),
319 high_24h: Some(51000.0),
320 low_24h: Some(49000.0),
321 volume_24h: Some(1_000_000.0),
322 quote_volume_24h: Some(50_000_000.0),
323 price_change_pct_24h: None,
324 best_bid: Some(50000.0),
325 best_ask: Some(50010.0),
326 }),
327 recent_trades: Some(vec![Trade {
328 price: 50005.0,
329 quantity: 0.5,
330 quote_quantity: Some(25002.5),
331 timestamp_ms: 1700000000000,
332 side: TradeSide::Buy,
333 id: None,
334 }]),
335 };
336
337 let mut frame = serde_json::json!({"type": "update"});
338 attach_exchange_data(&mut frame, &snapshot);
339
340 assert!(frame.get("exchange_order_book").is_some());
341 assert!(frame.get("exchange_ticker").is_some());
342 assert!(frame.get("exchange_trades").is_some());
343 assert_eq!(
344 frame["exchange_ticker"]["last_price"].as_f64().unwrap(),
345 50005.0
346 );
347 }
348
349 #[test]
350 fn test_attach_exchange_data_empty() {
351 let snapshot = MarketSnapshot {
352 order_book: None,
353 ticker: None,
354 recent_trades: None,
355 };
356
357 let mut frame = serde_json::json!({"type": "update"});
358 attach_exchange_data(&mut frame, &snapshot);
359
360 assert!(frame.get("exchange_order_book").is_none());
361 assert!(frame.get("exchange_ticker").is_none());
362 assert!(frame.get("exchange_trades").is_none());
363 }
364
365 #[test]
366 fn test_attach_exchange_data_spread_matches_the_market_summary() {
367 use scope::market::{
368 BookSource, HealthThresholds, MarketSummary, OrderBook, OrderBookLevel,
369 };
370 let level = |price, quantity| OrderBookLevel { price, quantity };
371 let book = OrderBook {
373 pair: "X/USDT".to_string(),
374 source: BookSource::Exchange,
375 bids: vec![level(1.0, 0.0), level(0.999, 500.0)],
376 asks: vec![level(1.0001, 0.0), level(1.001, 500.0)],
377 };
378 let summary = MarketSummary::from_order_book(&book, &HealthThresholds::default(), None);
379 let snapshot = scope::market::MarketSnapshot {
380 order_book: Some(book),
381 ticker: None,
382 recent_trades: None,
383 };
384 let mut frame = serde_json::json!({"type": "update"});
385 attach_exchange_data(&mut frame, &snapshot);
386 assert_eq!(
387 frame["exchange_order_book"]["spread"].as_f64(),
388 summary.spread
389 );
390 assert_eq!(
391 frame["exchange_order_book"]["mid_price"].as_f64(),
392 summary.mid_price
393 );
394 }
395
396 #[test]
397 fn test_attach_exchange_data_with_sell_trade() {
398 let snapshot = MarketSnapshot {
399 order_book: None,
400 ticker: None,
401 recent_trades: Some(vec![Trade {
402 price: 50005.0,
403 quantity: 0.5,
404 quote_quantity: Some(25002.5),
405 timestamp_ms: 1700000000000,
406 side: TradeSide::Sell,
407 id: None,
408 }]),
409 };
410
411 let mut frame = serde_json::json!({"type": "update"});
412 attach_exchange_data(&mut frame, &snapshot);
413
414 assert!(frame.get("exchange_trades").is_some());
415 let trades = frame["exchange_trades"].as_array().unwrap();
416 assert_eq!(trades.len(), 1);
417 assert_eq!(trades[0]["side"], "sell");
418 }
419
420 #[test]
421 fn test_attach_exchange_data_order_book_only() {
422 let snapshot = MarketSnapshot {
423 order_book: Some(OrderBook {
424 pair: "ETH/USDT".to_string(),
425 source: scope::market::BookSource::Exchange,
426 bids: vec![OrderBookLevel {
427 price: 2000.0,
428 quantity: 2.0,
429 }],
430 asks: vec![OrderBookLevel {
431 price: 2005.0,
432 quantity: 1.5,
433 }],
434 }),
435 ticker: None,
436 recent_trades: None,
437 };
438
439 let mut frame = serde_json::json!({"type": "update"});
440 attach_exchange_data(&mut frame, &snapshot);
441
442 assert!(frame.get("exchange_order_book").is_some());
443 assert_eq!(frame["exchange_order_book"]["pair"], "ETH/USDT");
444 assert_eq!(frame["exchange_order_book"]["best_bid"], 2000.0);
445 assert_eq!(frame["exchange_order_book"]["best_ask"], 2005.0);
446 assert!(frame.get("exchange_ticker").is_none());
447 assert!(frame.get("exchange_trades").is_none());
448 }
449
450 #[test]
451 fn test_attach_exchange_data_ticker_only() {
452 let snapshot = MarketSnapshot {
453 order_book: None,
454 ticker: Some(Ticker {
455 pair: "SOL/USDT".to_string(),
456 last_price: Some(100.5),
457 high_24h: Some(102.0),
458 low_24h: Some(99.0),
459 volume_24h: Some(50000.0),
460 quote_volume_24h: Some(5_000_000.0),
461 price_change_pct_24h: None,
462 best_bid: Some(100.0),
463 best_ask: Some(101.0),
464 }),
465 recent_trades: None,
466 };
467
468 let mut frame = serde_json::json!({"type": "update"});
469 attach_exchange_data(&mut frame, &snapshot);
470
471 assert!(frame.get("exchange_ticker").is_some());
472 assert_eq!(frame["exchange_ticker"]["pair"], "SOL/USDT");
473 assert_eq!(frame["exchange_ticker"]["last_price"], 100.5);
474 assert!(frame.get("exchange_order_book").is_none());
475 }
476}