Skip to main content

schwab_cli/agent/
risk.rs

1//! Options sleeve risk: drawdown halt from high-water mark.
2
3use serde::{Deserialize, Serialize};
4use serde_json::{json, Value};
5
6use crate::rules::RulesConfig;
7
8use super::state::{AgentState, TrackedPosition};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct DrawdownStatus {
12    pub current_equity_usd: f64,
13    pub peak_equity_usd: f64,
14    pub drawdown_pct: f64,
15    pub halt_threshold_pct: f64,
16    pub halted: bool,
17    pub sleeve_base_usd: f64,
18    pub realized_pnl_usd: f64,
19    pub unrealized_pnl_usd: f64,
20}
21
22/// Sleeve base for HWM: explicit `drawdown_sleeve_usd`, else sim budget, else portfolio risk cap.
23pub fn sleeve_base_usd(state: &AgentState, rules: &RulesConfig) -> f64 {
24    if let Some(v) = rules.risk.drawdown_sleeve_usd.filter(|v| *v > 0.0) {
25        return v;
26    }
27    if let Some(sim) = &state.sim {
28        if sim.starting_budget_usd > 0.0 {
29            return sim.starting_budget_usd;
30        }
31    }
32    rules.risk.max_portfolio_risk_usd.max(0.01)
33}
34
35pub fn realized_pnl_usd(state: &AgentState) -> f64 {
36    if let Some(sim) = &state.sim {
37        return sim.realized_pnl_usd;
38    }
39    state.cumulative_realized_pnl_usd
40}
41
42/// Unrealized $ from monitor snapshots (`profit_pct` × credit × 100 × contracts).
43pub fn unrealized_pnl_from_monitors(monitored: &[Value]) -> f64 {
44    monitored.iter().map(unrealized_from_snapshot).sum()
45}
46
47fn unrealized_from_snapshot(snap: &Value) -> f64 {
48    let profit_pct = snap.get("profit_pct").and_then(|v| v.as_f64()).unwrap_or(0.0);
49    let credit = snap
50        .get("entry_credit")
51        .and_then(|v| v.as_f64())
52        .unwrap_or(0.0);
53    let contracts = snap
54        .get("contracts")
55        .and_then(|v| v.as_u64())
56        .unwrap_or(1)
57        .max(1) as f64;
58    if credit <= 0.0 {
59        return 0.0;
60    }
61    (profit_pct / 100.0) * credit * 100.0 * contracts
62}
63
64pub fn compute_sleeve_equity(state: &AgentState, rules: &RulesConfig, monitored: &[Value]) -> f64 {
65    let base = sleeve_base_usd(state, rules);
66    let realized = realized_pnl_usd(state);
67    let unrealized = unrealized_pnl_from_monitors(monitored);
68    base + realized + unrealized
69}
70
71/// Update HWM / drawdown halt. Clears only prior drawdown-sourced halt reasons.
72pub fn update_drawdown(
73    state: &mut AgentState,
74    rules: &RulesConfig,
75    monitored: &[Value],
76) -> DrawdownStatus {
77    let sleeve_base = sleeve_base_usd(state, rules);
78    let realized = realized_pnl_usd(state);
79    let unrealized = unrealized_pnl_from_monitors(monitored);
80    let current = compute_sleeve_equity(state, rules, monitored);
81
82    if state.sleeve_peak_equity_usd <= 0.0 {
83        state.sleeve_peak_equity_usd = current.max(sleeve_base);
84    }
85    if current > state.sleeve_peak_equity_usd {
86        state.sleeve_peak_equity_usd = current;
87    }
88
89    let peak = state.sleeve_peak_equity_usd.max(0.01);
90    let drawdown_pct = if current >= peak {
91        0.0
92    } else {
93        ((peak - current) / peak) * 100.0
94    };
95
96    let halt_threshold = rules.risk.max_drawdown_halt_pct.unwrap_or(0.0);
97    let halted = halt_threshold > 0.0 && drawdown_pct >= halt_threshold;
98
99    if halted {
100        state.trading_halted_reason = Some(format!(
101            "drawdown halt: {:.1}% >= {:.1}%",
102            drawdown_pct, halt_threshold
103        ));
104    } else if state
105        .trading_halted_reason
106        .as_deref()
107        .is_some_and(|r| r.starts_with("drawdown halt"))
108    {
109        state.trading_halted_reason = None;
110    }
111
112    DrawdownStatus {
113        current_equity_usd: current,
114        peak_equity_usd: peak,
115        drawdown_pct,
116        halt_threshold_pct: halt_threshold,
117        halted,
118        sleeve_base_usd: sleeve_base,
119        realized_pnl_usd: realized,
120        unrealized_pnl_usd: unrealized,
121    }
122}
123
124pub fn drawdown_to_json(status: &DrawdownStatus) -> Value {
125    serde_json::to_value(status).unwrap_or(json!({}))
126}
127
128/// Credit-spread realized PnL in USD.
129pub fn credit_spread_pnl_usd(entry_credit: f64, debit_to_close: f64, contracts: u32) -> f64 {
130    (entry_credit - debit_to_close) * 100.0 * contracts.max(1) as f64
131}
132
133/// Record live (non-sim) realized PnL when a position closes.
134pub fn record_live_realized_pnl(
135    state: &mut AgentState,
136    tracked: &TrackedPosition,
137    debit_to_close: f64,
138) {
139    if state.sim.is_some() {
140        return; // sim ledger owns realized PnL
141    }
142    let entry = tracked.entry_credit.unwrap_or(0.0);
143    let pnl = credit_spread_pnl_usd(entry, debit_to_close, tracked.contracts);
144    state.cumulative_realized_pnl_usd += pnl;
145}
146
147    #[cfg(test)]
148mod tests {
149    use super::*;
150    use serde_json::json;
151
152    use crate::rules::RulesConfig;
153    use super::super::state::AgentState;
154
155    #[test]
156    fn drawdown_halt_triggers_and_clears() {
157        let rules: RulesConfig = serde_json::from_value(json!({
158            "version": 1,
159            "agent_id": "t",
160            "accounts": [{"hash": "H", "enabled": true}],
161            "watchlist": ["SPY"],
162            "risk": {
163                "max_portfolio_risk_usd": 500,
164                "max_risk_per_trade_usd": 500,
165                "max_trades_per_day": 1,
166                "allowed_underlyings": ["SPY"],
167                "blocked_events": [],
168                "max_drawdown_halt_pct": 10.0,
169                "drawdown_sleeve_usd": 1000.0
170            }
171        }))
172        .unwrap();
173
174        let mut state = AgentState {
175            agent_id: "t".into(),
176            cumulative_realized_pnl_usd: -150.0,
177            ..Default::default()
178        };
179        let status = update_drawdown(&mut state, &rules, &[]);
180        assert!(status.halted, "dd={}", status.drawdown_pct);
181        assert!(state
182            .trading_halted_reason
183            .as_deref()
184            .unwrap()
185            .starts_with("drawdown halt"));
186
187        state.cumulative_realized_pnl_usd = 50.0;
188        let recovered = update_drawdown(&mut state, &rules, &[]);
189        assert!(!recovered.halted);
190        assert!(state.trading_halted_reason.is_none());
191    }
192
193    #[test]
194    fn unrealized_from_monitor_snapshot() {
195        let snap = json!({
196            "profit_pct": 50.0,
197            "entry_credit": 0.40,
198            "contracts": 2
199        });
200        // 0.5 * 0.40 * 100 * 2 = 40
201        assert!((unrealized_from_snapshot(&snap) - 40.0).abs() < 1e-9);
202    }
203}