Skip to main content

schwab_cli/agent/
spread_analytics.rs

1//! Credit-spread analytics: POP, break-even, expected move, net theta.
2
3use serde::{Deserialize, Serialize};
4use serde_json::{json, Value};
5
6/// Enriched spread metrics for TUI, LLM context, and entry filters.
7#[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    /// Annualized realized vol (%) of the underlying over the configured lookback.
18    pub realized_vol_pct: Option<f64>,
19    /// `chain_iv_pct / realized_vol_pct` when both are available.
20    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    /// Position theta $/day per spread (positive = decay helps seller).
26    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            // Position theta: (-1)*short + (+1)*long per share; ×100 per contract.
165            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
204/// Composite 0–100 path-strength score for credit spreads.
205/// Weights probability / OTM cushion / delta over mark-to-market P&L — options are not stocks.
206pub 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    // OTM cushion: ~8% OTM ≈ full score (far from short strike).
213    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    // Positive net theta helps sellers as time passes.
221    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    // MTM is secondary — temporary debit widenings should not dominate.
226    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    // Fail-closed: when the 1σ gate is on, missing IV (None) rejects — never silently pass.
254    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, // missing IV/RV or ratio too low — fail closed
261        }
262    }
263    true
264}
265
266/// Shared IV/RV check for iron condors (and any caller with only the ratio threshold).
267pub 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
282/// 1σ expected move in dollars (lognormal, IV as annualized decimal %).
283pub 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
292/// P(S_T > price) at expiry under lognormal (risk-neutral, zero rates).
293pub 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    // Abramowitz & Stegun approximation
317    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
331/// Price rail for credit spreads: BE (left) → spot (●) → short strike.
332pub 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}