1use 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, ¶ms)?;
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}