Skip to main content

hyper_playbook/
logger.rs

1use std::fs::{self, OpenOptions};
2use std::io::Write;
3use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6
7use crate::engine::{TickAction, TickResult};
8
9// ---------------------------------------------------------------------------
10// PlaybookTickLog
11// ---------------------------------------------------------------------------
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[serde(rename_all = "camelCase")]
15pub struct PlaybookTickLog {
16    pub ts: String,
17    pub symbol: String,
18    pub regime: String,
19    pub regime_changed: bool,
20    pub fsm_state: String,
21    pub action: String,
22    pub triggered_rules: Vec<String>,
23    // Optional position context
24    pub entry_price: Option<f64>,
25    pub current_price: Option<f64>,
26    pub unrealized_pnl: Option<f64>,
27}
28
29// ---------------------------------------------------------------------------
30// PlaybookLogger
31// ---------------------------------------------------------------------------
32
33pub struct PlaybookLogger {
34    log_dir: PathBuf,
35    symbol: String,
36}
37
38impl PlaybookLogger {
39    pub fn new(log_dir: PathBuf, symbol: &str) -> Self {
40        Self {
41            log_dir,
42            symbol: symbol.to_string(),
43        }
44    }
45
46    /// Log a tick result as one JSONL line.
47    pub fn log_tick(
48        &self,
49        tick: &TickResult,
50        current_price: Option<f64>,
51        entry_price: Option<f64>,
52    ) {
53        let unrealized_pnl = match (current_price, entry_price) {
54            (Some(curr), Some(entry)) => Some(curr - entry),
55            _ => None,
56        };
57
58        let action_str = format_action_string(&tick.action);
59
60        let log_entry = PlaybookTickLog {
61            ts: chrono::Utc::now().to_rfc3339(),
62            symbol: self.symbol.clone(),
63            regime: tick.regime.clone(),
64            regime_changed: tick.regime_changed,
65            fsm_state: tick.fsm_state.clone(),
66            action: action_str,
67            triggered_rules: tick.triggered_rules.clone(),
68            entry_price,
69            current_price,
70            unrealized_pnl,
71        };
72
73        if let Ok(json_line) = serde_json::to_string(&log_entry) {
74            let date = chrono::Utc::now().format("%Y-%m-%d").to_string();
75            let filename = format!("playbook-{}.jsonl", date);
76            let path = self.log_dir.join(filename);
77
78            if let Some(parent) = path.parent() {
79                let _ = fs::create_dir_all(parent);
80            }
81
82            if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&path) {
83                let _ = writeln!(file, "{}", json_line);
84            }
85        }
86    }
87
88    /// Check if a tick result should trigger a notification.
89    pub fn should_notify(tick: &TickResult) -> bool {
90        match &tick.action {
91            TickAction::None => false,
92            TickAction::OrderPlaced { .. } => true,
93            TickAction::OrderFilled { .. } => true,
94            TickAction::OrderCancelled { .. } => false,
95            TickAction::PositionClosed { .. } => true,
96            TickAction::ForceClose { .. } => true,
97        }
98    }
99
100    /// Format a notification message for Discord.
101    pub fn format_notification(tick: &TickResult, symbol: &str) -> Option<String> {
102        if !Self::should_notify(tick) {
103            return None;
104        }
105
106        let msg = match &tick.action {
107            TickAction::OrderPlaced { side, size, .. } => {
108                format!(
109                    "\u{1f4ca} **{}** | Order placed: {} {} (regime: {})",
110                    symbol, side, size, tick.regime
111                )
112            }
113            TickAction::OrderFilled { entry_price, .. } => {
114                format!(
115                    "\u{2705} **{}** | Order filled at ${:.2} (regime: {})",
116                    symbol, entry_price, tick.regime
117                )
118            }
119            TickAction::PositionClosed { reason } => {
120                format!(
121                    "\u{1f512} **{}** | Position closed: {} (regime: {})",
122                    symbol, reason, tick.regime
123                )
124            }
125            TickAction::ForceClose { reason } => {
126                format!(
127                    "\u{26a0}\u{fe0f} **{}** | Force close: {} (regime: {})",
128                    symbol, reason, tick.regime
129                )
130            }
131            _ => return None,
132        };
133
134        // Add regime change info
135        if tick.regime_changed {
136            let prev = tick.previous_regime.as_deref().unwrap_or("unknown");
137            return Some(format!(
138                "{}\n\u{1f504} Regime changed: {} \u{2192} {}",
139                msg, prev, tick.regime
140            ));
141        }
142
143        Some(msg)
144    }
145}
146
147// ---------------------------------------------------------------------------
148// Helpers
149// ---------------------------------------------------------------------------
150
151fn format_action_string(action: &TickAction) -> String {
152    match action {
153        TickAction::None => "none".to_string(),
154        TickAction::OrderPlaced {
155            order_id,
156            side,
157            size,
158        } => format!("order_placed:{}:{}:{}", order_id, side, size),
159        TickAction::OrderFilled {
160            position_id,
161            entry_price,
162        } => format!("order_filled:{}:{}", position_id, entry_price),
163        TickAction::OrderCancelled { order_id, reason } => {
164            format!("order_cancelled:{}:{}", order_id, reason)
165        }
166        TickAction::PositionClosed { reason } => format!("position_closed:{}", reason),
167        TickAction::ForceClose { reason } => format!("force_close:{}", reason),
168    }
169}
170
171// ---------------------------------------------------------------------------
172// Tests
173// ---------------------------------------------------------------------------
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::engine::{TickAction, TickResult};
179
180    fn make_tick(action: TickAction) -> TickResult {
181        TickResult {
182            regime: "bull".into(),
183            regime_changed: false,
184            previous_regime: None,
185            fsm_state: "Idle".into(),
186            action,
187            triggered_rules: vec!["signal_a".into()],
188        }
189    }
190
191    fn make_tick_with_regime_change(action: TickAction) -> TickResult {
192        TickResult {
193            regime: "bear".into(),
194            regime_changed: true,
195            previous_regime: Some("bull".into()),
196            fsm_state: "Idle".into(),
197            action,
198            triggered_rules: vec![],
199        }
200    }
201
202    // 1. PlaybookTickLog serialization roundtrip
203    #[test]
204    fn test_tick_log_serde_roundtrip() {
205        let log = PlaybookTickLog {
206            ts: "2026-03-19T00:00:00Z".into(),
207            symbol: "BTC-USD".into(),
208            regime: "bull".into(),
209            regime_changed: true,
210            fsm_state: "Idle".into(),
211            action: "order_placed:o-1:buy:100".into(),
212            triggered_rules: vec!["buy_momentum".into()],
213            entry_price: Some(50000.0),
214            current_price: Some(51000.0),
215            unrealized_pnl: Some(1000.0),
216        };
217
218        let json = serde_json::to_string(&log).unwrap();
219        let back: PlaybookTickLog = serde_json::from_str(&json).unwrap();
220
221        assert_eq!(back.symbol, "BTC-USD");
222        assert_eq!(back.regime, "bull");
223        assert!(back.regime_changed);
224        assert_eq!(back.action, "order_placed:o-1:buy:100");
225        assert_eq!(back.triggered_rules, vec!["buy_momentum".to_string()]);
226        assert_eq!(back.entry_price, Some(50000.0));
227        assert_eq!(back.current_price, Some(51000.0));
228        assert_eq!(back.unrealized_pnl, Some(1000.0));
229    }
230
231    // 2. should_notify returns true for OrderPlaced, OrderFilled, PositionClosed, ForceClose
232    #[test]
233    fn test_should_notify_true_cases() {
234        let cases = vec![
235            TickAction::OrderPlaced {
236                order_id: "o-1".into(),
237                side: "buy".into(),
238                size: 100.0,
239            },
240            TickAction::OrderFilled {
241                position_id: "p-1".into(),
242                entry_price: 50000.0,
243            },
244            TickAction::PositionClosed {
245                reason: "exit_rule".into(),
246            },
247            TickAction::ForceClose {
248                reason: "regime_change".into(),
249            },
250        ];
251
252        for action in cases {
253            let tick = make_tick(action.clone());
254            assert!(
255                PlaybookLogger::should_notify(&tick),
256                "should_notify should be true for {:?}",
257                action
258            );
259        }
260    }
261
262    // 3. should_notify returns false for None, OrderCancelled
263    #[test]
264    fn test_should_notify_false_cases() {
265        let cases = vec![
266            TickAction::None,
267            TickAction::OrderCancelled {
268                order_id: "o-1".into(),
269                reason: "timeout".into(),
270            },
271        ];
272
273        for action in cases {
274            let tick = make_tick(action.clone());
275            assert!(
276                !PlaybookLogger::should_notify(&tick),
277                "should_notify should be false for {:?}",
278                action
279            );
280        }
281    }
282
283    // 4. format_notification returns correct messages
284    #[test]
285    fn test_format_notification_messages() {
286        // OrderPlaced
287        let tick = make_tick(TickAction::OrderPlaced {
288            order_id: "o-1".into(),
289            side: "buy".into(),
290            size: 100.0,
291        });
292        let msg = PlaybookLogger::format_notification(&tick, "BTC-USD").unwrap();
293        assert!(msg.contains("BTC-USD"));
294        assert!(msg.contains("Order placed"));
295        assert!(msg.contains("buy"));
296        assert!(msg.contains("100"));
297
298        // OrderFilled
299        let tick = make_tick(TickAction::OrderFilled {
300            position_id: "p-1".into(),
301            entry_price: 50000.0,
302        });
303        let msg = PlaybookLogger::format_notification(&tick, "BTC-USD").unwrap();
304        assert!(msg.contains("Order filled"));
305        assert!(msg.contains("50000.00"));
306
307        // PositionClosed
308        let tick = make_tick(TickAction::PositionClosed {
309            reason: "stop_loss".into(),
310        });
311        let msg = PlaybookLogger::format_notification(&tick, "BTC-USD").unwrap();
312        assert!(msg.contains("Position closed"));
313        assert!(msg.contains("stop_loss"));
314
315        // ForceClose
316        let tick = make_tick(TickAction::ForceClose {
317            reason: "regime_change".into(),
318        });
319        let msg = PlaybookLogger::format_notification(&tick, "BTC-USD").unwrap();
320        assert!(msg.contains("Force close"));
321        assert!(msg.contains("regime_change"));
322
323        // None => no notification
324        let tick = make_tick(TickAction::None);
325        assert!(PlaybookLogger::format_notification(&tick, "BTC-USD").is_none());
326
327        // OrderCancelled => no notification
328        let tick = make_tick(TickAction::OrderCancelled {
329            order_id: "o-1".into(),
330            reason: "timeout".into(),
331        });
332        assert!(PlaybookLogger::format_notification(&tick, "BTC-USD").is_none());
333    }
334
335    // 5. format_notification includes regime change info
336    #[test]
337    fn test_format_notification_regime_change() {
338        let tick = make_tick_with_regime_change(TickAction::ForceClose {
339            reason: "regime_change".into(),
340        });
341        let msg = PlaybookLogger::format_notification(&tick, "ETH-USD").unwrap();
342        assert!(msg.contains("Regime changed"));
343        assert!(msg.contains("bull"));
344        assert!(msg.contains("bear"));
345    }
346
347    // 6. log_tick writes JSONL to temp dir
348    #[test]
349    fn test_log_tick_writes_jsonl() {
350        let test_dir = PathBuf::from(format!("/tmp/test-playbook-logs-{}", std::process::id()));
351        let _ = std::fs::remove_dir_all(&test_dir);
352
353        let logger = PlaybookLogger::new(test_dir.clone(), "BTC-USD");
354
355        let tick = TickResult {
356            regime: "bull".into(),
357            regime_changed: false,
358            previous_regime: None,
359            fsm_state: "InPosition".into(),
360            action: TickAction::OrderPlaced {
361                order_id: "o-1".into(),
362                side: "buy".into(),
363                size: 500.0,
364            },
365            triggered_rules: vec!["buy_momentum".into()],
366        };
367
368        logger.log_tick(&tick, Some(51000.0), Some(50000.0));
369
370        // Find the written file
371        let entries: Vec<_> = std::fs::read_dir(&test_dir)
372            .expect("log dir should exist")
373            .filter_map(|e| e.ok())
374            .collect();
375        assert_eq!(entries.len(), 1, "should have exactly one log file");
376
377        let content = std::fs::read_to_string(entries[0].path()).unwrap();
378        let lines: Vec<&str> = content.trim().lines().collect();
379        assert_eq!(lines.len(), 1, "should have exactly one JSONL line");
380
381        let parsed: PlaybookTickLog = serde_json::from_str(lines[0]).unwrap();
382        assert_eq!(parsed.symbol, "BTC-USD");
383        assert_eq!(parsed.regime, "bull");
384        assert!(parsed.action.starts_with("order_placed:"));
385        assert_eq!(parsed.entry_price, Some(50000.0));
386        assert_eq!(parsed.current_price, Some(51000.0));
387        assert_eq!(parsed.unrealized_pnl, Some(1000.0));
388
389        // Cleanup
390        let _ = std::fs::remove_dir_all(&test_dir);
391    }
392
393    // 7. log_tick with None prices produces null pnl
394    #[test]
395    fn test_log_tick_no_prices() {
396        let test_dir = PathBuf::from(format!(
397            "/tmp/test-playbook-logs-noprice-{}",
398            std::process::id()
399        ));
400        let _ = std::fs::remove_dir_all(&test_dir);
401
402        let logger = PlaybookLogger::new(test_dir.clone(), "ETH-USD");
403
404        let tick = TickResult {
405            regime: "neutral".into(),
406            regime_changed: false,
407            previous_regime: None,
408            fsm_state: "Idle".into(),
409            action: TickAction::None,
410            triggered_rules: vec![],
411        };
412
413        logger.log_tick(&tick, None, None);
414
415        let entries: Vec<_> = std::fs::read_dir(&test_dir)
416            .unwrap()
417            .filter_map(|e| e.ok())
418            .collect();
419        let content = std::fs::read_to_string(entries[0].path()).unwrap();
420        let parsed: PlaybookTickLog = serde_json::from_str(content.trim()).unwrap();
421        assert_eq!(parsed.unrealized_pnl, None);
422        assert_eq!(parsed.entry_price, None);
423        assert_eq!(parsed.current_price, None);
424
425        let _ = std::fs::remove_dir_all(&test_dir);
426    }
427}