Skip to main content

indodax_cli/commands/
alert.rs

1use crate::client::IndodaxClient;
2use crate::commands::helpers;
3use crate::output::CommandOutput;
4use anyhow::Result;
5use colored::*;
6use futures_util::{SinkExt, StreamExt};
7use serde::{Deserialize, Serialize};
8use std::fs;
9use std::path::PathBuf;
10use tokio_tungstenite::{connect_async, tungstenite::Message};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct PriceAlert {
14    pub id: u64,
15    pub pair: String,
16    pub condition: AlertCondition,
17    pub created_at: u64,
18    pub triggered_at: Option<u64>,
19    pub status: AlertStatus,
20    pub note: Option<String>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(tag = "type")]
25pub enum AlertCondition {
26    #[serde(rename = "above")]
27    Above { price: f64 },
28    #[serde(rename = "below")]
29    Below { price: f64 },
30    #[serde(rename = "change_up")]
31    ChangeUp { percent: f64, from_price: f64 },
32    #[serde(rename = "change_down")]
33    ChangeDown { percent: f64, from_price: f64 },
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
37#[serde(rename_all = "lowercase")]
38pub enum AlertStatus {
39    Active,
40    Triggered,
41    Cancelled,
42}
43
44#[derive(Debug, clap::Subcommand)]
45pub enum AlertCommand {
46    #[command(name = "add", about = "Add a price alert")]
47    Add {
48        #[arg(short = 'p', long, help = "Trading pair (e.g. btc_idr)")]
49        pair: String,
50        #[arg(long, help = "Alert when price goes above this value")]
51        above: Option<f64>,
52        #[arg(long, help = "Alert when price goes below this value")]
53        below: Option<f64>,
54        #[arg(long, help = "Alert when price increases by this percent")]
55        percent_up: Option<f64>,
56        #[arg(long, help = "Alert when price decreases by this percent")]
57        percent_down: Option<f64>,
58        #[arg(short = 'n', long, help = "Note for this alert")]
59        note: Option<String>,
60    },
61
62    #[command(name = "list", about = "List all price alerts")]
63    List {
64        #[arg(long, help = "Include triggered and cancelled alerts")]
65        history: bool,
66    },
67
68    #[command(name = "cancel", about = "Cancel a price alert")]
69    Cancel {
70        #[arg(short = 'i', long, help = "Alert ID to cancel")]
71        id: Option<u64>,
72        #[arg(long, help = "Cancel all alerts")]
73        all: bool,
74    },
75
76    #[command(name = "check", about = "Check alerts against current prices")]
77    Check {
78        #[arg(short = 'i', long, help = "Check specific alert by ID")]
79        id: Option<u64>,
80        #[arg(short = 'p', long, help = "Filter by pair (e.g. btc_idr)")]
81        pair: Option<String>,
82    },
83
84    #[command(name = "watch", about = "Monitor alerts in real-time via WebSocket")]
85    Watch {
86        #[arg(short = 'i', long, help = "Filter by alert ID")]
87        id: Option<u64>,
88        #[arg(short = 'p', long, help = "Filter by pair (e.g. btc_idr)")]
89        pair: Option<String>,
90        #[arg(long, default_value = "60", help = "Price change threshold (%) to trigger")]
91        threshold: f64,
92    },
93
94    #[command(name = "triggered", about = "Show triggered alerts")]
95    Triggered,
96}
97
98pub async fn execute(
99    client: &IndodaxClient,
100    _creds: &Option<crate::config::ResolvedCredentials>,
101    cmd: &AlertCommand,
102) -> Result<CommandOutput> {
103    match cmd {
104        AlertCommand::Add { pair, above, below, percent_up, percent_down, note } => {
105            let pair = helpers::normalize_pair(pair);
106            alert_add(&pair, *above, *below, *percent_up, *percent_down, note.clone(), client).await
107        }
108        AlertCommand::List { history } => alert_list(*history),
109        AlertCommand::Cancel { id, all } => alert_cancel(*id, *all),
110        AlertCommand::Check { id, pair } => {
111            let pair = pair.as_ref().map(|p| helpers::normalize_pair(p));
112            alert_check(client, *id, pair.as_deref()).await
113        }
114        AlertCommand::Watch { id, pair, threshold } => {
115            let pair = pair.as_ref().map(|p| helpers::normalize_pair(p));
116            alert_watch(client, *id, pair.as_deref(), *threshold).await
117        }
118        AlertCommand::Triggered => alert_triggered(),
119    }
120}
121
122pub fn alerts_path() -> PathBuf {
123    let config_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
124    config_dir.join("indodax").join("alerts.json")
125}
126
127fn ensure_alerts_dir() -> std::io::Result<()> {
128    let config_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
129    fs::create_dir_all(config_dir.join("indodax"))
130}
131
132fn load_alerts() -> Vec<PriceAlert> {
133    let path = alerts_path();
134    if path.exists() {
135        match fs::read_to_string(&path) {
136            Ok(content) => match serde_json::from_str(&content) {
137                Ok(alerts) => alerts,
138                Err(e) => {
139                    eprintln!("[ALERT] Warning: Corrupt alerts file ({}), attempting backup...", e);
140                    let backup_path = path.with_extension("json.bak");
141                    if let Err(copy_err) = fs::copy(&path, &backup_path) {
142                        eprintln!("[ALERT] Warning: Could not backup corrupt file: {}", copy_err);
143                    } else {
144                        eprintln!("[ALERT] Backed up corrupt file to {:?}. Starting fresh.", backup_path);
145                    }
146                    Vec::new()
147                }
148            },
149            Err(e) => {
150                eprintln!("[ALERT] Warning: Failed to read alerts file: {}. Starting fresh.", e);
151                Vec::new()
152            }
153        }
154    } else {
155        Vec::new()
156    }
157}
158
159fn save_alerts(alerts: &[PriceAlert]) -> Result<()> {
160    ensure_alerts_dir()?;
161    let path = alerts_path();
162    let content = serde_json::to_string_pretty(alerts)?;
163    #[cfg(unix)]
164    {
165        use std::io::Write;
166        use std::os::unix::fs::OpenOptionsExt;
167        let mut file = std::fs::OpenOptions::new()
168            .write(true)
169            .create(true)
170            .truncate(true)
171            .mode(0o600)
172            .open(&path)?;
173        file.write_all(content.as_bytes())?;
174    }
175    #[cfg(not(unix))]
176    {
177        fs::write(&path, content)?;
178    }
179    Ok(())
180}
181
182fn get_next_id(alerts: &[PriceAlert]) -> u64 {
183    alerts.iter().map(|a| a.id).max().unwrap_or(0) + 1
184}
185
186async fn alert_add(
187    pair: &str,
188    above: Option<f64>,
189    below: Option<f64>,
190    percent_up: Option<f64>,
191    percent_down: Option<f64>,
192    note: Option<String>,
193    client: &IndodaxClient,
194) -> Result<CommandOutput> {
195    let condition = if let Some(price) = above {
196        if price <= 0.0 {
197            return Err(anyhow::anyhow!("Price must be positive, got {}", price));
198        }
199        AlertCondition::Above { price }
200    } else if let Some(price) = below {
201        if price <= 0.0 {
202            return Err(anyhow::anyhow!("Price must be positive, got {}", price));
203        }
204        AlertCondition::Below { price }
205    } else if let Some(percent) = percent_up {
206        if percent <= 0.0 || percent > 1000.0 {
207            return Err(anyhow::anyhow!("Percent must be between 0 and 1000, got {}", percent));
208        }
209        let from_price = fetch_price(client, pair).await?;
210        AlertCondition::ChangeUp { percent, from_price }
211    } else if let Some(percent) = percent_down {
212        if percent <= 0.0 || percent > 1000.0 {
213            return Err(anyhow::anyhow!("Percent must be between 0 and 1000, got {}", percent));
214        }
215        let from_price = fetch_price(client, pair).await?;
216        AlertCondition::ChangeDown { percent, from_price }
217    } else {
218        return Err(anyhow::anyhow!(
219            "Must specify one of: --above, --below, --percent-up, or --percent-down"
220        ));
221    };
222
223    let mut alerts = load_alerts();
224    let id = get_next_id(&alerts);
225    let alert = PriceAlert {
226        id,
227        pair: pair.to_string(),
228        condition,
229        created_at: helpers::now_millis(),
230        triggered_at: None,
231        status: AlertStatus::Active,
232        note,
233    };
234
235    alerts.push(alert.clone());
236    save_alerts(&alerts)?;
237
238    let condition_str = match &alert.condition {
239        AlertCondition::Above { price } => format!("above {}", format_number(*price)),
240        AlertCondition::Below { price } => format!("below {}", format_number(*price)),
241        AlertCondition::ChangeUp { percent, from_price } => format!("+{:.1}% from {}", percent, format_number(*from_price)),
242        AlertCondition::ChangeDown { percent, from_price } => format!("-{:.1}% from {}", percent, format_number(*from_price)),
243    };
244
245    let data = serde_json::json!({
246        "status": "ok",
247        "id": id,
248        "pair": pair,
249        "condition": condition_str,
250        "created_at": alert.created_at,
251    });
252
253    let headers = vec!["Field".into(), "Value".into()];
254    let rows = vec![
255        vec!["Alert ID".into(), id.to_string()],
256        vec!["Pair".into(), pair.to_string()],
257        vec!["Condition".into(), condition_str.clone()],
258        vec!["Created".into(), chrono::DateTime::from_timestamp_millis(alert.created_at.min(i64::MAX as u64) as i64)
259            .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
260            .unwrap_or_default()],
261    ];
262
263    Ok(CommandOutput::new(data, headers, rows)
264        .with_addendum(format!("[ALERT] Created {} alert for {} @ {}", id, pair, condition_str)))
265}
266
267fn alert_list(include_history: bool) -> Result<CommandOutput> {
268    let alerts = load_alerts();
269
270    let filtered: Vec<&PriceAlert> = if include_history {
271        alerts.iter().collect()
272    } else {
273        alerts.iter().filter(|a| a.status == AlertStatus::Active).collect()
274    };
275
276    if filtered.is_empty() {
277        return Ok(CommandOutput::json(serde_json::json!({
278            "status": "ok",
279            "message": if include_history { "No alerts" } else { "No active alerts" },
280            "alerts": [],
281        })));
282    }
283
284    let mut headers = vec!["ID".into(), "Pair".into(), "Condition".into(), "Status".into(), "Created".into()];
285    if include_history {
286        headers.push("Triggered".into());
287    }
288
289    let mut rows: Vec<Vec<String>> = Vec::new();
290    for alert in &filtered {
291        let condition_str = match &alert.condition {
292            AlertCondition::Above { price } => format!("> {}", format_number(*price)),
293            AlertCondition::Below { price } => format!("< {}", format_number(*price)),
294            AlertCondition::ChangeUp { percent, from_price } => format!("+{:.1}% from {}", percent, format_number(*from_price)),
295            AlertCondition::ChangeDown { percent, from_price } => format!("-{:.1}% from {}", percent, format_number(*from_price)),
296        };
297
298        let mut row = vec![
299            alert.id.to_string(),
300            alert.pair.clone(),
301            condition_str.clone(),
302            format!("{:?}", alert.status),
303            chrono::DateTime::from_timestamp_millis(alert.created_at.min(i64::MAX as u64) as i64)
304                .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
305                .unwrap_or_default(),
306        ];
307
308        if include_history {
309            let triggered = alert.triggered_at.map(|t| {
310                chrono::DateTime::from_timestamp_millis(t.min(i64::MAX as u64) as i64)
311                    .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
312                    .unwrap_or_default()
313            }).unwrap_or_else(|| "-".to_string());
314            row.push(triggered);
315        }
316
317        rows.push(row);
318    }
319
320    let data = serde_json::json!({
321        "status": "ok",
322        "count": filtered.len(),
323    });
324
325    Ok(CommandOutput::new(data, headers, rows)
326        .with_addendum(format!("[ALERT] {} alert(s)", filtered.len())))
327}
328
329fn alert_cancel(id: Option<u64>, cancel_all: bool) -> Result<CommandOutput> {
330    let mut alerts = load_alerts();
331
332    if cancel_all {
333        let count = alerts.iter().filter(|a| a.status == AlertStatus::Active).count();
334        for alert in alerts.iter_mut() {
335            if alert.status == AlertStatus::Active {
336                alert.status = AlertStatus::Cancelled;
337            }
338        }
339        save_alerts(&alerts)?;
340
341        return Ok(CommandOutput::json(serde_json::json!({
342            "status": "ok",
343            "message": format!("Cancelled {} alert(s)", count),
344            "cancelled": count,
345        })).with_addendum(format!("[ALERT] Cancelled {} alert(s)", count)));
346    }
347
348    if let Some(target_id) = id {
349        let alert = alerts.iter_mut().find(|a| a.id == target_id);
350        match alert {
351            Some(a) if a.status == AlertStatus::Active => {
352                a.status = AlertStatus::Cancelled;
353                save_alerts(&alerts)?;
354
355                Ok(CommandOutput::json(serde_json::json!({
356                    "status": "ok",
357                    "message": format!("Cancelled alert {}", target_id),
358                    "id": target_id,
359                })).with_addendum(format!("[ALERT] Cancelled alert {}", target_id)))
360            }
361            Some(_) => Err(anyhow::anyhow!("Alert {} is already cancelled or triggered", target_id)),
362            None => Err(anyhow::anyhow!("Alert {} not found", target_id)),
363        }
364    } else {
365        Err(anyhow::anyhow!("Must specify --id or --all"))
366    }
367}
368
369async fn alert_check(
370    client: &IndodaxClient,
371    id: Option<u64>,
372    pair_filter: Option<&str>,
373) -> Result<CommandOutput> {
374    let mut alerts = load_alerts();
375
376    let to_check: Vec<&mut PriceAlert> = if let Some(target_id) = id {
377        alerts.iter_mut().filter(|a| a.id == target_id && a.status == AlertStatus::Active).collect()
378    } else {
379        let filter = pair_filter.unwrap_or("*");
380        alerts.iter_mut()
381            .filter(|a| a.status == AlertStatus::Active && (filter == "*" || a.pair == filter))
382            .collect()
383    };
384
385    if to_check.is_empty() {
386        return Ok(CommandOutput::json(serde_json::json!({
387            "status": "ok",
388            "message": "No active alerts to check",
389            "triggered": [],
390        })));
391    }
392
393    let mut triggered_alerts: Vec<PriceAlert> = Vec::new();
394
395    for alert in to_check {
396        let price = match fetch_price(client, &alert.pair).await {
397            Ok(p) => p,
398            Err(_) => continue,
399        };
400
401        let should_trigger = match &alert.condition {
402            AlertCondition::Above { price: threshold } => price >= *threshold,
403            AlertCondition::Below { price: threshold } => price <= *threshold,
404            AlertCondition::ChangeUp { percent, from_price } => {
405                let change = ((price - from_price) / from_price) * 100.0;
406                change >= *percent
407            }
408            AlertCondition::ChangeDown { percent, from_price } => {
409                let change = ((from_price - price) / from_price) * 100.0;
410                change >= *percent
411            }
412        };
413
414        if should_trigger {
415            alert.status = AlertStatus::Triggered;
416            alert.triggered_at = Some(helpers::now_millis());
417            triggered_alerts.push(alert.clone());
418        }
419    }
420
421    save_alerts(&alerts)?;
422
423    if triggered_alerts.is_empty() {
424        return Ok(CommandOutput::json(serde_json::json!({
425            "status": "ok",
426            "message": "No alerts triggered",
427            "triggered": [],
428        })).with_addendum("[ALERT] No alerts triggered"));
429    }
430
431    let headers = vec!["ID".into(), "Pair".into(), "Condition".into(), "Price".into(), "Triggered At".into()];
432    let mut rows: Vec<Vec<String>> = Vec::new();
433
434    for alert in &triggered_alerts {
435        let current_price = fetch_price(client, &alert.pair).await.unwrap_or(0.0);
436        let condition_str = match &alert.condition {
437            AlertCondition::Above { price } => format!("> {}", format_number(*price)),
438            AlertCondition::Below { price } => format!("< {}", format_number(*price)),
439            AlertCondition::ChangeUp { percent, from_price } => format!("+{:.1}% from {}", percent, format_number(*from_price)),
440            AlertCondition::ChangeDown { percent, from_price } => format!("-{:.1}% from {}", percent, format_number(*from_price)),
441        };
442
443        rows.push(vec![
444            alert.id.to_string(),
445            alert.pair.clone(),
446            condition_str.clone(),
447            format_number(current_price),
448            chrono::DateTime::from_timestamp_millis(alert.triggered_at.unwrap_or(0).min(i64::MAX as u64) as i64)
449                .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
450                .unwrap_or_default(),
451        ]);
452    }
453
454    let data = serde_json::json!({
455        "status": "ok",
456        "triggered": triggered_alerts,
457        "count": triggered_alerts.len(),
458    });
459
460    Ok(CommandOutput::new(data, headers, rows)
461        .with_addendum(format!("[ALERT] {} alert(s) triggered!", triggered_alerts.len())))
462}
463
464fn alert_triggered() -> Result<CommandOutput> {
465    let alerts = load_alerts();
466    let triggered: Vec<&PriceAlert> = alerts.iter()
467        .filter(|a| a.status == AlertStatus::Triggered)
468        .collect();
469
470    if triggered.is_empty() {
471        return Ok(CommandOutput::json(serde_json::json!({
472            "status": "ok",
473            "message": "No triggered alerts",
474            "count": 0,
475        })));
476    }
477
478    let headers = vec!["ID".into(), "Pair".into(), "Condition".into(), "Triggered At".into()];
479    let mut rows: Vec<Vec<String>> = Vec::new();
480
481    for alert in &triggered {
482        let condition_str = match &alert.condition {
483            AlertCondition::Above { price } => format!("> {}", format_number(*price)),
484            AlertCondition::Below { price } => format!("< {}", format_number(*price)),
485            AlertCondition::ChangeUp { percent, from_price } => format!("+{:.1}% from {}", percent, format_number(*from_price)),
486            AlertCondition::ChangeDown { percent, from_price } => format!("-{:.1}% from {}", percent, format_number(*from_price)),
487        };
488
489        rows.push(vec![
490            alert.id.to_string(),
491            alert.pair.clone(),
492            condition_str.clone(),
493            chrono::DateTime::from_timestamp_millis(alert.triggered_at.unwrap_or(0).min(i64::MAX as u64) as i64)
494                .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
495                .unwrap_or_default(),
496        ]);
497    }
498
499    Ok(CommandOutput::new(
500        serde_json::json!({"status": "ok", "count": triggered.len()}),
501        headers,
502        rows,
503    ).with_addendum(format!("[ALERT] {} triggered alert(s)", triggered.len())))
504}
505
506async fn fetch_price(client: &IndodaxClient, pair: &str) -> Result<f64> {
507    let response: serde_json::Value = client.public_get(&format!("/api/ticker/{}", pair)).await?;
508
509    let price = response.get("ticker")
510        .and_then(|t| t.get("last"))
511        .and_then(|v| v.as_str())
512        .and_then(|s| s.parse::<f64>().ok())
513        .or_else(|| {
514            response.get("ticker")
515                .and_then(|t| t.get("last"))
516                .and_then(|v| v.as_f64())
517        })
518        .ok_or_else(|| anyhow::anyhow!("Failed to parse price for {}", pair))?;
519
520    Ok(price)
521}
522
523async fn alert_watch(
524    client: &IndodaxClient,
525    id: Option<u64>,
526    pair_filter: Option<&str>,
527    threshold: f64,
528) -> Result<CommandOutput> {
529    let mut alerts = load_alerts();
530
531    let target_ids: std::collections::HashSet<u64> = if let Some(target_id) = id {
532        alerts.iter().filter(|a| a.id == target_id && a.status == AlertStatus::Active).map(|a| a.id).collect()
533    } else {
534        let filter = pair_filter.unwrap_or("*");
535        alerts.iter()
536            .filter(|a| a.status == AlertStatus::Active && (filter == "*" || a.pair == filter))
537            .map(|a| a.id)
538            .collect()
539    };
540
541    if target_ids.is_empty() {
542        return Ok(CommandOutput::json(serde_json::json!({
543            "status": "ok",
544            "message": "No active alerts to watch",
545            "watching": [],
546        })));
547    }
548
549    let pairs: Vec<String> = alerts.iter()
550        .filter(|a| target_ids.contains(&a.id))
551        .map(|a| a.pair.clone())
552        .collect();
553    let pair_set: std::collections::HashSet<String> = pairs.iter().cloned().collect();
554    let watching = pair_set.len();
555
556    eprintln!("[ALERT] Watching {} alerts for {} pair(s): {}", target_ids.len(), watching, pairs.join(", "));
557    eprintln!("[ALERT] Press Ctrl+C to stop monitoring");
558    eprintln!();
559
560    const PUBLIC_WS_URL: &str = "wss://ws3.indodax.com/ws/";
561
562    let token = helpers::fetch_public_ws_token(client).await?;
563
564    let (ws_stream, _) = tokio::time::timeout(std::time::Duration::from_secs(10), connect_async(PUBLIC_WS_URL)).await
565        .map_err(|_| anyhow::anyhow!("WebSocket connection timed out after 10s"))?
566        .map_err(|e| anyhow::anyhow!("Failed to connect to WebSocket: {}", e))?;
567
568    let (mut write, mut read) = ws_stream.split();
569
570    let auth_msg = serde_json::json!({
571        "params": { "token": token },
572        "id": 1
573    });
574    write.send(Message::Text(auth_msg.to_string())).await
575        .map_err(|e| anyhow::anyhow!("Failed to authenticate: {}", e))?;
576
577    let mut authed = false;
578    let mut last_prices: std::collections::HashMap<String, f64> = std::collections::HashMap::new();
579    let mut triggered_count = 0;
580
581    let mut triggered_ids = std::collections::HashSet::new();
582
583    while let Some(msg) = read.next().await {
584        match msg {
585            Ok(Message::Text(text)) => {
586                if let Ok(data) = serde_json::from_str::<serde_json::Value>(&text) {
587                    if !authed {
588                        if data.get("id").and_then(|v| v.as_i64()) == Some(1)
589                            && data.get("result").is_some()
590                        {
591                            authed = true;
592                            eprintln!("[WS] Authenticated, subscribing to pairs...");
593                            for pair in &pair_set {
594                                let sub_msg = serde_json::json!({
595                                    "method": "subscribe",
596                                    "params": { "channel": format!("chart:tick-{}", pair) },
597                                    "id": 2
598                                });
599                                write.send(Message::Text(sub_msg.to_string())).await.ok();
600                            }
601                        }
602                        continue;
603                    }
604
605                    if let Some(result) = data.get("result").or(data.get("data")) {
606                        let pair = result.get("pair").or(data.get("pair")).and_then(|v| v.as_str()).unwrap_or("");
607                        let price = result.get("price").or(result.get("c")).or(result.get("close"))
608                            .and_then(|v| v.as_str().and_then(|s| s.parse::<f64>().ok()))
609                            .or_else(|| result.get("price").or(result.get("c")).and_then(|v| v.as_f64()));
610
611                        if let Some(price) = price {
612                            let prev_price = last_prices.get(pair).copied();
613                            last_prices.insert(pair.to_string(), price);
614
615                            if let Some(prev) = prev_price {
616                                let change_pct = ((price - prev) / prev * 100.0).abs();
617                                if change_pct > threshold {
618                                    eprintln!("[PRICE] {} {} (change: {:.2}%)",
619                                        pair,
620                                        format_number(price),
621                                        if price > prev { '+' } else { '-' });
622                                }
623                            }
624
625                            for alert in alerts.iter_mut().filter(|a| a.pair == pair && target_ids.contains(&a.id) && a.status == AlertStatus::Active) {
626                                let should_trigger = match &alert.condition {
627                                    AlertCondition::Above { price: threshold } => price >= *threshold,
628                                    AlertCondition::Below { price: threshold } => price <= *threshold,
629                                    AlertCondition::ChangeUp { percent, from_price } => {
630                                        let change = ((price - from_price) / from_price) * 100.0;
631                                        change >= *percent
632                                    }
633                                    AlertCondition::ChangeDown { percent, from_price } => {
634                                        let change = ((from_price - price) / from_price) * 100.0;
635                                        change >= *percent
636                                    }
637                                };
638
639                                if should_trigger {
640                                    alert.status = AlertStatus::Triggered;
641                                    alert.triggered_at = Some(helpers::now_millis());
642                                    triggered_ids.insert(alert.id);
643                                    triggered_count += 1;
644                                    let condition_str = match &alert.condition {
645                                        AlertCondition::Above { price } => format!("> {}", format_number(*price)),
646                                        AlertCondition::Below { price } => format!("< {}", format_number(*price)),
647                                        AlertCondition::ChangeUp { percent, from_price } => format!("+{:.1}% from {}", percent, format_number(*from_price)),
648                                        AlertCondition::ChangeDown { percent, from_price } => format!("-{:.1}% from {}", percent, format_number(*from_price)),
649                                    };
650                                    eprintln!();
651                                    eprintln!("{}", "=".repeat(60).yellow());
652                                    eprintln!("{} TRIGGERED {} {}", "[ALERT]".bold().green(), format!("#{}", alert.id).bold(), "!".green().bold());
653                                    eprintln!("  Pair:      {}", pair);
654                                    eprintln!("  Condition: {}", condition_str);
655                                    eprintln!("  Price:     {} (triggered)", format_number(price));
656                                    if let Some(note) = &alert.note {
657                                        eprintln!("  Note:      {}", note);
658                                    }
659                                    eprintln!("{}", "=".repeat(60).yellow());
660                                    eprintln!();
661                                }
662                            }
663                        }
664                    }
665                }
666            }
667            Ok(Message::Ping(data)) => {
668                write.send(Message::Pong(data)).await.ok();
669            }
670            Ok(Message::Close(_)) => {
671                break;
672            }
673            Err(e) => {
674                eprintln!("[WARN] WebSocket error: {}", e);
675                break;
676            }
677            _ => {}
678        }
679    }
680
681    eprintln!("\n[ALERT] Monitoring stopped. {} alert(s) triggered.", triggered_count);
682
683    if triggered_count > 0 {
684        save_alerts(&alerts)?;
685    }
686
687    let data = serde_json::json!({
688        "status": "ok",
689        "watching": target_ids.len(),
690        "pairs": pairs,
691        "triggered": triggered_count,
692    });
693
694    Ok(CommandOutput::new(data, vec![], vec![]).with_addendum(format!(
695        "[ALERT] Watched {} alert(s) for {} pair(s). {} triggered.",
696        target_ids.len(), watching, triggered_count
697    )))
698}
699
700fn format_number(n: f64) -> String {
701    if n >= 1_000_000_000.0 {
702        format!("{:.2}B", n / 1_000_000_000.0)
703    } else if n >= 1_000_000.0 {
704        format!("{:.2}M", n / 1_000_000.0)
705    } else if n >= 1_000.0 {
706        format!("{:.2}K", n / 1_000.0)
707    } else if n >= 1.0 {
708        format!("{:.2}", n)
709    } else {
710        format!("{:.8}", n)
711    }
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717
718    #[test]
719    fn test_format_number() {
720        assert_eq!(format_number(1_500_000_000.0), "1.50B");
721        assert_eq!(format_number(100_000_000.0), "100.00M");
722        assert_eq!(format_number(50_000.0), "50.00K");
723        assert_eq!(format_number(1_000.0), "1.00K");
724        assert_eq!(format_number(100.0), "100.00");
725        assert_eq!(format_number(0.00001), "0.00001000");
726    }
727
728    #[test]
729    fn test_alert_condition_serialization() {
730        let above = AlertCondition::Above { price: 100000000.0 };
731        let json = serde_json::to_string(&above).unwrap();
732        assert!(json.contains("\"type\":\"above\""));
733
734        let below = AlertCondition::Below { price: 50000000.0 };
735        let json = serde_json::to_string(&below).unwrap();
736        assert!(json.contains("\"type\":\"below\""));
737
738        let change_up = AlertCondition::ChangeUp { percent: 5.0, from_price: 100000000.0 };
739        let json = serde_json::to_string(&change_up).unwrap();
740        assert!(json.contains("\"type\":\"change_up\""));
741        assert!(json.contains("5.0"));
742
743        let change_down = AlertCondition::ChangeDown { percent: 10.0, from_price: 150000000.0 };
744        let json = serde_json::to_string(&change_down).unwrap();
745        assert!(json.contains("\"type\":\"change_down\""));
746    }
747
748    #[test]
749    fn test_alert_status_serialization() {
750        let active = AlertStatus::Active;
751        let json = serde_json::to_string(&active).unwrap();
752        assert_eq!(json, "\"active\"");
753
754        let triggered = AlertStatus::Triggered;
755        let json = serde_json::to_string(&triggered).unwrap();
756        assert_eq!(json, "\"triggered\"");
757    }
758}