Skip to main content

hyper_agent_notify/
notifier.rs

1//! Discord webhook notifications and JSONL structured logging for agent events.
2//!
3//! The [`Notifier`] dispatches [`AgentEvent`]s to two sinks:
4//!
5//! 1. **JSONL log files** -- one file per day (`logs/YYYY-MM-DD.jsonl`), one
6//!    JSON line per event.  Always written when a `log_dir` is configured.
7//! 2. **Discord webhook** -- a rich embed posted via `reqwest`.  Suppressed
8//!    during configurable quiet hours (default 23:00 - 08:00 local time).
9
10use chrono::{Local, NaiveTime, Utc};
11use serde::{Deserialize, Serialize};
12use std::fmt;
13use std::fs::{self, OpenOptions};
14use std::io::Write;
15use std::path::PathBuf;
16
17use crate::config::NotifierSection;
18
19// ---------------------------------------------------------------------------
20// AgentEvent
21// ---------------------------------------------------------------------------
22
23/// Structured events emitted by the agent during its lifecycle.
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
25#[serde(tag = "event")]
26pub enum AgentEvent {
27    OrderPlaced {
28        market: String,
29        side: String,
30        size: f64,
31        price: f64,
32    },
33    PositionClosed {
34        market: String,
35        pnl: f64,
36        reason: String,
37    },
38    CycleComplete {
39        markets_scanned: u32,
40        orders_placed: u32,
41    },
42    DailyPnl {
43        total_pnl: f64,
44        win_rate: f64,
45        trades: u32,
46    },
47    Error {
48        message: String,
49    },
50    AgentStarted {
51        name: String,
52        mode: String,
53    },
54    AgentStopped {
55        name: String,
56    },
57    OrderExecuted {
58        market: String,
59        side: String,
60        size: f64,
61        price: f64,
62        order_id: String,
63    },
64    OrderBlocked {
65        market: String,
66        reason: String,
67    },
68}
69
70impl AgentEvent {
71    /// Human-readable title for Discord embeds.
72    pub fn title(&self) -> &str {
73        match self {
74            AgentEvent::OrderPlaced { .. } => "Order Placed",
75            AgentEvent::PositionClosed { .. } => "Position Closed",
76            AgentEvent::CycleComplete { .. } => "Cycle Complete",
77            AgentEvent::DailyPnl { .. } => "Daily PnL",
78            AgentEvent::Error { .. } => "Error",
79            AgentEvent::AgentStarted { .. } => "Agent Started",
80            AgentEvent::AgentStopped { .. } => "Agent Stopped",
81            AgentEvent::OrderExecuted { .. } => "Order Executed",
82            AgentEvent::OrderBlocked { .. } => "Order Blocked",
83        }
84    }
85
86    /// Discord embed colour: green for profit, red for loss/error, blue for info.
87    pub fn color(&self) -> u32 {
88        match self {
89            AgentEvent::OrderPlaced { .. } => 0x3498db, // blue
90            AgentEvent::PositionClosed { pnl, .. } => {
91                if *pnl >= 0.0 {
92                    0x2ecc71
93                } else {
94                    0xe74c3c
95                } // green / red
96            }
97            AgentEvent::CycleComplete { .. } => 0x3498db, // blue
98            AgentEvent::DailyPnl { total_pnl, .. } => {
99                if *total_pnl >= 0.0 {
100                    0x2ecc71
101                } else {
102                    0xe74c3c
103                }
104            }
105            AgentEvent::Error { .. } => 0xe74c3c,        // red
106            AgentEvent::AgentStarted { .. } => 0x2ecc71, // green
107            AgentEvent::AgentStopped { .. } => 0x95a5a6, // grey
108            AgentEvent::OrderExecuted { side, .. } => {
109                if side == "buy" {
110                    0x2ecc71
111                } else {
112                    0xe74c3c
113                } // green buy / red sell
114            }
115            AgentEvent::OrderBlocked { .. } => 0xe74c3c, // red
116        }
117    }
118
119    /// Build Discord embed fields as `(name, value, inline)` triples.
120    pub fn embed_fields(&self) -> Vec<(&str, String, bool)> {
121        match self {
122            AgentEvent::OrderPlaced {
123                market,
124                side,
125                size,
126                price,
127            } => vec![
128                ("Market", market.clone(), true),
129                ("Side", side.clone(), true),
130                ("Size", format!("{size}"), true),
131                ("Price", format!("{price}"), true),
132            ],
133            AgentEvent::PositionClosed {
134                market,
135                pnl,
136                reason,
137            } => vec![
138                ("Market", market.clone(), true),
139                ("PnL", format!("{pnl:+.2}"), true),
140                ("Reason", reason.clone(), true),
141            ],
142            AgentEvent::CycleComplete {
143                markets_scanned,
144                orders_placed,
145            } => vec![
146                ("Markets Scanned", format!("{markets_scanned}"), true),
147                ("Orders Placed", format!("{orders_placed}"), true),
148            ],
149            AgentEvent::DailyPnl {
150                total_pnl,
151                win_rate,
152                trades,
153            } => vec![
154                ("Total PnL", format!("{total_pnl:+.2}"), true),
155                ("Win Rate", format!("{:.1}%", win_rate * 100.0), true),
156                ("Trades", format!("{trades}"), true),
157            ],
158            AgentEvent::Error { message } => vec![("Message", message.clone(), false)],
159            AgentEvent::AgentStarted { name, mode } => {
160                vec![("Agent", name.clone(), true), ("Mode", mode.clone(), true)]
161            }
162            AgentEvent::AgentStopped { name } => vec![("Agent", name.clone(), true)],
163            AgentEvent::OrderExecuted {
164                market,
165                side,
166                size,
167                price,
168                order_id,
169            } => vec![
170                ("Market", market.clone(), true),
171                ("Side", side.clone(), true),
172                ("Size", format!("{size}"), true),
173                ("Price", format!("{price:.2}"), true),
174                ("Order ID", order_id.clone(), false),
175            ],
176            AgentEvent::OrderBlocked { market, reason } => vec![
177                ("Market", market.clone(), true),
178                ("Reason", reason.clone(), false),
179            ],
180        }
181    }
182}
183
184// ---------------------------------------------------------------------------
185// NotifierError
186// ---------------------------------------------------------------------------
187
188/// Errors emitted by the notifier.
189#[derive(Debug)]
190pub enum NotifierError {
191    /// Failed to write to the JSONL log file.
192    IoError(std::io::Error),
193    /// Failed to serialize the event.
194    SerializeError(serde_json::Error),
195    /// Discord webhook request failed.
196    DiscordError(String),
197}
198
199impl fmt::Display for NotifierError {
200    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201        match self {
202            NotifierError::IoError(e) => write!(f, "notifier I/O error: {e}"),
203            NotifierError::SerializeError(e) => write!(f, "notifier serialize error: {e}"),
204            NotifierError::DiscordError(e) => write!(f, "discord webhook error: {e}"),
205        }
206    }
207}
208
209impl std::error::Error for NotifierError {}
210
211impl From<std::io::Error> for NotifierError {
212    fn from(e: std::io::Error) -> Self {
213        NotifierError::IoError(e)
214    }
215}
216
217impl From<serde_json::Error> for NotifierError {
218    fn from(e: serde_json::Error) -> Self {
219        NotifierError::SerializeError(e)
220    }
221}
222
223// ---------------------------------------------------------------------------
224// Notifier
225// ---------------------------------------------------------------------------
226
227/// Dispatches agent events to Discord and/or JSONL log files.
228pub struct Notifier {
229    discord_webhook: Option<String>,
230    log_dir: Option<PathBuf>,
231    quiet_start: NaiveTime,
232    quiet_end: NaiveTime,
233    http: reqwest::Client,
234}
235
236impl Notifier {
237    /// Build a `Notifier` from the config section.
238    pub fn from_config(config: &NotifierSection) -> Self {
239        let quiet_start = NaiveTime::parse_from_str(&config.quiet_start, "%H:%M")
240            .unwrap_or_else(|_| NaiveTime::from_hms_opt(23, 0, 0).unwrap());
241        let quiet_end = NaiveTime::parse_from_str(&config.quiet_end, "%H:%M")
242            .unwrap_or_else(|_| NaiveTime::from_hms_opt(8, 0, 0).unwrap());
243
244        let log_dir = if config.log_dir.is_empty() {
245            None
246        } else {
247            Some(PathBuf::from(&config.log_dir))
248        };
249
250        Self {
251            discord_webhook: config.discord_webhook.clone(),
252            log_dir,
253            quiet_start,
254            quiet_end,
255            http: reqwest::Client::new(),
256        }
257    }
258
259    /// Send an event to all configured sinks.
260    ///
261    /// JSONL is always written (if `log_dir` is set).
262    /// Discord is skipped during quiet hours.
263    pub async fn notify(&self, event: &AgentEvent) -> Result<(), NotifierError> {
264        self.write_jsonl(event)?;
265
266        if self.discord_webhook.is_some() && !self.is_quiet_hours() {
267            self.send_discord(event).await?;
268        }
269
270        Ok(())
271    }
272
273    /// Returns `true` if the current local time falls within the quiet window.
274    ///
275    /// Quiet hours wrap around midnight when `quiet_start > quiet_end`
276    /// (e.g. 23:00 - 08:00).
277    pub fn is_quiet_hours(&self) -> bool {
278        self.is_quiet_hours_at(Local::now().time())
279    }
280
281    /// Testable version that accepts an explicit time.
282    pub fn is_quiet_hours_at(&self, now: NaiveTime) -> bool {
283        if self.quiet_start <= self.quiet_end {
284            // Same-day range, e.g. 01:00 - 06:00
285            now >= self.quiet_start && now < self.quiet_end
286        } else {
287            // Wraps midnight, e.g. 23:00 - 08:00
288            now >= self.quiet_start || now < self.quiet_end
289        }
290    }
291
292    /// POST a Discord webhook embed.
293    async fn send_discord(&self, event: &AgentEvent) -> Result<(), NotifierError> {
294        let webhook_url = match &self.discord_webhook {
295            Some(url) => url,
296            None => return Ok(()),
297        };
298
299        let payload = self.build_discord_payload(event);
300
301        let resp = self
302            .http
303            .post(webhook_url)
304            .json(&payload)
305            .send()
306            .await
307            .map_err(|e| NotifierError::DiscordError(e.to_string()))?;
308
309        if !resp.status().is_success() {
310            let status = resp.status();
311            let body = resp
312                .text()
313                .await
314                .unwrap_or_else(|_| "<unreadable>".to_string());
315            return Err(NotifierError::DiscordError(format!(
316                "Discord returned {status}: {body}"
317            )));
318        }
319
320        Ok(())
321    }
322
323    /// Build the Discord webhook JSON payload (embeds format).
324    pub fn build_discord_payload(&self, event: &AgentEvent) -> serde_json::Value {
325        let fields: Vec<serde_json::Value> = event
326            .embed_fields()
327            .into_iter()
328            .map(|(name, value, inline)| {
329                serde_json::json!({
330                    "name": name,
331                    "value": value,
332                    "inline": inline,
333                })
334            })
335            .collect();
336
337        serde_json::json!({
338            "embeds": [{
339                "title": event.title(),
340                "color": event.color(),
341                "fields": fields,
342                "timestamp": Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
343            }]
344        })
345    }
346
347    /// Append one JSONL line to `<log_dir>/YYYY-MM-DD.jsonl`.
348    fn write_jsonl(&self, event: &AgentEvent) -> Result<(), NotifierError> {
349        let log_dir = match &self.log_dir {
350            Some(d) => d,
351            None => return Ok(()),
352        };
353
354        fs::create_dir_all(log_dir)?;
355
356        let today = Utc::now().format("%Y-%m-%d").to_string();
357        let path = log_dir.join(format!("{today}.jsonl"));
358
359        // Build the line: merge a `ts` field into the event JSON.
360        let mut value = serde_json::to_value(event)?;
361        if let Some(obj) = value.as_object_mut() {
362            // Insert ts as the first key (preserve_order feature).
363            let ts = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
364            let mut ordered = serde_json::Map::new();
365            ordered.insert("ts".to_string(), serde_json::Value::String(ts));
366            for (k, v) in obj.iter() {
367                ordered.insert(k.clone(), v.clone());
368            }
369            value = serde_json::Value::Object(ordered);
370        }
371
372        let line = serde_json::to_string(&value)?;
373
374        let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
375        writeln!(file, "{line}")?;
376
377        Ok(())
378    }
379}
380
381// ---------------------------------------------------------------------------
382// Tests
383// ---------------------------------------------------------------------------
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use std::io::BufRead;
389
390    fn test_config(dir: &str) -> NotifierSection {
391        NotifierSection {
392            enabled: true,
393            discord_webhook: Some("https://discord.com/api/webhooks/test/fake".to_string()),
394            log_dir: dir.to_string(),
395            quiet_start: "23:00".to_string(),
396            quiet_end: "08:00".to_string(),
397        }
398    }
399
400    // ---- Event serialization ----
401
402    #[test]
403    fn test_event_serialization_order_placed() {
404        let event = AgentEvent::OrderPlaced {
405            market: "BTC-PERP".to_string(),
406            side: "buy".to_string(),
407            size: 100.0,
408            price: 65000.0,
409        };
410        let json = serde_json::to_string(&event).unwrap();
411        let deser: AgentEvent = serde_json::from_str(&json).unwrap();
412        assert_eq!(event, deser);
413        assert!(json.contains("\"event\":\"OrderPlaced\""));
414    }
415
416    #[test]
417    fn test_event_serialization_position_closed() {
418        let event = AgentEvent::PositionClosed {
419            market: "ETH-PERP".to_string(),
420            pnl: -42.5,
421            reason: "stop_loss".to_string(),
422        };
423        let json = serde_json::to_string(&event).unwrap();
424        let deser: AgentEvent = serde_json::from_str(&json).unwrap();
425        assert_eq!(event, deser);
426    }
427
428    #[test]
429    fn test_event_serialization_all_variants() {
430        let events: Vec<AgentEvent> = vec![
431            AgentEvent::OrderPlaced {
432                market: "BTC".into(),
433                side: "buy".into(),
434                size: 1.0,
435                price: 100.0,
436            },
437            AgentEvent::PositionClosed {
438                market: "ETH".into(),
439                pnl: 50.0,
440                reason: "tp".into(),
441            },
442            AgentEvent::CycleComplete {
443                markets_scanned: 10,
444                orders_placed: 2,
445            },
446            AgentEvent::DailyPnl {
447                total_pnl: 123.45,
448                win_rate: 0.65,
449                trades: 20,
450            },
451            AgentEvent::Error {
452                message: "connection lost".into(),
453            },
454            AgentEvent::AgentStarted {
455                name: "alpha".into(),
456                mode: "paper".into(),
457            },
458            AgentEvent::AgentStopped {
459                name: "alpha".into(),
460            },
461            AgentEvent::OrderExecuted {
462                market: "BTC-PERP".into(),
463                side: "buy".into(),
464                size: 0.01,
465                price: 65000.0,
466                order_id: "ord-123".into(),
467            },
468            AgentEvent::OrderBlocked {
469                market: "ETH-PERP".into(),
470                reason: "max_position_exceeded".into(),
471            },
472        ];
473
474        for event in &events {
475            let json = serde_json::to_string(event).unwrap();
476            let deser: AgentEvent = serde_json::from_str(&json).unwrap();
477            assert_eq!(event, &deser);
478        }
479    }
480
481    // ---- Quiet hours ----
482
483    #[test]
484    fn test_quiet_hours_wrapping_midnight() {
485        // 23:00 - 08:00 (wraps midnight)
486        let cfg = test_config("/tmp/notifier-test-qh");
487        let notifier = Notifier::from_config(&cfg);
488
489        // Inside quiet hours
490        let t_2330 = NaiveTime::from_hms_opt(23, 30, 0).unwrap();
491        let t_0200 = NaiveTime::from_hms_opt(2, 0, 0).unwrap();
492        let t_0000 = NaiveTime::from_hms_opt(0, 0, 0).unwrap();
493        assert!(notifier.is_quiet_hours_at(t_2330));
494        assert!(notifier.is_quiet_hours_at(t_0200));
495        assert!(notifier.is_quiet_hours_at(t_0000));
496
497        // Outside quiet hours
498        let t_0800 = NaiveTime::from_hms_opt(8, 0, 0).unwrap();
499        let t_1200 = NaiveTime::from_hms_opt(12, 0, 0).unwrap();
500        let t_2259 = NaiveTime::from_hms_opt(22, 59, 0).unwrap();
501        assert!(!notifier.is_quiet_hours_at(t_0800));
502        assert!(!notifier.is_quiet_hours_at(t_1200));
503        assert!(!notifier.is_quiet_hours_at(t_2259));
504    }
505
506    #[test]
507    fn test_quiet_hours_same_day_range() {
508        // 01:00 - 06:00 (no wrap)
509        let cfg = NotifierSection {
510            enabled: true,
511            discord_webhook: None,
512            log_dir: "logs".to_string(),
513            quiet_start: "01:00".to_string(),
514            quiet_end: "06:00".to_string(),
515        };
516        let notifier = Notifier::from_config(&cfg);
517
518        let t_0300 = NaiveTime::from_hms_opt(3, 0, 0).unwrap();
519        let t_1200 = NaiveTime::from_hms_opt(12, 0, 0).unwrap();
520        let t_0059 = NaiveTime::from_hms_opt(0, 59, 0).unwrap();
521        assert!(notifier.is_quiet_hours_at(t_0300));
522        assert!(!notifier.is_quiet_hours_at(t_1200));
523        assert!(!notifier.is_quiet_hours_at(t_0059));
524    }
525
526    #[test]
527    fn test_quiet_hours_boundary_start_inclusive_end_exclusive() {
528        let cfg = test_config("/tmp/notifier-test-boundary");
529        let notifier = Notifier::from_config(&cfg);
530
531        let t_2300 = NaiveTime::from_hms_opt(23, 0, 0).unwrap();
532        assert!(notifier.is_quiet_hours_at(t_2300)); // start is inclusive
533
534        let t_0800 = NaiveTime::from_hms_opt(8, 0, 0).unwrap();
535        assert!(!notifier.is_quiet_hours_at(t_0800)); // end is exclusive
536    }
537
538    // ---- JSONL write + read back ----
539
540    #[test]
541    fn test_jsonl_write_and_read_back() {
542        let dir = tempfile::tempdir().unwrap();
543        let cfg = NotifierSection {
544            enabled: true,
545            discord_webhook: None,
546            log_dir: dir.path().to_str().unwrap().to_string(),
547            quiet_start: "23:00".to_string(),
548            quiet_end: "08:00".to_string(),
549        };
550        let notifier = Notifier::from_config(&cfg);
551
552        let events = vec![
553            AgentEvent::OrderPlaced {
554                market: "BTC-PERP".into(),
555                side: "buy".into(),
556                size: 100.0,
557                price: 65000.0,
558            },
559            AgentEvent::PositionClosed {
560                market: "ETH-PERP".into(),
561                pnl: 42.5,
562                reason: "take_profit".into(),
563            },
564            AgentEvent::Error {
565                message: "timeout".into(),
566            },
567        ];
568
569        // Write events
570        for event in &events {
571            notifier.write_jsonl(event).unwrap();
572        }
573
574        // Find the log file
575        let today = Utc::now().format("%Y-%m-%d").to_string();
576        let log_path = dir.path().join(format!("{today}.jsonl"));
577        assert!(log_path.exists());
578
579        // Read back and validate
580        let file = std::fs::File::open(&log_path).unwrap();
581        let reader = std::io::BufReader::new(file);
582        let lines: Vec<String> = reader.lines().map(|l| l.unwrap()).collect();
583        assert_eq!(lines.len(), 3);
584
585        // Each line should be valid JSON with a `ts` field
586        for (i, line) in lines.iter().enumerate() {
587            let v: serde_json::Value = serde_json::from_str(line).unwrap();
588            assert!(v.get("ts").is_some(), "line {i} missing ts field");
589            assert!(v.get("event").is_some(), "line {i} missing event field");
590        }
591
592        // Verify the first event deserializes correctly (strip ts)
593        let first: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
594        assert_eq!(first["event"], "OrderPlaced");
595        assert_eq!(first["market"], "BTC-PERP");
596        assert_eq!(first["side"], "buy");
597        assert_eq!(first["size"], 100.0);
598        assert_eq!(first["price"], 65000.0);
599    }
600
601    #[test]
602    fn test_jsonl_creates_directory_if_missing() {
603        let dir = tempfile::tempdir().unwrap();
604        let nested = dir.path().join("deep").join("nested").join("logs");
605        let cfg = NotifierSection {
606            enabled: true,
607            discord_webhook: None,
608            log_dir: nested.to_str().unwrap().to_string(),
609            quiet_start: "23:00".to_string(),
610            quiet_end: "08:00".to_string(),
611        };
612        let notifier = Notifier::from_config(&cfg);
613
614        let event = AgentEvent::AgentStarted {
615            name: "test".into(),
616            mode: "dry-run".into(),
617        };
618        notifier.write_jsonl(&event).unwrap();
619        assert!(nested.exists());
620    }
621
622    #[test]
623    fn test_jsonl_no_log_dir_is_noop() {
624        let cfg = NotifierSection {
625            enabled: true,
626            discord_webhook: None,
627            log_dir: String::new(),
628            quiet_start: "23:00".to_string(),
629            quiet_end: "08:00".to_string(),
630        };
631        let notifier = Notifier::from_config(&cfg);
632
633        let event = AgentEvent::Error {
634            message: "test".into(),
635        };
636        // Should not error even with empty log_dir
637        notifier.write_jsonl(&event).unwrap();
638    }
639
640    // ---- Discord payload formatting ----
641
642    #[test]
643    fn test_discord_payload_order_placed() {
644        let cfg = test_config("/tmp/notifier-discord-test");
645        let notifier = Notifier::from_config(&cfg);
646
647        let event = AgentEvent::OrderPlaced {
648            market: "BTC-PERP".into(),
649            side: "buy".into(),
650            size: 100.0,
651            price: 65000.0,
652        };
653
654        let payload = notifier.build_discord_payload(&event);
655        let embed = &payload["embeds"][0];
656        assert_eq!(embed["title"], "Order Placed");
657        assert_eq!(embed["color"], 0x3498db); // blue
658
659        let fields = embed["fields"].as_array().unwrap();
660        assert_eq!(fields.len(), 4);
661        assert_eq!(fields[0]["name"], "Market");
662        assert_eq!(fields[0]["value"], "BTC-PERP");
663        assert_eq!(fields[1]["name"], "Side");
664        assert_eq!(fields[1]["value"], "buy");
665    }
666
667    #[test]
668    fn test_discord_payload_position_closed_profit() {
669        let cfg = test_config("/tmp/notifier-discord-test2");
670        let notifier = Notifier::from_config(&cfg);
671
672        let event = AgentEvent::PositionClosed {
673            market: "ETH-PERP".into(),
674            pnl: 150.0,
675            reason: "take_profit".into(),
676        };
677
678        let payload = notifier.build_discord_payload(&event);
679        let embed = &payload["embeds"][0];
680        assert_eq!(embed["color"], 0x2ecc71); // green for profit
681    }
682
683    #[test]
684    fn test_discord_payload_position_closed_loss() {
685        let cfg = test_config("/tmp/notifier-discord-test3");
686        let notifier = Notifier::from_config(&cfg);
687
688        let event = AgentEvent::PositionClosed {
689            market: "ETH-PERP".into(),
690            pnl: -50.0,
691            reason: "stop_loss".into(),
692        };
693
694        let payload = notifier.build_discord_payload(&event);
695        let embed = &payload["embeds"][0];
696        assert_eq!(embed["color"], 0xe74c3c); // red for loss
697    }
698
699    #[test]
700    fn test_discord_payload_error_event() {
701        let cfg = test_config("/tmp/notifier-discord-test4");
702        let notifier = Notifier::from_config(&cfg);
703
704        let event = AgentEvent::Error {
705            message: "connection refused".into(),
706        };
707
708        let payload = notifier.build_discord_payload(&event);
709        let embed = &payload["embeds"][0];
710        assert_eq!(embed["title"], "Error");
711        assert_eq!(embed["color"], 0xe74c3c); // red
712
713        let fields = embed["fields"].as_array().unwrap();
714        assert_eq!(fields[0]["name"], "Message");
715        assert_eq!(fields[0]["value"], "connection refused");
716        assert!(!fields[0]["inline"].as_bool().unwrap());
717    }
718
719    #[test]
720    fn test_discord_payload_daily_pnl() {
721        let cfg = test_config("/tmp/notifier-discord-test5");
722        let notifier = Notifier::from_config(&cfg);
723
724        let event = AgentEvent::DailyPnl {
725            total_pnl: -200.0,
726            win_rate: 0.4,
727            trades: 10,
728        };
729
730        let payload = notifier.build_discord_payload(&event);
731        let embed = &payload["embeds"][0];
732        assert_eq!(embed["color"], 0xe74c3c); // red for negative pnl
733        assert!(embed["timestamp"].is_string());
734    }
735
736    #[test]
737    fn test_discord_payload_has_timestamp() {
738        let cfg = test_config("/tmp/notifier-discord-test6");
739        let notifier = Notifier::from_config(&cfg);
740
741        let event = AgentEvent::AgentStarted {
742            name: "test-agent".into(),
743            mode: "paper".into(),
744        };
745
746        let payload = notifier.build_discord_payload(&event);
747        let ts = payload["embeds"][0]["timestamp"].as_str().unwrap();
748        // Should be ISO-8601 format
749        assert!(ts.contains('T'));
750        assert!(ts.ends_with('Z'));
751    }
752
753    // ---- Event color mapping ----
754
755    #[test]
756    fn test_event_colors() {
757        // Blue for info events
758        assert_eq!(
759            AgentEvent::OrderPlaced {
760                market: "X".into(),
761                side: "buy".into(),
762                size: 1.0,
763                price: 1.0,
764            }
765            .color(),
766            0x3498db
767        );
768        assert_eq!(
769            AgentEvent::CycleComplete {
770                markets_scanned: 1,
771                orders_placed: 0,
772            }
773            .color(),
774            0x3498db
775        );
776
777        // Green for positive events
778        assert_eq!(
779            AgentEvent::AgentStarted {
780                name: "a".into(),
781                mode: "live".into(),
782            }
783            .color(),
784            0x2ecc71
785        );
786        assert_eq!(
787            AgentEvent::PositionClosed {
788                market: "X".into(),
789                pnl: 0.0,
790                reason: "x".into(),
791            }
792            .color(),
793            0x2ecc71
794        ); // zero pnl is green
795
796        // Red for errors
797        assert_eq!(
798            AgentEvent::Error {
799                message: "x".into(),
800            }
801            .color(),
802            0xe74c3c
803        );
804
805        // Grey for stopped
806        assert_eq!(
807            AgentEvent::AgentStopped { name: "a".into() }.color(),
808            0x95a5a6
809        );
810    }
811
812    // ---- Notifier from_config ----
813
814    #[test]
815    fn test_from_config_default() {
816        let cfg = NotifierSection::default();
817        let notifier = Notifier::from_config(&cfg);
818        assert!(notifier.discord_webhook.is_none());
819        assert!(notifier.log_dir.is_some()); // "logs"
820        assert_eq!(
821            notifier.quiet_start,
822            NaiveTime::from_hms_opt(23, 0, 0).unwrap()
823        );
824        assert_eq!(
825            notifier.quiet_end,
826            NaiveTime::from_hms_opt(8, 0, 0).unwrap()
827        );
828    }
829
830    #[test]
831    fn test_from_config_invalid_time_uses_defaults() {
832        let cfg = NotifierSection {
833            enabled: true,
834            discord_webhook: None,
835            log_dir: "logs".to_string(),
836            quiet_start: "invalid".to_string(),
837            quiet_end: "also-invalid".to_string(),
838        };
839        let notifier = Notifier::from_config(&cfg);
840        assert_eq!(
841            notifier.quiet_start,
842            NaiveTime::from_hms_opt(23, 0, 0).unwrap()
843        );
844        assert_eq!(
845            notifier.quiet_end,
846            NaiveTime::from_hms_opt(8, 0, 0).unwrap()
847        );
848    }
849
850    // ---- NotifierError display ----
851
852    #[test]
853    fn test_notifier_error_display() {
854        let io_err = NotifierError::IoError(std::io::Error::new(
855            std::io::ErrorKind::PermissionDenied,
856            "access denied",
857        ));
858        assert!(io_err.to_string().contains("I/O"));
859
860        let discord_err = NotifierError::DiscordError("429 rate limited".into());
861        assert!(discord_err.to_string().contains("discord"));
862    }
863}