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