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#[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 pub entry_price: Option<f64>,
25 pub current_price: Option<f64>,
26 pub unrealized_pnl: Option<f64>,
27}
28
29pub 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 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 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 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 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
147fn 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#[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 #[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 #[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 #[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 #[test]
285 fn test_format_notification_messages() {
286 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 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 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 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 let tick = make_tick(TickAction::None);
325 assert!(PlaybookLogger::format_notification(&tick, "BTC-USD").is_none());
326
327 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 #[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 #[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 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 let _ = std::fs::remove_dir_all(&test_dir);
391 }
392
393 #[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}