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 realized_vol_pct: Option<f64>,
19 pub iv_rv_ratio: Option<f64>,
21 pub short_delta: Option<f64>,
22 pub long_delta: Option<f64>,
23 pub short_theta: Option<f64>,
24 pub long_theta: Option<f64>,
25 pub net_theta_per_day_usd: Option<f64>,
27 pub short_otm_pct: Option<f64>,
28 pub approx_short_otm_prob_pct: Option<f64>,
29 pub break_even_price: Option<f64>,
30 pub distance_to_be_usd: Option<f64>,
31 pub distance_to_be_pct: Option<f64>,
32 pub expected_move_1sigma_usd: Option<f64>,
33 pub expected_move_1sigma_pct: Option<f64>,
34 pub short_strike_inside_1sigma: Option<bool>,
35 pub spread_pop_pct: Option<f64>,
36 pub credit_to_width_pct: Option<f64>,
37 pub max_loss_per_spread_usd: Option<f64>,
38 pub risk_reward_ratio: Option<f64>,
39 pub underlying_change_pct: Option<f64>,
40 pub distance_to_short_strike_usd: Option<f64>,
41}
42
43#[derive(Debug, Clone, Copy)]
44pub struct VerticalAnalyticsInput {
45 pub is_put_spread: bool,
46 pub underlying_price: f64,
47 pub short_strike: f64,
48 pub long_strike: f64,
49 pub credit: f64,
50 pub dte: i64,
51 pub chain_iv_pct: Option<f64>,
52 pub realized_vol_pct: Option<f64>,
53 pub short_delta: Option<f64>,
54 pub long_delta: Option<f64>,
55 pub short_theta: Option<f64>,
56 pub long_theta: Option<f64>,
57 pub contracts: u32,
58 pub underlying_change_pct: Option<f64>,
59}
60
61pub fn compute_vertical_analytics(input: VerticalAnalyticsInput) -> SpreadAnalytics {
62 let width = (input.short_strike - input.long_strike).abs();
63 let credit = input.credit.max(0.0);
64 let contracts = input.contracts.max(1);
65
66 let iv = input
67 .chain_iv_pct
68 .or_else(|| strike_iv_fallback(input.short_delta))
69 .filter(|v| *v > 0.0);
70
71 let realized_vol_pct = input.realized_vol_pct.filter(|v| *v > 0.0);
72 let iv_rv_ratio = match (iv, realized_vol_pct) {
73 (Some(iv_pct), Some(rv)) if rv > 0.0 => Some(iv_pct / rv),
74 _ => None,
75 };
76
77 let (short_otm_pct, distance_to_be_usd, break_even) =
78 if input.underlying_price > f64::EPSILON {
79 if input.is_put_spread {
80 let be = input.short_strike - credit;
81 let dist = input.underlying_price - be;
82 (
83 Some(((input.underlying_price - input.short_strike) / input.underlying_price)
84 * 100.0),
85 Some(dist),
86 Some(be),
87 )
88 } else {
89 let be = input.short_strike + credit;
90 let dist = be - input.underlying_price;
91 (
92 Some(((input.short_strike - input.underlying_price) / input.underlying_price)
93 * 100.0),
94 Some(dist),
95 Some(be),
96 )
97 }
98 } else {
99 (None, None, None)
100 };
101
102 let distance_to_be_pct = break_even.zip(Some(input.underlying_price)).map(|(be, spot)| {
103 if input.is_put_spread {
104 ((spot - be) / spot) * 100.0
105 } else {
106 ((be - spot) / spot) * 100.0
107 }
108 });
109
110 let (expected_move_1sigma_usd, expected_move_1sigma_pct) =
111 iv.and_then(|iv_pct| {
112 expected_move(input.underlying_price, iv_pct, input.dte)
113 })
114 .map(|em| (Some(em), Some((em / input.underlying_price) * 100.0)))
115 .unwrap_or((None, None));
116
117 let short_strike_inside_1sigma = expected_move_1sigma_usd.map(|em| {
118 if input.is_put_spread {
119 (input.underlying_price - input.short_strike) < em
120 } else {
121 (input.short_strike - input.underlying_price) < em
122 }
123 });
124
125 let approx_short_otm_prob_pct = input.short_delta.map(|d| {
126 if input.is_put_spread {
127 (1.0 + d) * 100.0
128 } else {
129 (1.0 - d) * 100.0
130 }
131 });
132
133 let distance_to_short_strike_usd = if input.underlying_price > f64::EPSILON {
134 Some(if input.is_put_spread {
135 input.underlying_price - input.short_strike
136 } else {
137 input.short_strike - input.underlying_price
138 })
139 } else {
140 None
141 };
142
143 let spread_pop_pct = break_even.and_then(|be| {
144 iv.and_then(|iv_pct| {
145 probability_above_price(input.underlying_price, be, iv_pct, input.dte)
146 })
147 });
148
149 let credit_to_width_pct = if width > f64::EPSILON {
150 Some((credit / width) * 100.0)
151 } else {
152 None
153 };
154
155 let max_loss = ((width - credit).max(0.0)) * 100.0;
156 let risk_reward_ratio = if max_loss > f64::EPSILON {
157 Some((credit * 100.0) / max_loss)
158 } else {
159 None
160 };
161
162 let net_theta_per_day_usd = match (input.short_theta, input.long_theta) {
163 (Some(st), Some(lt)) => {
164 let per_share = lt - st;
166 Some(per_share * 100.0 * contracts as f64)
167 }
168 _ => None,
169 };
170
171 SpreadAnalytics {
172 is_put_spread: input.is_put_spread,
173 underlying_price: input.underlying_price,
174 short_strike: input.short_strike,
175 long_strike: input.long_strike,
176 width,
177 credit,
178 dte: input.dte,
179 chain_iv_pct: iv,
180 realized_vol_pct,
181 iv_rv_ratio,
182 short_delta: input.short_delta,
183 long_delta: input.long_delta,
184 short_theta: input.short_theta,
185 long_theta: input.long_theta,
186 net_theta_per_day_usd,
187 short_otm_pct,
188 approx_short_otm_prob_pct,
189 break_even_price: break_even,
190 distance_to_be_usd,
191 distance_to_be_pct,
192 expected_move_1sigma_usd,
193 expected_move_1sigma_pct,
194 short_strike_inside_1sigma,
195 spread_pop_pct,
196 credit_to_width_pct,
197 max_loss_per_spread_usd: Some(max_loss),
198 risk_reward_ratio,
199 underlying_change_pct: input.underlying_change_pct,
200 distance_to_short_strike_usd,
201 }
202}
203
204pub fn spread_win_score(
207 profit_pct: f64,
208 analytics: &SpreadAnalytics,
209 pct_cushion_from_stop: f64,
210) -> f64 {
211 let pop = analytics.spread_pop_pct.unwrap_or(50.0) / 100.0;
212 let otm = (analytics.short_otm_pct.unwrap_or(0.0) / 8.0).clamp(0.0, 1.0);
214 let be_cushion = (analytics.distance_to_be_pct.unwrap_or(0.0) / 12.0).clamp(0.0, 1.0);
215 let delta_comfort = analytics
216 .short_delta
217 .map(|d| (0.40 - d.abs()) / 0.30)
218 .unwrap_or(0.5)
219 .clamp(0.0, 1.0);
220 let theta = analytics
222 .net_theta_per_day_usd
223 .map(|t| ((t + 0.5) / 3.0).clamp(0.0, 1.0))
224 .unwrap_or(0.5);
225 let pnl = ((profit_pct + 40.0) / 100.0).clamp(0.0, 1.0);
227 let stop_room = (pct_cushion_from_stop / 100.0).clamp(0.0, 1.0);
228 (pop * 0.28
229 + otm * 0.28
230 + be_cushion * 0.12
231 + delta_comfort * 0.12
232 + theta * 0.08
233 + pnl * 0.07
234 + stop_room * 0.05)
235 * 100.0
236}
237
238pub fn entry_analytics_pass(entry: &crate::rules::VerticalEntryRules, a: &SpreadAnalytics) -> bool {
239 if let Some(min) = entry.min_pop_pct {
240 if a.spread_pop_pct.unwrap_or(0.0) < min {
241 return false;
242 }
243 }
244 if let Some(min) = entry.min_distance_to_be_pct {
245 if a.distance_to_be_pct.unwrap_or(0.0) < min {
246 return false;
247 }
248 }
249 let min_ctw = entry.min_credit_to_width_pct.unwrap_or(12.5);
250 if a.credit_to_width_pct.unwrap_or(0.0) < min_ctw {
251 return false;
252 }
253 if entry.reject_short_inside_1sigma && a.short_strike_inside_1sigma != Some(false) {
255 return false;
256 }
257 if let Some(min_ratio) = entry.min_iv_rv_ratio {
258 match a.iv_rv_ratio {
259 Some(ratio) if ratio >= min_ratio => {}
260 _ => return false, }
262 }
263 true
264}
265
266pub fn passes_min_iv_rv_ratio(min_ratio: Option<f64>, iv_rv: Option<f64>) -> bool {
268 match min_ratio {
269 None => true,
270 Some(min) => iv_rv.is_some_and(|r| r >= min),
271 }
272}
273
274pub fn analytics_to_json(a: &SpreadAnalytics) -> Value {
275 serde_json::to_value(a).unwrap_or(json!({}))
276}
277
278pub fn analytics_from_json(v: &Value) -> Option<SpreadAnalytics> {
279 serde_json::from_value(v.clone()).ok()
280}
281
282pub fn expected_move(spot: f64, iv_pct: f64, dte: i64) -> Option<f64> {
284 if spot <= 0.0 || iv_pct <= 0.0 || dte <= 0 {
285 return None;
286 }
287 let iv = iv_pct / 100.0;
288 let t = dte as f64 / 365.0;
289 Some(spot * iv * t.sqrt())
290}
291
292pub fn probability_above_price(spot: f64, price: f64, iv_pct: f64, dte: i64) -> Option<f64> {
294 if spot <= 0.0 || price <= 0.0 || iv_pct <= 0.0 || dte <= 0 {
295 return None;
296 }
297 let iv = iv_pct / 100.0;
298 let t = dte as f64 / 365.0;
299 let denom = iv * t.sqrt();
300 if denom <= f64::EPSILON {
301 return None;
302 }
303 let d = (spot / price).ln() / denom;
304 Some(normal_cdf(d) * 100.0)
305}
306
307fn strike_iv_fallback(_delta: Option<f64>) -> Option<f64> {
308 None
309}
310
311fn normal_cdf(x: f64) -> f64 {
312 0.5 * (1.0 + erf(x / std::f64::consts::SQRT_2))
313}
314
315fn erf(x: f64) -> f64 {
316 let sign = if x < 0.0 { -1.0 } else { 1.0 };
318 let x = x.abs();
319 let a1 = 0.254829592;
320 let a2 = -0.284496736;
321 let a3 = 1.421413741;
322 let a4 = -1.453152027;
323 let a5 = 1.061405429;
324 let p = 0.3275911;
325 let t = 1.0 / (1.0 + p * x);
326 let y = 1.0
327 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-x * x).exp();
328 sign * y
329}
330
331pub fn price_cushion_rail(
333 break_even: f64,
334 spot: f64,
335 short_strike: f64,
336 is_put_spread: bool,
337 width: usize,
338) -> (String, f64) {
339 let width = width.max(12);
340 if is_put_spread {
341 let lo = break_even.min(short_strike);
342 let hi = short_strike.max(break_even).max(spot);
343 let span = (hi - lo).max(0.01);
344 let mut chars: Vec<char> = vec!['·'; width];
345 let be_idx = ((break_even - lo) / span * (width.saturating_sub(1) as f64)).round() as usize;
346 let short_idx =
347 ((short_strike - lo) / span * (width.saturating_sub(1) as f64)).round() as usize;
348 let spot_idx = ((spot.clamp(lo, hi) - lo) / span * (width.saturating_sub(1) as f64))
349 .round() as usize;
350 if be_idx < width {
351 chars[be_idx] = 'B';
352 }
353 if short_idx < width && short_idx != be_idx {
354 chars[short_idx] = 'S';
355 }
356 if spot_idx < width {
357 chars[spot_idx] = '●';
358 }
359 let cushion_pct = ((spot - break_even) / span * 100.0).clamp(0.0, 200.0);
360 (chars.into_iter().collect(), cushion_pct)
361 } else {
362 let lo = short_strike.min(break_even).min(spot);
363 let hi = break_even.max(short_strike).max(spot);
364 let span = (hi - lo).max(0.01);
365 let mut chars: Vec<char> = vec!['·'; width];
366 let be_idx = ((break_even - lo) / span * (width.saturating_sub(1) as f64)).round() as usize;
367 let short_idx =
368 ((short_strike - lo) / span * (width.saturating_sub(1) as f64)).round() as usize;
369 let spot_idx = ((spot.clamp(lo, hi) - lo) / span * (width.saturating_sub(1) as f64))
370 .round() as usize;
371 if short_idx < width {
372 chars[short_idx] = 'S';
373 }
374 if be_idx < width && be_idx != short_idx {
375 chars[be_idx] = 'B';
376 }
377 if spot_idx < width {
378 chars[spot_idx] = '●';
379 }
380 let cushion_pct = ((break_even - spot) / span * 100.0).clamp(0.0, 200.0);
381 (chars.into_iter().collect(), cushion_pct)
382 }
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388
389 #[test]
390 fn expected_move_scales_with_sqrt_time() {
391 let em30 = expected_move(300.0, 20.0, 30).unwrap();
392 let em120 = expected_move(300.0, 20.0, 120).unwrap();
393 assert!(em120 > em30);
394 }
395
396 #[test]
397 fn put_credit_pop_above_break_even() {
398 let pop = probability_above_price(300.0, 280.0, 25.0, 35).unwrap();
399 assert!(pop > 60.0);
400 }
401
402 #[test]
403 fn vertical_analytics_put_credit() {
404 let a = compute_vertical_analytics(VerticalAnalyticsInput {
405 is_put_spread: true,
406 underlying_price: 299.0,
407 short_strike: 282.0,
408 long_strike: 280.0,
409 credit: 0.25,
410 dte: 36,
411 chain_iv_pct: Some(28.0),
412 realized_vol_pct: Some(20.0),
413 short_delta: Some(-0.22),
414 long_delta: Some(-0.15),
415 short_theta: Some(-0.08),
416 long_theta: Some(-0.05),
417 contracts: 1,
418 underlying_change_pct: Some(0.5),
419 });
420 assert!((a.break_even_price.unwrap() - 281.75).abs() < 0.01);
421 assert!(a.spread_pop_pct.unwrap() > 55.0);
422 assert!(a.distance_to_be_pct.unwrap() > 5.0);
423 assert!(a.net_theta_per_day_usd.unwrap() > 0.0);
424 }
425
426 #[test]
427 fn price_rail_marks_be_and_spot() {
428 let (rail, _) = price_cushion_rail(281.75, 299.0, 282.0, true, 24);
429 assert!(rail.contains('B'));
430 assert!(rail.contains('●'));
431 }
432
433 #[test]
434 fn entry_analytics_reject_inside_1sigma_when_enabled() {
435 let mut entry = crate::rules::VerticalEntryRules::default();
436 entry.min_pop_pct = Some(50.0);
437 entry.min_distance_to_be_pct = Some(1.0);
438 entry.min_credit_to_width_pct = Some(5.0);
439 entry.reject_short_inside_1sigma = true;
440
441 let mut a = SpreadAnalytics {
442 spread_pop_pct: Some(70.0),
443 distance_to_be_pct: Some(5.0),
444 credit_to_width_pct: Some(15.0),
445 short_strike_inside_1sigma: Some(true),
446 ..Default::default()
447 };
448 assert!(!entry_analytics_pass(&entry, &a));
449
450 a.short_strike_inside_1sigma = Some(false);
451 assert!(entry_analytics_pass(&entry, &a));
452
453 entry.reject_short_inside_1sigma = false;
454 a.short_strike_inside_1sigma = Some(true);
455 assert!(entry_analytics_pass(&entry, &a));
456 }
457
458 #[test]
459 fn entry_analytics_1sigma_fails_closed_when_iv_missing() {
460 let mut entry = crate::rules::VerticalEntryRules::default();
461 entry.min_pop_pct = Some(50.0);
462 entry.min_distance_to_be_pct = Some(1.0);
463 entry.min_credit_to_width_pct = Some(5.0);
464 entry.reject_short_inside_1sigma = true;
465
466 let a = SpreadAnalytics {
467 spread_pop_pct: Some(70.0),
468 distance_to_be_pct: Some(5.0),
469 credit_to_width_pct: Some(15.0),
470 short_strike_inside_1sigma: None,
471 ..Default::default()
472 };
473 assert!(!entry_analytics_pass(&entry, &a));
474 }
475
476 #[test]
477 fn entry_analytics_iv_rv_gate() {
478 let mut entry = crate::rules::VerticalEntryRules::default();
479 entry.min_pop_pct = Some(50.0);
480 entry.min_distance_to_be_pct = Some(1.0);
481 entry.min_credit_to_width_pct = Some(5.0);
482 entry.min_iv_rv_ratio = Some(1.15);
483
484 let mut a = SpreadAnalytics {
485 spread_pop_pct: Some(70.0),
486 distance_to_be_pct: Some(5.0),
487 credit_to_width_pct: Some(15.0),
488 short_strike_inside_1sigma: Some(false),
489 iv_rv_ratio: Some(1.05),
490 ..Default::default()
491 };
492 assert!(!entry_analytics_pass(&entry, &a));
493
494 a.iv_rv_ratio = Some(1.20);
495 assert!(entry_analytics_pass(&entry, &a));
496
497 a.iv_rv_ratio = None;
498 assert!(!entry_analytics_pass(&entry, &a));
499 }
500}