Skip to main content

indodax_cli/commands/
alert.rs

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