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.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, ¶ms)?;
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 },
260 );
261
262 let detail = json!({
263 "fill_status": "FILLED",
264 "mode": "simulate",
265 "position_id": position_id,
266 "entry_credit": credit,
267 "contracts": contracts,
268 "max_loss_usd": margin,
269 "signal": signal,
270 });
271 state.record_action("sim_entry", detail.clone());
272 journal::append_event(rules_path, true, "sim_entry_filled", detail.clone())?;
273 Ok(detail)
274}
275
276pub fn record_sim_exit(
277 rules_path: &Path,
278 state: &mut AgentState,
279 rules: &RulesConfig,
280 position_id: &str,
281 exit_reason: &str,
282 mark: &SpreadMark,
283 signal: &Value,
284) -> Result<Value> {
285 let tracked = state
286 .open_positions
287 .remove(position_id)
288 .with_context(|| format!("sim position {position_id} not found"))?;
289 let entry_credit = tracked.entry_credit.unwrap_or(mark.entry_credit);
290 let contracts = tracked.contracts.max(1) as f64;
291 let pnl_per_spread = (entry_credit - mark.debit_to_close) * 100.0;
292 let pnl_usd = pnl_per_spread * contracts;
293 let pnl_pct = if entry_credit > f64::EPSILON {
294 ((entry_credit - mark.debit_to_close) / entry_credit) * 100.0
295 } else {
296 0.0
297 };
298 let hold_days = (Utc::now() - tracked.opened_at).num_days().max(0) as u32;
299 let trade_id = format!("sim-{}-{}", position_id, Utc::now().timestamp());
300
301 let ledger = ensure_ledger(state, rules);
302 ledger.realized_pnl_usd += pnl_usd;
303 ledger.closed_trades.push(ClosedSimTrade {
304 trade_id: trade_id.clone(),
305 position_id: position_id.to_string(),
306 underlying: tracked.underlying.clone(),
307 expiry: tracked.expiry.clone(),
308 strategy: tracked.strategy.clone(),
309 contracts: tracked.contracts,
310 entry_credit,
311 exit_debit: mark.debit_to_close,
312 opened_at: tracked.opened_at,
313 closed_at: Utc::now(),
314 pnl_usd,
315 pnl_pct,
316 exit_reason: exit_reason.to_string(),
317 hold_days,
318 });
319
320 let detail = json!({
321 "fill_status": "FILLED",
322 "mode": "simulate",
323 "position_id": position_id,
324 "exit_reason": exit_reason,
325 "pnl_usd": pnl_usd,
326 "pnl_pct": pnl_pct,
327 "mark": mark,
328 "signal": signal,
329 });
330 state.record_action("sim_exit", detail.clone());
331 journal::append_event(rules_path, true, "sim_exit_filled", detail.clone())?;
332 Ok(detail)
333}
334
335pub fn analysis_report(state: &AgentState, rules: &RulesConfig) -> Value {
336 let stats = compute_stats(state, rules);
337 let per_underlying: HashMap<String, f64> = state
338 .sim
339 .as_ref()
340 .map(|l| {
341 l.closed_trades
342 .iter()
343 .fold(HashMap::new(), |mut acc, t| {
344 *acc.entry(t.underlying.clone()).or_insert(0.0) += t.pnl_usd;
345 acc
346 })
347 })
348 .unwrap_or_default();
349 json!({
350 "mode": "simulate",
351 "stats": stats,
352 "per_underlying_pnl": per_underlying,
353 "open_positions": state.open_positions.values().collect::<Vec<_>>(),
354 })
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 #[test]
362 fn spread_pnl_from_credit_and_debit() {
363 let pnl_per: f64 = (1.0 - 0.4) * 100.0;
364 assert!((pnl_per - 60.0).abs() < 0.01);
365 }
366}