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}