1use 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
22pub 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
42pub 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
71pub 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
128pub 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
133pub 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; }
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 assert!((unrealized_from_snapshot(&snap) - 40.0).abs() < 1e-9);
202 }
203}