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.rolls_today = 0;
164    state.sim = Some(SimLedger {
165        starting_budget_usd: start,
166        realized_pnl_usd: 0.0,
167        closed_trades: vec![],
168    });
169}
170
171pub fn record_sim_entry(
172    rules_path: &Path,
173    state: &mut AgentState,
174    rules: &RulesConfig,
175    account_hash: &str,
176    kind: StrategyKind,
177    signal: &Value,
178) -> Result<Value> {
179    let roll_replacement = signal
180        .get("roll_replacement")
181        .and_then(|v| v.as_bool())
182        .unwrap_or(false);
183    if !roll_replacement
184        && rules.risk.max_trades_per_day > 0
185        && state.trades_capacity_used() >= rules.risk.max_trades_per_day
186    {
187        return Ok(json!({
188            "fill_status": "SKIPPED",
189            "reason": "max_trades_per_day reached",
190            "mode": "simulate",
191        }));
192    }
193
194    let params = signal
195        .get("params")
196        .cloned()
197        .context("signal missing params")?;
198    let margin = estimate_order_margin(&json!({}), kind, &params)?;
199    if margin > rules.risk.max_risk_per_trade_usd {
200        return Ok(json!({
201            "fill_status": "SKIPPED",
202            "reason": "max_risk_per_trade_usd exceeded",
203            "required_margin_usd": margin,
204            "mode": "simulate",
205        }));
206    }
207    let reserved = state.reserved_risk_usd();
208    if reserved + margin > rules.risk.max_portfolio_risk_usd {
209        return Ok(json!({
210            "fill_status": "SKIPPED",
211            "reason": "max_portfolio_risk_usd exceeded",
212            "mode": "simulate",
213        }));
214    }
215
216    let position_id = signal
217        .get("position_id")
218        .and_then(|v| v.as_str())
219        .context("signal missing position_id")?
220        .to_string();
221    if state.open_positions.contains_key(&position_id) {
222        return Ok(json!({
223            "fill_status": "SKIPPED",
224            "reason": "position already open",
225            "position_id": position_id,
226            "mode": "simulate",
227        }));
228    }
229
230    let credit = signal
231        .get("estimated_credit")
232        .and_then(|v| v.as_f64())
233        .or_else(|| params.get("limit_credit").and_then(|v| v.as_f64()))
234        .unwrap_or(0.0);
235    let underlying = params
236        .get("underlying")
237        .and_then(|v| v.as_str())
238        .unwrap_or("")
239        .to_string();
240    let expiry = params
241        .get("expiry")
242        .and_then(|v| v.as_str())
243        .unwrap_or("")
244        .to_string();
245    let contracts = params
246        .get("contracts")
247        .and_then(|v| v.as_f64())
248        .unwrap_or(1.0)
249        .round()
250        .max(1.0) as u32;
251
252    ensure_ledger(state, rules);
253    if !roll_replacement {
254        state.trades_today += 1;
255    }
256    let rolls_used = signal
257        .get("rolls_used")
258        .and_then(|v| v.as_u64())
259        .unwrap_or(0) as u32;
260    let last_roll_at = if roll_replacement {
261        Some(Utc::now())
262    } else {
263        None
264    };
265    state.open_positions.insert(
266        position_id.clone(),
267        TrackedPosition {
268            position_id: position_id.clone(),
269            account_hash: account_hash.to_string(),
270            underlying: underlying.clone(),
271            expiry: expiry.clone(),
272            strategy: kind.as_str().to_string(),
273            opened_at: Utc::now(),
274            entry_credit: Some(credit),
275            max_loss_usd: margin,
276            contracts,
277            entry_params: Some(params.clone()),
278            peak_profit_pct: None,
279            entry_pop_pct: signal
280                .pointer("/market_context/spread_pop_pct")
281                .and_then(|v| v.as_f64()),
282            entry_short_delta: signal
283                .pointer("/market_context/short_delta")
284                .and_then(|v| v.as_f64())
285                .map(f64::abs),
286            rolls_used,
287            last_roll_at,
288            ..Default::default()
289        },
290    );
291
292    let detail = json!({
293        "fill_status": "FILLED",
294        "mode": "simulate",
295        "position_id": position_id,
296        "entry_credit": credit,
297        "contracts": contracts,
298        "max_loss_usd": margin,
299        "signal": signal,
300    });
301    state.record_action("sim_entry", detail.clone());
302    journal::append_event(rules_path, true, "sim_entry_filled", detail.clone())?;
303    Ok(detail)
304}
305
306pub fn record_sim_exit(
307    rules_path: &Path,
308    state: &mut AgentState,
309    rules: &RulesConfig,
310    position_id: &str,
311    exit_reason: &str,
312    mark: &SpreadMark,
313    signal: &Value,
314) -> Result<Value> {
315    let tracked = state
316        .open_positions
317        .remove(position_id)
318        .with_context(|| format!("sim position {position_id} not found"))?;
319    let entry_credit = tracked.entry_credit.unwrap_or(mark.entry_credit);
320    let contracts = tracked.contracts.max(1) as f64;
321    let pnl_per_spread = (entry_credit - mark.debit_to_close) * 100.0;
322    let pnl_usd = pnl_per_spread * contracts;
323    let pnl_pct = if entry_credit > f64::EPSILON {
324        ((entry_credit - mark.debit_to_close) / entry_credit) * 100.0
325    } else {
326        0.0
327    };
328    let hold_days = (Utc::now() - tracked.opened_at).num_days().max(0) as u32;
329    let trade_id = format!("sim-{}-{}", position_id, Utc::now().timestamp());
330
331    let ledger = ensure_ledger(state, rules);
332    ledger.realized_pnl_usd += pnl_usd;
333    ledger.closed_trades.push(ClosedSimTrade {
334        trade_id: trade_id.clone(),
335        position_id: position_id.to_string(),
336        underlying: tracked.underlying.clone(),
337        expiry: tracked.expiry.clone(),
338        strategy: tracked.strategy.clone(),
339        contracts: tracked.contracts,
340        entry_credit,
341        exit_debit: mark.debit_to_close,
342        opened_at: tracked.opened_at,
343        closed_at: Utc::now(),
344        pnl_usd,
345        pnl_pct,
346        exit_reason: exit_reason.to_string(),
347        hold_days,
348    });
349
350    let detail = json!({
351        "fill_status": "FILLED",
352        "mode": "simulate",
353        "position_id": position_id,
354        "exit_reason": exit_reason,
355        "pnl_usd": pnl_usd,
356        "pnl_pct": pnl_pct,
357        "mark": mark,
358        "signal": signal,
359    });
360    state.record_action("sim_exit", detail.clone());
361    journal::append_event(rules_path, true, "sim_exit_filled", detail.clone())?;
362    Ok(detail)
363}
364
365pub fn analysis_report(state: &AgentState, rules: &RulesConfig) -> Value {
366    let stats = compute_stats(state, rules);
367    let per_underlying: HashMap<String, f64> = state
368        .sim
369        .as_ref()
370        .map(|l| {
371            l.closed_trades
372                .iter()
373                .fold(HashMap::new(), |mut acc, t| {
374                    *acc.entry(t.underlying.clone()).or_insert(0.0) += t.pnl_usd;
375                    acc
376                })
377        })
378        .unwrap_or_default();
379    json!({
380        "mode": "simulate",
381        "stats": stats,
382        "per_underlying_pnl": per_underlying,
383        "open_positions": state.open_positions.values().collect::<Vec<_>>(),
384    })
385}
386
387#[cfg(test)]
388mod tests {
389    #[test]
390    fn spread_pnl_from_credit_and_debit() {
391        let pnl_per: f64 = (1.0 - 0.4) * 100.0;
392        assert!((pnl_per - 60.0).abs() < 0.01);
393    }
394}