1use serde::{Deserialize, Serialize};
4use serde_json::{json, Value};
5
6#[derive(Debug, Clone, Serialize, Deserialize, Default)]
8pub struct SpreadAnalytics {
9 pub is_put_spread: bool,
10 pub underlying_price: f64,
11 pub short_strike: f64,
12 pub long_strike: f64,
13 pub width: f64,
14 pub credit: f64,
15 pub dte: i64,
16 pub chain_iv_pct: Option<f64>,
17 pub short_delta: Option<f64>,
18 pub long_delta: Option<f64>,
19 pub short_theta: Option<f64>,
20 pub long_theta: Option<f64>,
21 pub net_theta_per_day_usd: Option<f64>,
23 pub short_otm_pct: Option<f64>,
24 pub approx_short_otm_prob_pct: Option<f64>,
25 pub break_even_price: Option<f64>,
26 pub distance_to_be_usd: Option<f64>,
27 pub distance_to_be_pct: Option<f64>,
28 pub expected_move_1sigma_usd: Option<f64>,
29 pub expected_move_1sigma_pct: Option<f64>,
30 pub short_strike_inside_1sigma: Option<bool>,
31 pub spread_pop_pct: Option<f64>,
32 pub credit_to_width_pct: Option<f64>,
33 pub max_loss_per_spread_usd: Option<f64>,
34 pub risk_reward_ratio: Option<f64>,
35 pub underlying_change_pct: Option<f64>,
36 pub distance_to_short_strike_usd: Option<f64>,
37}
38
39#[derive(Debug, Clone, Copy)]
40pub struct VerticalAnalyticsInput {
41 pub is_put_spread: bool,
42 pub underlying_price: f64,
43 pub short_strike: f64,
44 pub long_strike: f64,
45 pub credit: f64,
46 pub dte: i64,
47 pub chain_iv_pct: Option<f64>,
48 pub short_delta: Option<f64>,
49 pub long_delta: Option<f64>,
50 pub short_theta: Option<f64>,
51 pub long_theta: Option<f64>,
52 pub contracts: u32,
53 pub underlying_change_pct: Option<f64>,
54}
55
56pub fn compute_vertical_analytics(input: VerticalAnalyticsInput) -> SpreadAnalytics {
57 let width = (input.short_strike - input.long_strike).abs();
58 let credit = input.credit.max(0.0);
59 let contracts = input.contracts.max(1);
60
61 let iv = input
62 .chain_iv_pct
63 .or_else(|| strike_iv_fallback(input.short_delta))
64 .filter(|v| *v > 0.0);
65
66 let (short_otm_pct, distance_to_be_usd, break_even) =
67 if input.underlying_price > f64::EPSILON {
68 if input.is_put_spread {
69 let be = input.short_strike - credit;
70 let dist = input.underlying_price - be;
71 (
72 Some(((input.underlying_price - input.short_strike) / input.underlying_price)
73 * 100.0),
74 Some(dist),
75 Some(be),
76 )
77 } else {
78 let be = input.short_strike + credit;
79 let dist = be - input.underlying_price;
80 (
81 Some(((input.short_strike - input.underlying_price) / input.underlying_price)
82 * 100.0),
83 Some(dist),
84 Some(be),
85 )
86 }
87 } else {
88 (None, None, None)
89 };
90
91 let distance_to_be_pct = break_even.zip(Some(input.underlying_price)).map(|(be, spot)| {
92 if input.is_put_spread {
93 ((spot - be) / spot) * 100.0
94 } else {
95 ((be - spot) / spot) * 100.0
96 }
97 });
98
99 let (expected_move_1sigma_usd, expected_move_1sigma_pct) =
100 iv.and_then(|iv_pct| {
101 expected_move(input.underlying_price, iv_pct, input.dte)
102 })
103 .map(|em| (Some(em), Some((em / input.underlying_price) * 100.0)))
104 .unwrap_or((None, None));
105
106 let short_strike_inside_1sigma = expected_move_1sigma_usd.map(|em| {
107 if input.is_put_spread {
108 (input.underlying_price - input.short_strike) < em
109 } else {
110 (input.short_strike - input.underlying_price) < em
111 }
112 });
113
114 let approx_short_otm_prob_pct = input.short_delta.map(|d| {
115 if input.is_put_spread {
116 (1.0 + d) * 100.0
117 } else {
118 (1.0 - d) * 100.0
119 }
120 });
121
122 let distance_to_short_strike_usd = if input.underlying_price > f64::EPSILON {
123 Some(if input.is_put_spread {
124 input.underlying_price - input.short_strike
125 } else {
126 input.short_strike - input.underlying_price
127 })
128 } else {
129 None
130 };
131
132 let spread_pop_pct = break_even.and_then(|be| {
133 iv.and_then(|iv_pct| {
134 probability_above_price(input.underlying_price, be, iv_pct, input.dte)
135 })
136 });
137
138 let credit_to_width_pct = if width > f64::EPSILON {
139 Some((credit / width) * 100.0)
140 } else {
141 None
142 };
143
144 let max_loss = ((width - credit).max(0.0)) * 100.0;
145 let risk_reward_ratio = if max_loss > f64::EPSILON {
146 Some((credit * 100.0) / max_loss)
147 } else {
148 None
149 };
150
151 let net_theta_per_day_usd = match (input.short_theta, input.long_theta) {
152 (Some(st), Some(lt)) => {
153 let per_share = lt - st;
155 Some(per_share * 100.0 * contracts as f64)
156 }
157 _ => None,
158 };
159
160 SpreadAnalytics {
161 is_put_spread: input.is_put_spread,
162 underlying_price: input.underlying_price,
163 short_strike: input.short_strike,
164 long_strike: input.long_strike,
165 width,
166 credit,
167 dte: input.dte,
168 chain_iv_pct: iv,
169 short_delta: input.short_delta,
170 long_delta: input.long_delta,
171 short_theta: input.short_theta,
172 long_theta: input.long_theta,
173 net_theta_per_day_usd,
174 short_otm_pct,
175 approx_short_otm_prob_pct,
176 break_even_price: break_even,
177 distance_to_be_usd,
178 distance_to_be_pct,
179 expected_move_1sigma_usd,
180 expected_move_1sigma_pct,
181 short_strike_inside_1sigma,
182 spread_pop_pct,
183 credit_to_width_pct,
184 max_loss_per_spread_usd: Some(max_loss),
185 risk_reward_ratio,
186 underlying_change_pct: input.underlying_change_pct,
187 distance_to_short_strike_usd,
188 }
189}
190
191pub fn spread_win_score(
193 profit_pct: f64,
194 analytics: &SpreadAnalytics,
195 pct_cushion_from_stop: f64,
196) -> f64 {
197 let pop = analytics.spread_pop_pct.unwrap_or(50.0) / 100.0;
198 let pnl = ((profit_pct + 30.0) / 80.0).clamp(0.0, 1.0);
199 let cushion = (analytics.distance_to_be_pct.unwrap_or(0.0) / 15.0).clamp(0.0, 1.0);
200 let stop_room = (pct_cushion_from_stop / 100.0).clamp(0.0, 1.0);
201 let delta_comfort = analytics
202 .short_delta
203 .map(|d| (0.45 - d.abs()) / 0.35)
204 .unwrap_or(0.5)
205 .clamp(0.0, 1.0);
206 (pop * 0.35 + pnl * 0.25 + cushion * 0.20 + stop_room * 0.10 + delta_comfort * 0.10) * 100.0
207}
208
209pub fn entry_analytics_pass(entry: &crate::rules::VerticalEntryRules, a: &SpreadAnalytics) -> bool {
210 if let Some(min) = entry.min_pop_pct {
211 if a.spread_pop_pct.unwrap_or(0.0) < min {
212 return false;
213 }
214 }
215 if let Some(min) = entry.min_distance_to_be_pct {
216 if a.distance_to_be_pct.unwrap_or(0.0) < min {
217 return false;
218 }
219 }
220 let min_ctw = entry.min_credit_to_width_pct.unwrap_or(12.5);
221 if a.credit_to_width_pct.unwrap_or(0.0) < min_ctw {
222 return false;
223 }
224 true
225}
226
227pub fn analytics_to_json(a: &SpreadAnalytics) -> Value {
228 serde_json::to_value(a).unwrap_or(json!({}))
229}
230
231pub fn analytics_from_json(v: &Value) -> Option<SpreadAnalytics> {
232 serde_json::from_value(v.clone()).ok()
233}
234
235pub fn expected_move(spot: f64, iv_pct: f64, dte: i64) -> Option<f64> {
237 if spot <= 0.0 || iv_pct <= 0.0 || dte <= 0 {
238 return None;
239 }
240 let iv = iv_pct / 100.0;
241 let t = dte as f64 / 365.0;
242 Some(spot * iv * t.sqrt())
243}
244
245pub fn probability_above_price(spot: f64, price: f64, iv_pct: f64, dte: i64) -> Option<f64> {
247 if spot <= 0.0 || price <= 0.0 || iv_pct <= 0.0 || dte <= 0 {
248 return None;
249 }
250 let iv = iv_pct / 100.0;
251 let t = dte as f64 / 365.0;
252 let denom = iv * t.sqrt();
253 if denom <= f64::EPSILON {
254 return None;
255 }
256 let d = (spot / price).ln() / denom;
257 Some(normal_cdf(d) * 100.0)
258}
259
260fn strike_iv_fallback(_delta: Option<f64>) -> Option<f64> {
261 None
262}
263
264fn normal_cdf(x: f64) -> f64 {
265 0.5 * (1.0 + erf(x / std::f64::consts::SQRT_2))
266}
267
268fn erf(x: f64) -> f64 {
269 let sign = if x < 0.0 { -1.0 } else { 1.0 };
271 let x = x.abs();
272 let a1 = 0.254829592;
273 let a2 = -0.284496736;
274 let a3 = 1.421413741;
275 let a4 = -1.453152027;
276 let a5 = 1.061405429;
277 let p = 0.3275911;
278 let t = 1.0 / (1.0 + p * x);
279 let y = 1.0
280 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-x * x).exp();
281 sign * y
282}
283
284pub fn price_cushion_rail(
286 break_even: f64,
287 spot: f64,
288 short_strike: f64,
289 is_put_spread: bool,
290 width: usize,
291) -> (String, f64) {
292 let width = width.max(12);
293 if is_put_spread {
294 let lo = break_even.min(short_strike);
295 let hi = short_strike.max(break_even).max(spot);
296 let span = (hi - lo).max(0.01);
297 let mut chars: Vec<char> = vec!['·'; width];
298 let be_idx = ((break_even - lo) / span * (width.saturating_sub(1) as f64)).round() as usize;
299 let short_idx =
300 ((short_strike - lo) / span * (width.saturating_sub(1) as f64)).round() as usize;
301 let spot_idx = ((spot.clamp(lo, hi) - lo) / span * (width.saturating_sub(1) as f64))
302 .round() as usize;
303 if be_idx < width {
304 chars[be_idx] = 'B';
305 }
306 if short_idx < width && short_idx != be_idx {
307 chars[short_idx] = 'S';
308 }
309 if spot_idx < width {
310 chars[spot_idx] = '●';
311 }
312 let cushion_pct = ((spot - break_even) / span * 100.0).clamp(0.0, 200.0);
313 (chars.into_iter().collect(), cushion_pct)
314 } else {
315 let lo = short_strike.min(break_even).min(spot);
316 let hi = break_even.max(short_strike).max(spot);
317 let span = (hi - lo).max(0.01);
318 let mut chars: Vec<char> = vec!['·'; width];
319 let be_idx = ((break_even - lo) / span * (width.saturating_sub(1) as f64)).round() as usize;
320 let short_idx =
321 ((short_strike - lo) / span * (width.saturating_sub(1) as f64)).round() as usize;
322 let spot_idx = ((spot.clamp(lo, hi) - lo) / span * (width.saturating_sub(1) as f64))
323 .round() as usize;
324 if short_idx < width {
325 chars[short_idx] = 'S';
326 }
327 if be_idx < width && be_idx != short_idx {
328 chars[be_idx] = 'B';
329 }
330 if spot_idx < width {
331 chars[spot_idx] = '●';
332 }
333 let cushion_pct = ((break_even - spot) / span * 100.0).clamp(0.0, 200.0);
334 (chars.into_iter().collect(), cushion_pct)
335 }
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341
342 #[test]
343 fn expected_move_scales_with_sqrt_time() {
344 let em30 = expected_move(300.0, 20.0, 30).unwrap();
345 let em120 = expected_move(300.0, 20.0, 120).unwrap();
346 assert!(em120 > em30);
347 }
348
349 #[test]
350 fn put_credit_pop_above_break_even() {
351 let pop = probability_above_price(300.0, 280.0, 25.0, 35).unwrap();
352 assert!(pop > 60.0);
353 }
354
355 #[test]
356 fn vertical_analytics_put_credit() {
357 let a = compute_vertical_analytics(VerticalAnalyticsInput {
358 is_put_spread: true,
359 underlying_price: 299.0,
360 short_strike: 282.0,
361 long_strike: 280.0,
362 credit: 0.25,
363 dte: 36,
364 chain_iv_pct: Some(28.0),
365 short_delta: Some(-0.22),
366 long_delta: Some(-0.15),
367 short_theta: Some(-0.08),
368 long_theta: Some(-0.05),
369 contracts: 1,
370 underlying_change_pct: Some(0.5),
371 });
372 assert!((a.break_even_price.unwrap() - 281.75).abs() < 0.01);
373 assert!(a.spread_pop_pct.unwrap() > 55.0);
374 assert!(a.distance_to_be_pct.unwrap() > 5.0);
375 assert!(a.net_theta_per_day_usd.unwrap() > 0.0);
376 }
377
378 #[test]
379 fn price_rail_marks_be_and_spot() {
380 let (rail, _) = price_cushion_rail(281.75, 299.0, 282.0, true, 24);
381 assert!(rail.contains('B'));
382 assert!(rail.contains('●'));
383 }
384}