Skip to main content

schwab_cli/agent/backtest/
runner.rs

1//! Day-loop options backtest over synthetic BS marks.
2
3use 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            // Condors: mechanical profit/stop/DTE only (no thesis analytics).
374            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 old, open new — successful roll skips stop cooldown.
479    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}