1use std::collections::HashMap;
4use std::path::Path;
5
6use anyhow::{Context, Result};
7use chrono::{NaiveDate, Utc};
8use serde_json::{json, Value};
9
10use crate::agent::exits::{evaluate_all_exits, SpreadMark};
11use crate::agent::journal;
12use crate::agent::paths::{backtest_cache_path, backtest_state_path};
13use crate::agent::regime::{classify_options_regime, OptionsRegimeClass};
14use crate::agent::roll::{
15 roll_biased_dte_min, roll_biased_max_width, roll_biased_short_delta_max, roll_eligible,
16 roll_money_ok, RollEligibility,
17};
18use crate::agent::sim::{compute_stats, ensure_ledger, ClosedSimTrade};
19use crate::agent::spread_analytics::{compute_vertical_analytics, VerticalAnalyticsInput};
20use crate::agent::state::{save_state, AgentState, TrackedPosition};
21use crate::options::StrategyKind;
22use crate::rules::{OptionsRegimeConfig, RulesConfig};
23
24use super::bs::vertical_debit_to_close;
25use super::cache::BacktestCache;
26use super::synth::{
27 pick_iron_condor, pick_vertical, vertical_passes_entry_gates, SynthCondor, SynthVertical,
28};
29
30pub struct BacktestRunOptions {
31 pub from: NaiveDate,
32 pub to: NaiveDate,
33 pub fresh: bool,
34}
35
36#[derive(Debug, Clone)]
37enum OpenKind {
38 Vertical(SynthVertical),
39 Condor(SynthCondor),
40}
41
42#[derive(Debug, Clone)]
43struct OpenBt {
44 id: String,
45 kind: OpenKind,
46 opened_on: NaiveDate,
47 entry_credit: f64,
48 max_loss_usd: f64,
49 contracts: u32,
50 peak_profit_pct: Option<f64>,
51 rolls_used: u32,
52 account_hash: String,
53 opened_at: chrono::DateTime<Utc>,
54}
55
56pub fn run_backtest(
57 rules_path: &Path,
58 rules: &RulesConfig,
59 options: BacktestRunOptions,
60) -> Result<Value> {
61 let cache_path = backtest_cache_path(rules_path);
62 let cache = BacktestCache::load(&cache_path).with_context(|| {
63 format!(
64 "missing cache {} — run: schwab agent backtest prefetch --rules-file {}",
65 cache_path.display(),
66 rules_path.display()
67 )
68 })?;
69
70 let state_path = backtest_state_path(rules_path);
71 let mut state = if options.fresh {
72 journal::clear_backtest_journal(rules_path)?;
73 let mut s = AgentState {
74 agent_id: rules.agent_id.clone(),
75 ..Default::default()
76 };
77 ensure_ledger(&mut s, rules);
78 s
79 } else {
80 let mut s = crate::agent::state::load_state(&state_path).unwrap_or_default();
81 s.agent_id = rules.agent_id.clone();
82 ensure_ledger(&mut s, rules);
83 s
84 };
85
86 let bench = rules.regime.benchmark_symbol.trim().to_uppercase();
87 let days = cache.trading_days(&bench, options.from, options.to)?;
88 let account = rules
89 .enabled_accounts()
90 .next()
91 .map(|a| a.hash.clone())
92 .unwrap_or_else(|| "BACKTEST".into());
93
94 let mut open: Vec<OpenBt> = Vec::new();
95 if options.fresh {
96 state.open_positions.clear();
97 state.trades_today = 0;
98 state.rolls_today = 0;
99 if let Some(ledger) = state.sim.as_mut() {
100 ledger.realized_pnl_usd = 0.0;
101 ledger.closed_trades.clear();
102 }
103 }
104
105 let mut skip_counts: HashMap<String, u32> = HashMap::new();
106 let mut day_summaries = Vec::new();
107
108 for day in &days {
109 state.reset_daily_if_needed(*day);
110 let as_of = day
111 .and_hms_opt(21, 0, 0)
112 .map(|t| t.and_utc())
113 .unwrap_or_else(Utc::now);
114
115 let mut still_open = Vec::new();
116 for pos in open.drain(..) {
117 match process_open(
118 rules_path,
119 rules,
120 &cache,
121 &mut state,
122 pos,
123 *day,
124 as_of,
125 &mut skip_counts,
126 )? {
127 Some(p) => still_open.push(p),
128 None => {}
129 }
130 }
131 open = still_open;
132 sync_open_to_state(&mut state, &open);
133
134 let closes_bench = cache.closes_through(&bench, *day);
135 let spot_bench = cache.close_on_date(&bench, *day).unwrap_or(0.0);
136 let (above50, above200) = sma_flags(&closes_bench, spot_bench);
137 let vix = cache.vix_on_date(&rules.regime.vix_symbol, *day);
138 let class = if rules.regime.enabled {
139 classify_options_regime(&rules.regime, vix, above50, above200)
140 } else {
141 OptionsRegimeClass::Neutral
142 };
143 let preferred = preferred_for(&rules.regime, class);
144 let pause = class == OptionsRegimeClass::Hostile
145 || vix.is_some_and(|v| v >= rules.regime.pause_entries_vix_above)
146 || rules
147 .regime
148 .pause_entries_vix_below
149 .is_some_and(|floor| vix.is_some_and(|v| v <= floor));
150 let blocked = !rules.risk.active_blocked_date_labels(*day).is_empty();
151
152 let mut entry_action = Value::Null;
153 if !pause && !blocked && preferred != "pause" && state.trading_halted_reason.is_none() {
154 if let Some(detail) = try_entry(
155 rules_path,
156 rules,
157 &cache,
158 &mut state,
159 &account,
160 *day,
161 as_of,
162 &preferred,
163 vix.unwrap_or(18.0),
164 &mut open,
165 &mut skip_counts,
166 )? {
167 entry_action = detail;
168 }
169 } else if pause {
170 *skip_counts.entry("vix_or_regime_pause".into()).or_insert(0) += 1;
171 } else if blocked {
172 *skip_counts.entry("blocked_dates".into()).or_insert(0) += 1;
173 }
174
175 sync_open_to_state(&mut state, &open);
176 let summary = json!({
177 "day": day.to_string(),
178 "regime": class.as_str(),
179 "preferred": preferred,
180 "vix": vix,
181 "open_positions": open.len(),
182 "entry": entry_action,
183 });
184 journal::append_backtest_event_at(
185 rules_path,
186 as_of,
187 "backtest_day_summary",
188 summary.clone(),
189 )?;
190 day_summaries.push(summary);
191 }
192
193 save_state(&state_path, &state)?;
194 let stats = compute_stats(&state, rules);
195 Ok(json!({
196 "agent_id": rules.agent_id,
197 "from": options.from.to_string(),
198 "to": options.to.to_string(),
199 "trading_days": days.len(),
200 "pricing_model": "black_scholes_vix_iv",
201 "caveat": "Synthetic BS marks using VIX as IV proxy — not OPRA historical fills",
202 "final_stats": stats,
203 "skip_reason_counts": skip_counts,
204 "open_positions": open.len(),
205 "day_summaries_tail": day_summaries.into_iter().rev().take(5).collect::<Vec<_>>(),
206 }))
207}
208
209fn preferred_for(cfg: &OptionsRegimeConfig, class: OptionsRegimeClass) -> String {
210 cfg.strategy_map
211 .get(class.as_str())
212 .cloned()
213 .unwrap_or_else(|| match class {
214 OptionsRegimeClass::BearishTrend => "call_credit".into(),
215 OptionsRegimeClass::HighVolChop => "iron_condor".into(),
216 OptionsRegimeClass::Hostile => "pause".into(),
217 _ => "put_credit".into(),
218 })
219}
220
221fn sma_flags(closes: &[f64], last: f64) -> (bool, bool) {
222 let sma = |n: usize| -> Option<f64> {
223 if closes.len() < n {
224 return None;
225 }
226 let slice = &closes[closes.len() - n..];
227 Some(slice.iter().sum::<f64>() / n as f64)
228 };
229 (
230 sma(50).map(|s| last >= s).unwrap_or(true),
231 sma(200).map(|s| last >= s).unwrap_or(true),
232 )
233}
234
235fn sync_open_to_state(state: &mut AgentState, open: &[OpenBt]) {
236 let ids: std::collections::HashSet<_> = open.iter().map(|o| o.id.clone()).collect();
237 state.open_positions.retain(|k, _| ids.contains(k));
238 for o in open {
239 if let Some(p) = state.open_positions.get_mut(&o.id) {
240 p.peak_profit_pct = o.peak_profit_pct;
241 p.rolls_used = o.rolls_used;
242 }
243 }
244}
245
246fn process_open(
247 rules_path: &Path,
248 rules: &RulesConfig,
249 cache: &BacktestCache,
250 state: &mut AgentState,
251 mut pos: OpenBt,
252 day: NaiveDate,
253 as_of: chrono::DateTime<Utc>,
254 skips: &mut HashMap<String, u32>,
255) -> Result<Option<OpenBt>> {
256 match &pos.kind {
257 OpenKind::Vertical(v) => {
258 let Some(spot) = cache.close_on_date(&v.underlying, day) else {
259 return Ok(Some(pos));
260 };
261 let iv = cache
262 .vix_on_date(&rules.regime.vix_symbol, day)
263 .unwrap_or(v.iv_pct);
264 let dte = (v.expiry - day).num_days().max(0);
265 let debit = vertical_debit_to_close(
266 v.is_put,
267 spot,
268 v.short_strike,
269 v.long_strike,
270 dte,
271 iv,
272 );
273 let profit_pct = if pos.entry_credit > f64::EPSILON {
274 ((pos.entry_credit - debit) / pos.entry_credit) * 100.0
275 } else {
276 0.0
277 };
278 let peak = pos.peak_profit_pct.unwrap_or(profit_pct).max(profit_pct);
279 pos.peak_profit_pct = Some(peak);
280
281 let analytics = compute_vertical_analytics(VerticalAnalyticsInput {
282 is_put_spread: v.is_put,
283 underlying_price: spot,
284 short_strike: v.short_strike,
285 long_strike: v.long_strike,
286 credit: pos.entry_credit,
287 dte,
288 chain_iv_pct: Some(iv),
289 realized_vol_pct: Some(v.realized_vol_pct).filter(|x| *x > 0.0),
290 short_delta: Some(
291 crate::agent::backtest::bs::bs_delta(
292 v.is_put,
293 spot,
294 v.short_strike,
295 crate::agent::backtest::bs::years_from_dte(dte),
296 0.0,
297 (iv / 100.0).max(0.01),
298 ),
299 ),
300 long_delta: None,
301 short_theta: None,
302 long_theta: None,
303 contracts: pos.contracts,
304 underlying_change_pct: None,
305 });
306 let mark = SpreadMark {
307 entry_credit: pos.entry_credit,
308 debit_to_close: debit,
309 profit_pct,
310 dte,
311 source: "bs".into(),
312 };
313 let exit = evaluate_all_exits(
314 rules,
315 Some(pos.entry_credit),
316 &mark,
317 Some(&analytics),
318 pos.peak_profit_pct,
319 Some(pos.opened_at),
320 );
321
322 if let Some(eval) = exit {
323 if eval.reason == "stop_loss" && rules.exit_rules.roll.enabled {
324 if let Some(rolled) = try_roll(
325 rules_path,
326 rules,
327 cache,
328 state,
329 &pos,
330 v,
331 &eval,
332 analytics.short_otm_pct,
333 day,
334 as_of,
335 spot,
336 iv,
337 skips,
338 )? {
339 return Ok(Some(rolled));
340 }
341 }
342 close_position(rules_path, state, &pos, &eval.reason, debit, as_of, day)?;
343 return Ok(None);
344 }
345 Ok(Some(pos))
346 }
347 OpenKind::Condor(c) => {
348 let Some(spot) = cache.close_on_date(&c.underlying, day) else {
349 return Ok(Some(pos));
350 };
351 let iv = cache
352 .vix_on_date(&rules.regime.vix_symbol, day)
353 .unwrap_or(c.iv_pct);
354 let dte = (c.expiry - day).num_days().max(0);
355 let put_debit = vertical_debit_to_close(true, spot, c.put_short, c.put_long, dte, iv);
356 let call_debit =
357 vertical_debit_to_close(false, spot, c.call_short, c.call_long, dte, iv);
358 let debit = put_debit + call_debit;
359 let profit_pct = if pos.entry_credit > f64::EPSILON {
360 ((pos.entry_credit - debit) / pos.entry_credit) * 100.0
361 } else {
362 0.0
363 };
364 let peak = pos.peak_profit_pct.unwrap_or(profit_pct).max(profit_pct);
365 pos.peak_profit_pct = Some(peak);
366 let mark = SpreadMark {
367 entry_credit: pos.entry_credit,
368 debit_to_close: debit,
369 profit_pct,
370 dte,
371 source: "bs".into(),
372 };
373 let exit = evaluate_all_exits(rules, Some(pos.entry_credit), &mark, None, None, None);
375 if let Some(eval) = exit {
376 close_position(rules_path, state, &pos, &eval.reason, debit, as_of, day)?;
377 return Ok(None);
378 }
379 Ok(Some(pos))
380 }
381 }
382}
383
384fn try_roll(
385 rules_path: &Path,
386 rules: &RulesConfig,
387 cache: &BacktestCache,
388 state: &mut AgentState,
389 pos: &OpenBt,
390 v: &SynthVertical,
391 eval: &crate::agent::exits::ExitEvaluation,
392 short_otm_pct: Option<f64>,
393 day: NaiveDate,
394 as_of: chrono::DateTime<Utc>,
395 spot: f64,
396 iv: f64,
397 skips: &mut HashMap<String, u32>,
398) -> Result<Option<OpenBt>> {
399 let tracked = state.open_positions.get(&pos.id).cloned().unwrap_or(TrackedPosition {
400 position_id: pos.id.clone(),
401 account_hash: pos.account_hash.clone(),
402 underlying: v.underlying.clone(),
403 expiry: v.expiry.to_string(),
404 strategy: "vertical".into(),
405 opened_at: pos.opened_at,
406 entry_credit: Some(pos.entry_credit),
407 max_loss_usd: pos.max_loss_usd,
408 contracts: pos.contracts,
409 entry_params: Some(json!({
410 "spread_type": if v.is_put { "put_credit" } else { "call_credit" },
411 "short_strike": v.short_strike,
412 "long_strike": v.long_strike,
413 })),
414 entry_short_delta: Some(v.short_delta.abs()),
415 rolls_used: pos.rolls_used,
416 ..Default::default()
417 });
418
419 if let Err(reason) = roll_eligible(
420 &rules.exit_rules.roll,
421 &RollEligibility {
422 tracked: &tracked,
423 mark_dte: eval.mark.dte,
424 short_otm_pct,
425 rolls_today: state.rolls_today,
426 reserved_risk_usd: state.reserved_risk_usd(),
427 max_portfolio_risk_usd: rules.risk.max_portfolio_risk_usd,
428 },
429 ) {
430 *skips.entry(reason.as_str().into()).or_insert(0) += 1;
431 return Ok(None);
432 }
433
434 let roll = &rules.exit_rules.roll;
435 let width = (v.short_strike - v.long_strike).abs();
436 let mut entry = rules.entry_rules.vertical.clone();
437 entry.dte_min = roll_biased_dte_min(entry.dte_min, eval.mark.dte, roll.min_dte_extension);
438 if entry.dte_min > entry.dte_max {
439 entry.dte_max = entry.dte_min;
440 }
441 entry.short_delta_max = roll_biased_short_delta_max(
442 entry.short_delta_max,
443 roll.target_short_delta_max,
444 Some(v.short_delta.abs()),
445 );
446 entry.short_delta_min = entry.short_delta_min.min(entry.short_delta_max * 0.5);
447 entry.max_width = roll_biased_max_width(entry.max_width, Some(width));
448 entry.max_contracts_per_trade = pos.contracts.max(1);
449
450 let closes = cache.closes_through(&v.underlying, day);
451 let Some(candidate) = pick_vertical(
452 &v.underlying,
453 day,
454 spot,
455 iv,
456 &closes,
457 &entry,
458 v.is_put,
459 pos.contracts,
460 ) else {
461 *skips.entry("no_roll_candidate".into()).or_insert(0) += 1;
462 return Ok(None);
463 };
464 if !roll_money_ok(
465 pos.entry_credit,
466 eval.mark.debit_to_close,
467 candidate.credit,
468 roll.max_debit_pct_of_entry_credit,
469 ) {
470 *skips.entry("roll_debit_too_large".into()).or_insert(0) += 1;
471 return Ok(None);
472 }
473 if vertical_passes_entry_gates(rules, &entry, &candidate, spot).is_err() {
474 *skips.entry("roll_gates_fail".into()).or_insert(0) += 1;
475 return Ok(None);
476 }
477
478 close_position(
480 rules_path,
481 state,
482 pos,
483 "defensive_roll",
484 eval.mark.debit_to_close,
485 as_of,
486 day,
487 )?;
488 state.rolls_today = state.rolls_today.saturating_add(1);
489 let next_rolls = pos.rolls_used.saturating_add(1);
490 let margin = vertical_margin(&candidate);
491 let side = if candidate.is_put { "P" } else { "C" };
492 let new_id = format!(
493 "{}|{}|{}|vertical|{}|{}",
494 pos.account_hash, candidate.underlying, candidate.expiry, side, candidate.short_strike
495 );
496 let opened = insert_vertical(
497 state,
498 &pos.account_hash,
499 day,
500 as_of,
501 candidate.clone(),
502 margin,
503 &new_id,
504 next_rolls,
505 true,
506 );
507 journal::append_backtest_event_at(
508 rules_path,
509 as_of,
510 "defensive_roll",
511 json!({
512 "closed_id": pos.id,
513 "new_id": new_id,
514 "close_debit": eval.mark.debit_to_close,
515 "new_credit": candidate.credit,
516 "net": candidate.credit - eval.mark.debit_to_close,
517 }),
518 )?;
519 Ok(Some(opened))
520}
521
522fn close_position(
523 rules_path: &Path,
524 state: &mut AgentState,
525 pos: &OpenBt,
526 reason: &str,
527 debit: f64,
528 as_of: chrono::DateTime<Utc>,
529 day: NaiveDate,
530) -> Result<()> {
531 let entry_credit = pos.entry_credit;
532 let contracts = pos.contracts.max(1) as f64;
533 let pnl_usd = (entry_credit - debit) * 100.0 * contracts;
534 let pnl_pct = if entry_credit > f64::EPSILON {
535 ((entry_credit - debit) / entry_credit) * 100.0
536 } else {
537 0.0
538 };
539 let hold_days = (day - pos.opened_on).num_days().max(0) as u32;
540 let underlying = match &pos.kind {
541 OpenKind::Vertical(v) => v.underlying.clone(),
542 OpenKind::Condor(c) => c.underlying.clone(),
543 };
544 let expiry = match &pos.kind {
545 OpenKind::Vertical(v) => v.expiry.to_string(),
546 OpenKind::Condor(c) => c.expiry.to_string(),
547 };
548 let strategy = match &pos.kind {
549 OpenKind::Vertical(_) => "vertical",
550 OpenKind::Condor(_) => "iron_condor",
551 };
552
553 if let Some(ledger) = state.sim.as_mut() {
554 ledger.realized_pnl_usd += pnl_usd;
555 ledger.closed_trades.push(ClosedSimTrade {
556 trade_id: format!("bt-{}-{}", pos.id, as_of.timestamp()),
557 position_id: pos.id.clone(),
558 underlying: underlying.clone(),
559 expiry: expiry.clone(),
560 strategy: strategy.into(),
561 contracts: pos.contracts,
562 entry_credit,
563 exit_debit: debit,
564 opened_at: pos.opened_at,
565 closed_at: as_of,
566 pnl_usd,
567 pnl_pct,
568 exit_reason: reason.to_string(),
569 hold_days,
570 });
571 }
572 state.open_positions.remove(&pos.id);
573 journal::append_backtest_event_at(
574 rules_path,
575 as_of,
576 "sim_exit_filled",
577 json!({
578 "position_id": pos.id,
579 "underlying": underlying,
580 "exit_reason": reason,
581 "pnl_usd": pnl_usd,
582 "pnl_pct": pnl_pct,
583 "exit_debit": debit,
584 "entry_credit": entry_credit,
585 }),
586 )?;
587 Ok(())
588}
589
590fn try_entry(
591 rules_path: &Path,
592 rules: &RulesConfig,
593 cache: &BacktestCache,
594 state: &mut AgentState,
595 account: &str,
596 day: NaiveDate,
597 as_of: chrono::DateTime<Utc>,
598 preferred: &str,
599 iv_pct: f64,
600 open: &mut Vec<OpenBt>,
601 skips: &mut HashMap<String, u32>,
602) -> Result<Option<Value>> {
603 if rules.risk.max_trades_per_day > 0 && state.trades_today >= rules.risk.max_trades_per_day {
604 *skips.entry("max_trades_per_day".into()).or_insert(0) += 1;
605 return Ok(None);
606 }
607
608 for item in rules.watchlist_items() {
609 let sym = item.symbol.trim().to_uppercase();
610 if !rules
611 .risk
612 .allowed_underlyings
613 .iter()
614 .any(|a| a.eq_ignore_ascii_case(&sym))
615 {
616 continue;
617 }
618 if let Some(max) = rules.risk.max_open_for_underlying(&sym) {
619 if state.count_open_for_underlying(account, &sym) >= max {
620 *skips.entry("max_open_per_underlying".into()).or_insert(0) += 1;
621 continue;
622 }
623 }
624 if let Some(group) = rules.risk.correlation_group_for(&sym) {
625 let count = group
626 .symbols
627 .iter()
628 .map(|s| state.count_open_for_underlying(account, s))
629 .sum::<u32>();
630 if count >= group.max_open {
631 *skips.entry("correlation_group".into()).or_insert(0) += 1;
632 continue;
633 }
634 }
635
636 let Some(spot) = cache.close_on_date(&sym, day) else {
637 continue;
638 };
639 let closes = cache.closes_through(&sym, day);
640
641 if preferred == "iron_condor" && rules.strategies.iron_condor.enabled {
642 let open_c = state.count_open_for_strategy(account, StrategyKind::IronCondor);
643 if open_c >= rules.entry_rules.iron_condor.max_open_positions {
644 *skips.entry("max_open_condor".into()).or_insert(0) += 1;
645 continue;
646 }
647 if let Some(c) = pick_iron_condor(&sym, day, spot, iv_pct, &closes, rules) {
648 let margin = condor_margin(&c);
649 if margin > rules.risk.max_risk_per_trade_usd
650 || state.reserved_risk_usd() + margin > rules.risk.max_portfolio_risk_usd
651 {
652 *skips.entry("risk_cap".into()).or_insert(0) += 1;
653 continue;
654 }
655 let id = format!("{account}|{sym}|{}|condor|{}", c.expiry, c.put_short);
656 if state.open_positions.contains_key(&id) {
657 continue;
658 }
659 let opened = insert_condor(state, account, day, as_of, c, margin, &id);
660 journal::append_backtest_event_at(
661 rules_path,
662 as_of,
663 "sim_entry_filled",
664 json!({ "position_id": id, "strategy": "iron_condor" }),
665 )?;
666 open.push(opened);
667 return Ok(Some(json!({"action":"entry","strategy":"iron_condor","id": id})));
668 }
669 *skips.entry("no_condor_candidate".into()).or_insert(0) += 1;
670 continue;
671 }
672
673 if !rules.strategies.vertical.enabled {
674 continue;
675 }
676 let open_v = state.count_open_for_strategy(account, StrategyKind::Vertical);
677 if open_v >= rules.entry_rules.vertical.max_open_positions {
678 *skips.entry("max_open_vertical".into()).or_insert(0) += 1;
679 continue;
680 }
681 let is_put = preferred != "call_credit";
682 let entry = &rules.entry_rules.vertical;
683 let Some(v) = pick_vertical(
684 &sym,
685 day,
686 spot,
687 iv_pct,
688 &closes,
689 entry,
690 is_put,
691 entry.max_contracts_per_trade,
692 ) else {
693 *skips.entry("no_vertical_candidate".into()).or_insert(0) += 1;
694 continue;
695 };
696 if let Err(reason) = vertical_passes_entry_gates(rules, entry, &v, spot) {
697 *skips.entry(reason).or_insert(0) += 1;
698 continue;
699 }
700 let margin = vertical_margin(&v);
701 if margin > rules.risk.max_risk_per_trade_usd
702 || state.reserved_risk_usd() + margin > rules.risk.max_portfolio_risk_usd
703 {
704 *skips.entry("risk_cap".into()).or_insert(0) += 1;
705 continue;
706 }
707 let side = if is_put { "P" } else { "C" };
708 let id = format!(
709 "{account}|{sym}|{}|vertical|{side}|{}",
710 v.expiry, v.short_strike
711 );
712 if state.open_positions.contains_key(&id) {
713 continue;
714 }
715 let opened = insert_vertical(state, account, day, as_of, v, margin, &id, 0, false);
716 journal::append_backtest_event_at(
717 rules_path,
718 as_of,
719 "sim_entry_filled",
720 json!({ "position_id": id, "strategy": "vertical" }),
721 )?;
722 open.push(opened);
723 return Ok(Some(json!({"action":"entry","strategy":"vertical","id": id})));
724 }
725 Ok(None)
726}
727
728fn vertical_margin(v: &SynthVertical) -> f64 {
729 let width = (v.short_strike - v.long_strike).abs();
730 ((width - v.credit).max(0.0) * 100.0) * v.contracts.max(1) as f64
731}
732
733fn condor_margin(c: &SynthCondor) -> f64 {
734 let put_w = (c.put_short - c.put_long).abs();
735 let call_w = (c.call_long - c.call_short).abs();
736 let width = put_w.max(call_w);
737 ((width - c.credit).max(0.0) * 100.0) * c.contracts.max(1) as f64
738}
739
740fn insert_vertical(
741 state: &mut AgentState,
742 account: &str,
743 day: NaiveDate,
744 as_of: chrono::DateTime<Utc>,
745 v: SynthVertical,
746 margin: f64,
747 id: &str,
748 rolls_used: u32,
749 roll_replacement: bool,
750) -> OpenBt {
751 if !roll_replacement {
752 state.trades_today = state.trades_today.saturating_add(1);
753 }
754 state.open_positions.insert(
755 id.to_string(),
756 TrackedPosition {
757 position_id: id.to_string(),
758 account_hash: account.to_string(),
759 underlying: v.underlying.clone(),
760 expiry: v.expiry.to_string(),
761 strategy: "vertical".into(),
762 opened_at: as_of,
763 entry_credit: Some(v.credit),
764 max_loss_usd: margin,
765 contracts: v.contracts,
766 entry_params: Some(json!({
767 "underlying": v.underlying,
768 "expiry": v.expiry.to_string(),
769 "spread_type": if v.is_put { "put_credit" } else { "call_credit" },
770 "short_strike": v.short_strike,
771 "long_strike": v.long_strike,
772 "contracts": v.contracts,
773 "limit_credit": v.credit,
774 })),
775 entry_short_delta: Some(v.short_delta.abs()),
776 rolls_used,
777 last_roll_at: if rolls_used > 0 { Some(as_of) } else { None },
778 ..Default::default()
779 },
780 );
781 OpenBt {
782 id: id.to_string(),
783 kind: OpenKind::Vertical(v.clone()),
784 opened_on: day,
785 entry_credit: v.credit,
786 max_loss_usd: margin,
787 contracts: v.contracts,
788 peak_profit_pct: None,
789 rolls_used,
790 account_hash: account.to_string(),
791 opened_at: as_of,
792 }
793}
794
795fn insert_condor(
796 state: &mut AgentState,
797 account: &str,
798 day: NaiveDate,
799 as_of: chrono::DateTime<Utc>,
800 c: SynthCondor,
801 margin: f64,
802 id: &str,
803) -> OpenBt {
804 state.trades_today = state.trades_today.saturating_add(1);
805 state.open_positions.insert(
806 id.to_string(),
807 TrackedPosition {
808 position_id: id.to_string(),
809 account_hash: account.to_string(),
810 underlying: c.underlying.clone(),
811 expiry: c.expiry.to_string(),
812 strategy: "iron_condor".into(),
813 opened_at: as_of,
814 entry_credit: Some(c.credit),
815 max_loss_usd: margin,
816 contracts: c.contracts,
817 entry_params: Some(json!({
818 "underlying": c.underlying,
819 "expiry": c.expiry.to_string(),
820 "put_short": c.put_short,
821 "put_long": c.put_long,
822 "call_short": c.call_short,
823 "call_long": c.call_long,
824 "contracts": c.contracts,
825 "limit_credit": c.credit,
826 })),
827 ..Default::default()
828 },
829 );
830 OpenBt {
831 id: id.to_string(),
832 kind: OpenKind::Condor(c.clone()),
833 opened_on: day,
834 entry_credit: c.credit,
835 max_loss_usd: margin,
836 contracts: c.contracts,
837 peak_profit_pct: None,
838 rolls_used: 0,
839 account_hash: account.to_string(),
840 opened_at: as_of,
841 }
842}