schwab_cli/agent/backtest/
synth.rs1use 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
29pub 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 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 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; 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 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}