Skip to main content

schwab_cli/agent/
sim.rs

1//! Paper options simulation: virtual fills from live chain marks (no broker orders).
2
3use std::collections::HashMap;
4use std::path::Path;
5
6use anyhow::{Context, Result};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use serde_json::{json, Value};
10
11use crate::options::validate::estimate_order_margin;
12use crate::options::StrategyKind;
13use crate::rules::RulesConfig;
14
15use super::exits::SpreadMark;
16use super::journal;
17use super::state::{AgentState, TrackedPosition};
18
19#[derive(Debug, Clone, Serialize, Deserialize, Default)]
20pub struct SimLedger {
21    pub starting_budget_usd: f64,
22    #[serde(default)]
23    pub realized_pnl_usd: f64,
24    #[serde(default)]
25    pub closed_trades: Vec<ClosedSimTrade>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ClosedSimTrade {
30    pub trade_id: String,
31    pub position_id: String,
32    pub underlying: String,
33    pub expiry: String,
34    pub strategy: String,
35    pub contracts: u32,
36    pub entry_credit: f64,
37    pub exit_debit: f64,
38    pub opened_at: DateTime<Utc>,
39    pub closed_at: DateTime<Utc>,
40    pub pnl_usd: f64,
41    pub pnl_pct: f64,
42    pub exit_reason: String,
43    pub hold_days: u32,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct SimStats {
48    pub starting_budget_usd: f64,
49    pub realized_pnl_usd: f64,
50    pub open_risk_usd: f64,
51    pub open_positions: usize,
52    pub closed_trades: usize,
53    pub roi_pct: f64,
54    pub win_rate_pct: f64,
55    pub avg_win_usd: f64,
56    pub avg_loss_usd: f64,
57    pub max_drawdown_pct: f64,
58    pub expectancy_usd: f64,
59    #[serde(default)]
60    pub exit_reason_counts: HashMap<String, u32>,
61}
62
63pub fn ensure_ledger<'a>(state: &'a mut AgentState, rules: &RulesConfig) -> &'a mut SimLedger {
64    if state.sim.is_none() {
65        let start = rules
66            .simulation
67            .as_ref()
68            .map(|s| s.starting_budget_usd)
69            .unwrap_or(rules.risk.max_portfolio_risk_usd);
70        state.sim = Some(SimLedger {
71            starting_budget_usd: start,
72            realized_pnl_usd: 0.0,
73            closed_trades: vec![],
74        });
75    }
76    state.sim.as_mut().expect("sim ledger")
77}
78
79pub fn compute_stats(state: &AgentState, rules: &RulesConfig) -> SimStats {
80    let ledger = state.sim.as_ref();
81    let starting = ledger
82        .map(|l| l.starting_budget_usd)
83        .unwrap_or_else(|| {
84            rules
85                .simulation
86                .as_ref()
87                .map(|s| s.starting_budget_usd)
88                .unwrap_or(rules.risk.max_portfolio_risk_usd)
89        });
90    let realized = ledger.map(|l| l.realized_pnl_usd).unwrap_or(0.0);
91    let closed = ledger.map(|l| l.closed_trades.as_slice()).unwrap_or(&[]);
92    let wins: Vec<f64> = closed.iter().filter(|t| t.pnl_usd > 0.0).map(|t| t.pnl_usd).collect();
93    let losses: Vec<f64> = closed
94        .iter()
95        .filter(|t| t.pnl_usd < 0.0)
96        .map(|t| t.pnl_usd)
97        .collect();
98    let win_rate = if closed.is_empty() {
99        0.0
100    } else {
101        (wins.len() as f64 / closed.len() as f64) * 100.0
102    };
103    let avg_win = if wins.is_empty() {
104        0.0
105    } else {
106        wins.iter().sum::<f64>() / wins.len() as f64
107    };
108    let avg_loss = if losses.is_empty() {
109        0.0
110    } else {
111        losses.iter().sum::<f64>() / losses.len() as f64
112    };
113    let expectancy = if closed.is_empty() {
114        0.0
115    } else {
116        closed.iter().map(|t| t.pnl_usd).sum::<f64>() / closed.len() as f64
117    };
118    let mut exit_reason_counts = HashMap::new();
119    for t in closed {
120        *exit_reason_counts.entry(t.exit_reason.clone()).or_insert(0) += 1;
121    }
122    let mut peak = starting;
123    let mut equity = starting;
124    let mut max_dd = 0.0f64;
125    for t in closed {
126        equity += t.pnl_usd;
127        peak = peak.max(equity);
128        if peak > 0.0 {
129            let dd = ((peak - equity) / peak) * 100.0;
130            max_dd = max_dd.max(dd);
131        }
132    }
133    SimStats {
134        starting_budget_usd: starting,
135        realized_pnl_usd: realized,
136        open_risk_usd: state.open_risk_usd(),
137        open_positions: state.open_positions.len(),
138        closed_trades: closed.len(),
139        roi_pct: if starting > 0.0 {
140            (realized / starting) * 100.0
141        } else {
142            0.0
143        },
144        win_rate_pct: win_rate,
145        avg_win_usd: avg_win,
146        avg_loss_usd: avg_loss,
147        max_drawdown_pct: max_dd,
148        expectancy_usd: expectancy,
149        exit_reason_counts,
150    }
151}
152
153pub fn reset_sim(state: &mut AgentState, rules: &RulesConfig) {
154    let start = rules
155        .simulation
156        .as_ref()
157        .map(|s| s.starting_budget_usd)
158        .unwrap_or(rules.risk.max_portfolio_risk_usd);
159    state.open_positions.clear();
160    state.pending_orders.clear();
161    state.pending_order_ids.clear();
162    state.trades_today = 0;
163    state.sim = Some(SimLedger {
164        starting_budget_usd: start,
165        realized_pnl_usd: 0.0,
166        closed_trades: vec![],
167    });
168}
169
170pub fn record_sim_entry(
171    rules_path: &Path,
172    state: &mut AgentState,
173    rules: &RulesConfig,
174    account_hash: &str,
175    kind: StrategyKind,
176    signal: &Value,
177) -> Result<Value> {
178    if state.trades_capacity_used() >= rules.risk.max_trades_per_day {
179        return Ok(json!({
180            "fill_status": "SKIPPED",
181            "reason": "max_trades_per_day reached",
182            "mode": "simulate",
183        }));
184    }
185
186    let params = signal
187        .get("params")
188        .cloned()
189        .context("signal missing params")?;
190    let margin = estimate_order_margin(&json!({}), kind, &params)?;
191    if margin > rules.risk.max_risk_per_trade_usd {
192        return Ok(json!({
193            "fill_status": "SKIPPED",
194            "reason": "max_risk_per_trade_usd exceeded",
195            "required_margin_usd": margin,
196            "mode": "simulate",
197        }));
198    }
199    let reserved = state.reserved_risk_usd();
200    if reserved + margin > rules.risk.max_portfolio_risk_usd {
201        return Ok(json!({
202            "fill_status": "SKIPPED",
203            "reason": "max_portfolio_risk_usd exceeded",
204            "mode": "simulate",
205        }));
206    }
207
208    let position_id = signal
209        .get("position_id")
210        .and_then(|v| v.as_str())
211        .context("signal missing position_id")?
212        .to_string();
213    if state.open_positions.contains_key(&position_id) {
214        return Ok(json!({
215            "fill_status": "SKIPPED",
216            "reason": "position already open",
217            "position_id": position_id,
218            "mode": "simulate",
219        }));
220    }
221
222    let credit = signal
223        .get("estimated_credit")
224        .and_then(|v| v.as_f64())
225        .or_else(|| params.get("limit_credit").and_then(|v| v.as_f64()))
226        .unwrap_or(0.0);
227    let underlying = params
228        .get("underlying")
229        .and_then(|v| v.as_str())
230        .unwrap_or("")
231        .to_string();
232    let expiry = params
233        .get("expiry")
234        .and_then(|v| v.as_str())
235        .unwrap_or("")
236        .to_string();
237    let contracts = params
238        .get("contracts")
239        .and_then(|v| v.as_f64())
240        .unwrap_or(1.0)
241        .round()
242        .max(1.0) as u32;
243
244    ensure_ledger(state, rules);
245    state.trades_today += 1;
246    state.open_positions.insert(
247        position_id.clone(),
248        TrackedPosition {
249            position_id: position_id.clone(),
250            account_hash: account_hash.to_string(),
251            underlying: underlying.clone(),
252            expiry: expiry.clone(),
253            strategy: kind.as_str().to_string(),
254            opened_at: Utc::now(),
255            entry_credit: Some(credit),
256            max_loss_usd: margin,
257            contracts,
258            entry_params: Some(params.clone()),
259            peak_profit_pct: None,
260            entry_pop_pct: signal
261                .pointer("/market_context/spread_pop_pct")
262                .and_then(|v| v.as_f64()),
263            entry_short_delta: signal
264                .pointer("/market_context/short_delta")
265                .and_then(|v| v.as_f64())
266                .map(f64::abs),
267        },
268    );
269
270    let detail = json!({
271        "fill_status": "FILLED",
272        "mode": "simulate",
273        "position_id": position_id,
274        "entry_credit": credit,
275        "contracts": contracts,
276        "max_loss_usd": margin,
277        "signal": signal,
278    });
279    state.record_action("sim_entry", detail.clone());
280    journal::append_event(rules_path, true, "sim_entry_filled", detail.clone())?;
281    Ok(detail)
282}
283
284pub fn record_sim_exit(
285    rules_path: &Path,
286    state: &mut AgentState,
287    rules: &RulesConfig,
288    position_id: &str,
289    exit_reason: &str,
290    mark: &SpreadMark,
291    signal: &Value,
292) -> Result<Value> {
293    let tracked = state
294        .open_positions
295        .remove(position_id)
296        .with_context(|| format!("sim position {position_id} not found"))?;
297    let entry_credit = tracked.entry_credit.unwrap_or(mark.entry_credit);
298    let contracts = tracked.contracts.max(1) as f64;
299    let pnl_per_spread = (entry_credit - mark.debit_to_close) * 100.0;
300    let pnl_usd = pnl_per_spread * contracts;
301    let pnl_pct = if entry_credit > f64::EPSILON {
302        ((entry_credit - mark.debit_to_close) / entry_credit) * 100.0
303    } else {
304        0.0
305    };
306    let hold_days = (Utc::now() - tracked.opened_at).num_days().max(0) as u32;
307    let trade_id = format!("sim-{}-{}", position_id, Utc::now().timestamp());
308
309    let ledger = ensure_ledger(state, rules);
310    ledger.realized_pnl_usd += pnl_usd;
311    ledger.closed_trades.push(ClosedSimTrade {
312        trade_id: trade_id.clone(),
313        position_id: position_id.to_string(),
314        underlying: tracked.underlying.clone(),
315        expiry: tracked.expiry.clone(),
316        strategy: tracked.strategy.clone(),
317        contracts: tracked.contracts,
318        entry_credit,
319        exit_debit: mark.debit_to_close,
320        opened_at: tracked.opened_at,
321        closed_at: Utc::now(),
322        pnl_usd,
323        pnl_pct,
324        exit_reason: exit_reason.to_string(),
325        hold_days,
326    });
327
328    let detail = json!({
329        "fill_status": "FILLED",
330        "mode": "simulate",
331        "position_id": position_id,
332        "exit_reason": exit_reason,
333        "pnl_usd": pnl_usd,
334        "pnl_pct": pnl_pct,
335        "mark": mark,
336        "signal": signal,
337    });
338    state.record_action("sim_exit", detail.clone());
339    journal::append_event(rules_path, true, "sim_exit_filled", detail.clone())?;
340    Ok(detail)
341}
342
343pub fn analysis_report(state: &AgentState, rules: &RulesConfig) -> Value {
344    let stats = compute_stats(state, rules);
345    let per_underlying: HashMap<String, f64> = state
346        .sim
347        .as_ref()
348        .map(|l| {
349            l.closed_trades
350                .iter()
351                .fold(HashMap::new(), |mut acc, t| {
352                    *acc.entry(t.underlying.clone()).or_insert(0.0) += t.pnl_usd;
353                    acc
354                })
355        })
356        .unwrap_or_default();
357    json!({
358        "mode": "simulate",
359        "stats": stats,
360        "per_underlying_pnl": per_underlying,
361        "open_positions": state.open_positions.values().collect::<Vec<_>>(),
362    })
363}
364
365#[cfg(test)]
366mod tests {
367    #[test]
368    fn spread_pnl_from_credit_and_debit() {
369        let pnl_per: f64 = (1.0 - 0.4) * 100.0;
370        assert!((pnl_per - 60.0).abs() < 0.01);
371    }
372}