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}