Skip to main content

schwab_cli/agent/backtest/
report.rs

1//! Aggregate options backtest journal + ledger into a report.
2
3use std::collections::HashMap;
4use std::path::Path;
5
6use anyhow::Result;
7use serde_json::{json, Value};
8
9use crate::agent::journal;
10use crate::agent::paths::backtest_state_path;
11use crate::agent::sim::compute_stats;
12use crate::agent::state::load_state;
13use crate::rules::RulesConfig;
14
15pub fn build_backtest_report(rules_path: &Path, rules: &RulesConfig) -> Result<Value> {
16    let state = load_state(&backtest_state_path(rules_path)).unwrap_or_default();
17    let stats = compute_stats(&state, rules);
18    let events = journal::read_all_backtest(rules_path)?;
19
20    let mut event_counts: HashMap<String, u32> = HashMap::new();
21    let mut exit_reasons: HashMap<String, u32> = HashMap::new();
22    let mut monthly_pnl: HashMap<String, f64> = HashMap::new();
23    let mut trading_days = 0u32;
24    let mut entries = 0u32;
25    let mut rolls = 0u32;
26
27    for e in &events {
28        let Some(t) = e.get("type").and_then(|v| v.as_str()) else {
29            continue;
30        };
31        *event_counts.entry(t.to_string()).or_insert(0) += 1;
32        let payload = e.get("payload").cloned().unwrap_or(json!({}));
33        match t {
34            "backtest_day_summary" => {
35                trading_days += 1;
36            }
37            "sim_entry_filled" => entries += 1,
38            "defensive_roll" => rolls += 1,
39            "sim_exit_filled" => {
40                let reason = payload
41                    .get("exit_reason")
42                    .and_then(|v| v.as_str())
43                    .unwrap_or("unknown")
44                    .to_string();
45                *exit_reasons.entry(reason).or_insert(0) += 1;
46                let pnl = payload.get("pnl_usd").and_then(|v| v.as_f64()).unwrap_or(0.0);
47                if let Some(ts) = e.get("ts").and_then(|v| v.as_str()) {
48                    if let Some(month) = ts.get(0..7) {
49                        *monthly_pnl.entry(month.to_string()).or_insert(0.0) += pnl;
50                    }
51                }
52            }
53            _ => {}
54        }
55    }
56
57    Ok(json!({
58        "agent_id": rules.agent_id,
59        "pricing_model": "black_scholes_vix_iv",
60        "caveat": "Synthetic BS marks using VIX as IV proxy — not OPRA historical fills. Use for gate/threshold research, not absolute expectancy.",
61        "ledger_stats": stats,
62        "event_counts": event_counts,
63        "exit_reason_counts": exit_reasons,
64        "monthly_closed_pnl_usd": monthly_pnl,
65        "trading_days_observed": trading_days,
66        "entries": entries,
67        "defensive_rolls": rolls,
68        "open_positions": state.open_positions.len(),
69        "closed_trades": state.sim.as_ref().map(|s| s.closed_trades.len()).unwrap_or(0),
70    }))
71}