Skip to main content

schwab_cli/agent/backtest/
synth.rs

1//! Synthetic vertical / iron-condor candidates from BS + VIX IV.
2
3use chrono::{Datelike, Duration, NaiveDate, Weekday};
4
5use crate::agent::spread_analytics::{
6    compute_vertical_analytics, entry_analytics_pass, VerticalAnalyticsInput,
7};
8use crate::agent::volatility::realized_vol_annualized_pct;
9use crate::rules::{RulesConfig, VerticalEntryRules};
10
11use super::bs::{bs_delta, vertical_credit, years_from_dte};
12
13#[derive(Debug, Clone)]
14pub struct SynthVertical {
15    pub underlying: String,
16    pub expiry: NaiveDate,
17    pub is_put: bool,
18    pub short_strike: f64,
19    pub long_strike: f64,
20    pub credit: f64,
21    pub dte: i64,
22    pub short_delta: f64,
23    pub long_delta: f64,
24    pub iv_pct: f64,
25    pub realized_vol_pct: f64,
26    pub contracts: u32,
27}
28
29/// Next Friday on or after `from` with DTE in `[dte_min, dte_max]` relative to `today`.
30pub fn pick_expiry(today: NaiveDate, dte_min: u32, dte_max: u32) -> Option<(NaiveDate, i64)> {
31    let mut d = today + Duration::days(dte_min as i64);
32    // Advance to Friday
33    while d.weekday() != Weekday::Fri {
34        d += Duration::days(1);
35    }
36    let end = today + Duration::days(dte_max as i64 + 7);
37    while d <= end {
38        let dte = (d - today).num_days();
39        if dte >= dte_min as i64 && dte <= dte_max as i64 {
40            return Some((d, dte));
41        }
42        d += Duration::days(7);
43    }
44    None
45}
46
47fn round_strike(spot: f64, strike: f64) -> f64 {
48    // $1 grid for index ETFs in the typical range.
49    if spot >= 50.0 {
50        strike.round()
51    } else {
52        (strike * 2.0).round() / 2.0
53    }
54}
55
56fn strike_candidates(spot: f64, is_put: bool) -> Vec<f64> {
57    let mut out = Vec::new();
58    let lo = (spot * 0.85).floor();
59    let hi = (spot * 1.15).ceil();
60    let mut s = lo;
61    while s <= hi {
62        out.push(s);
63        s += 1.0;
64    }
65    if is_put {
66        out.retain(|k| *k < spot);
67    } else {
68        out.retain(|k| *k > spot);
69    }
70    out
71}
72
73pub fn pick_vertical(
74    underlying: &str,
75    today: NaiveDate,
76    spot: f64,
77    iv_pct: f64,
78    closes: &[f64],
79    entry: &VerticalEntryRules,
80    is_put: bool,
81    contracts: u32,
82) -> Option<SynthVertical> {
83    let (expiry, dte) = pick_expiry(today, entry.dte_min, entry.dte_max)?;
84    let t = years_from_dte(dte);
85    let sigma = (iv_pct / 100.0).max(0.01);
86    let target = ((entry.short_delta_min + entry.short_delta_max) / 2.0).clamp(0.05, 0.40);
87
88    let mut best: Option<(f64, f64, f64)> = None; // short, |delta|-target, delta
89    for strike in strike_candidates(spot, is_put) {
90        let delta = bs_delta(is_put, spot, strike, t, 0.0, sigma);
91        let abs = delta.abs();
92        if abs < entry.short_delta_min || abs > entry.short_delta_max {
93            continue;
94        }
95        let err = (abs - target).abs();
96        match best {
97            Some((_, e, _)) if err >= e => {}
98            _ => best = Some((strike, err, delta)),
99        }
100    }
101    let (short_strike, _, short_delta) = best?;
102    let long_raw = if is_put {
103        short_strike - entry.max_width
104    } else {
105        short_strike + entry.max_width
106    };
107    let long_strike = round_strike(spot, long_raw);
108    if (short_strike - long_strike).abs() < entry.max_width * 0.4 {
109        return None;
110    }
111    let credit = vertical_credit(is_put, spot, short_strike, long_strike, dte, iv_pct);
112    if credit < entry.min_credit {
113        return None;
114    }
115    let long_delta = bs_delta(is_put, spot, long_strike, t, 0.0, sigma);
116    let lookback = 20usize;
117    let rv = realized_vol_annualized_pct(closes, lookback);
118    Some(SynthVertical {
119        underlying: underlying.to_uppercase(),
120        expiry,
121        is_put,
122        short_strike,
123        long_strike,
124        credit,
125        dte,
126        short_delta,
127        long_delta,
128        iv_pct,
129        realized_vol_pct: rv,
130        contracts: contracts.max(1),
131    })
132}
133
134pub fn vertical_passes_entry_gates(
135    rules: &RulesConfig,
136    entry: &VerticalEntryRules,
137    v: &SynthVertical,
138    spot: f64,
139) -> Result<(), String> {
140    let analytics = compute_vertical_analytics(VerticalAnalyticsInput {
141        is_put_spread: v.is_put,
142        underlying_price: spot,
143        short_strike: v.short_strike,
144        long_strike: v.long_strike,
145        credit: v.credit,
146        dte: v.dte,
147        chain_iv_pct: Some(v.iv_pct),
148        realized_vol_pct: Some(v.realized_vol_pct).filter(|x| *x > 0.0),
149        short_delta: Some(v.short_delta),
150        long_delta: Some(v.long_delta),
151        short_theta: None,
152        long_theta: None,
153        contracts: v.contracts,
154        underlying_change_pct: None,
155    });
156    if !entry_analytics_pass(entry, &analytics) {
157        return Err("entry_analytics".into());
158    }
159    if let Some(min_ratio) = entry.min_iv_rv_ratio {
160        let ratio = analytics.iv_rv_ratio;
161        if !crate::agent::spread_analytics::passes_min_iv_rv_ratio(Some(min_ratio), ratio) {
162            return Err(format!(
163                "iv_rv_ratio {:?}",
164                ratio
165            ));
166        }
167    }
168    if entry.reject_short_inside_1sigma
169        && analytics.short_strike_inside_1sigma == Some(true)
170    {
171        return Err("short_inside_1sigma".into());
172    }
173    // Thesis candidate gates (reuse exit helper when present).
174    if let Some(reason) =
175        crate::agent::exits::candidate_fails_thesis_gates(rules, &analytics)
176    {
177        return Err(reason.to_string());
178    }
179    Ok(())
180}
181
182#[derive(Debug, Clone)]
183pub struct SynthCondor {
184    pub underlying: String,
185    pub expiry: NaiveDate,
186    pub put_short: f64,
187    pub put_long: f64,
188    pub call_short: f64,
189    pub call_long: f64,
190    pub credit: f64,
191    #[allow(dead_code)]
192    pub dte: i64,
193    pub iv_pct: f64,
194    pub contracts: u32,
195}
196
197pub fn pick_iron_condor(
198    underlying: &str,
199    today: NaiveDate,
200    spot: f64,
201    iv_pct: f64,
202    closes: &[f64],
203    rules: &RulesConfig,
204) -> Option<SynthCondor> {
205    let ic = &rules.entry_rules.iron_condor;
206    let put_entry = VerticalEntryRules {
207        short_delta_min: (ic.short_delta - 0.03).max(0.05),
208        short_delta_max: ic.short_delta + 0.03,
209        max_width: ic.wing_width,
210        min_credit: ic.min_credit / 2.0,
211        dte_min: ic.dte_min,
212        dte_max: ic.dte_max,
213        min_iv_rv_ratio: ic.min_iv_rv_ratio,
214        ..rules.entry_rules.vertical.clone()
215    };
216    let put = pick_vertical(
217        underlying,
218        today,
219        spot,
220        iv_pct,
221        closes,
222        &put_entry,
223        true,
224        ic.max_contracts_per_trade,
225    )?;
226    let call = pick_vertical(
227        underlying,
228        today,
229        spot,
230        iv_pct,
231        closes,
232        &put_entry,
233        false,
234        ic.max_contracts_per_trade,
235    )?;
236    if put.expiry != call.expiry {
237        return None;
238    }
239    let credit = put.credit + call.credit;
240    if credit < ic.min_credit {
241        return None;
242    }
243    if let Some(min_ratio) = ic.min_iv_rv_ratio {
244        let rv = put.realized_vol_pct;
245        if rv <= 0.0 || iv_pct / rv < min_ratio {
246            return None;
247        }
248    }
249    Some(SynthCondor {
250        underlying: underlying.to_uppercase(),
251        expiry: put.expiry,
252        put_short: put.short_strike,
253        put_long: put.long_strike,
254        call_short: call.short_strike,
255        call_long: call.long_strike,
256        credit,
257        dte: put.dte,
258        iv_pct,
259        contracts: ic.max_contracts_per_trade.max(1),
260    })
261}