Skip to main content

scope_web/
monitor.rs

1//! WebSocket monitor handler for live token data streaming.
2//!
3//! Streams real-time token price, volume, and transaction data
4//! to connected browser clients via WebSocket.
5
6use 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/// Query parameters for the WebSocket monitor connection.
18#[derive(Debug, Deserialize)]
19pub struct MonitorQuery {
20    /// Token address or symbol to monitor.
21    pub token: String,
22    /// Chain (default: "ethereum").
23    #[serde(default = "crate::api::default_chain")]
24    pub chain: String,
25    /// Refresh interval in seconds (default: 5).
26    #[serde(default = "default_refresh")]
27    pub refresh: u64,
28    /// Optional exchange venue ID (e.g., "binance") — when set, exchange data
29    /// (order book, ticker, recent trades) is included in each update frame.
30    #[serde(default)]
31    pub venue: Option<String>,
32    /// Pair for exchange data, read like the CLI pair (default: the token
33    /// symbol with the venue's default quote).
34    #[serde(default)]
35    pub pair: Option<String>,
36}
37
38fn default_refresh() -> u64 {
39    5
40}
41
42/// The venue's exchange client, or the one unknown-venue error.
43fn 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
50/// WS /ws/monitor — WebSocket endpoint for live token monitoring.
51pub 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
59/// Handles an individual WebSocket connection.
60///
61/// Polls DexScreener at the configured interval and sends JSON updates
62/// containing price, volume, and activity data.
63async 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    // Resolve token to address if needed
68    let token_input = params.token.clone();
69    let chain = params.chain.clone();
70
71    // Optionally create an exchange client for CEX data. An unknown venue is
72    // the one unknown-venue error, reported on the socket (same frame shape
73    // as a DexTokenData error), not silently dropped.
74    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    // Send initial connection message
93    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        // Fetch latest DEX token data
111        let data: scope::error::Result<DexTokenData> =
112            dex_client.get_token_data(&chain, &token_input).await;
113
114        // Optionally fetch exchange snapshot in parallel
115        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                // Attach exchange data if available
160                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                // Still attach exchange data even on DEX error
172                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            // Client disconnected
181            break;
182        }
183
184        // Wait for next refresh or client message
185        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                        // Handle client commands (e.g., change token)
192                        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
206/// Levels and trades a monitor frame lists, at most.
207const FRAME_ITEMS: usize = 20;
208
209/// Attach exchange snapshot data (order book, ticker, trades) to a JSON
210/// frame, in the one JSON form of each (`OrderBook::to_json`, `Ticker` and
211/// `Trade` serialization).
212fn 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    /// An unknown venue used to be silently dropped (no exchange data, no
241    /// word); now the one unknown-venue error reaches the client.
242    #[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        // Zero-quantity top levels are not real orders.
372        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}