Skip to main content

schwab_cli/agent/backtest/
bs.rs

1//! Black–Scholes helpers for synthetic option marks.
2
3/// Standard normal CDF (Abramowitz & Stegun approximation).
4pub fn norm_cdf(x: f64) -> f64 {
5    if x.is_nan() {
6        return f64::NAN;
7    }
8    let a1 = 0.254829592;
9    let a2 = -0.284496736;
10    let a3 = 1.421413741;
11    let a4 = -1.453152027;
12    let a5 = 1.061405429;
13    let p = 0.3275911;
14    let sign = if x < 0.0 { -1.0 } else { 1.0 };
15    let x = x.abs() / (2.0_f64).sqrt();
16    let t = 1.0 / (1.0 + p * x);
17    let y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-x * x).exp();
18    0.5 * (1.0 + sign * y)
19}
20
21fn d1_d2(spot: f64, strike: f64, t: f64, r: f64, sigma: f64) -> Option<(f64, f64)> {
22    if spot <= 0.0 || strike <= 0.0 || t <= 0.0 || sigma <= 0.0 {
23        return None;
24    }
25    let vol_sqrt_t = sigma * t.sqrt();
26    if vol_sqrt_t <= f64::EPSILON {
27        return None;
28    }
29    let d1 = ((spot / strike).ln() + (r + 0.5 * sigma * sigma) * t) / vol_sqrt_t;
30    let d2 = d1 - vol_sqrt_t;
31    Some((d1, d2))
32}
33
34/// European option mid price. `is_put` selects put vs call. `sigma` is decimal vol (0.20 = 20%).
35pub fn bs_price(is_put: bool, spot: f64, strike: f64, t_years: f64, r: f64, sigma: f64) -> f64 {
36    if t_years <= 0.0 {
37        return intrinsic(is_put, spot, strike);
38    }
39    let Some((d1, d2)) = d1_d2(spot, strike, t_years, r, sigma) else {
40        return intrinsic(is_put, spot, strike);
41    };
42    let disc = (-r * t_years).exp();
43    if is_put {
44        (strike * disc * norm_cdf(-d2) - spot * norm_cdf(-d1)).max(0.0)
45    } else {
46        (spot * norm_cdf(d1) - strike * disc * norm_cdf(d2)).max(0.0)
47    }
48}
49
50pub fn bs_delta(is_put: bool, spot: f64, strike: f64, t_years: f64, r: f64, sigma: f64) -> f64 {
51    if t_years <= 0.0 {
52        return if is_put {
53            if spot < strike {
54                -1.0
55            } else {
56                0.0
57            }
58        } else if spot > strike {
59            1.0
60        } else {
61            0.0
62        };
63    }
64    let Some((d1, _)) = d1_d2(spot, strike, t_years, r, sigma) else {
65        return 0.0;
66    };
67    if is_put {
68        norm_cdf(d1) - 1.0
69    } else {
70        norm_cdf(d1)
71    }
72}
73
74fn intrinsic(is_put: bool, spot: f64, strike: f64) -> f64 {
75    if is_put {
76        (strike - spot).max(0.0)
77    } else {
78        (spot - strike).max(0.0)
79    }
80}
81
82pub fn years_from_dte(dte: i64) -> f64 {
83    (dte.max(0) as f64) / 365.0
84}
85
86/// Credit vertical mid: short premium − long premium (both same right).
87pub fn vertical_credit(
88    is_put: bool,
89    spot: f64,
90    short_strike: f64,
91    long_strike: f64,
92    dte: i64,
93    iv_pct: f64,
94) -> f64 {
95    let t = years_from_dte(dte);
96    let sigma = (iv_pct / 100.0).max(0.01);
97    let short = bs_price(is_put, spot, short_strike, t, 0.0, sigma);
98    let long = bs_price(is_put, spot, long_strike, t, 0.0, sigma);
99    (short - long).max(0.01)
100}
101
102/// Debit to close a short credit vertical ≈ same formula as credit at current marks.
103pub fn vertical_debit_to_close(
104    is_put: bool,
105    spot: f64,
106    short_strike: f64,
107    long_strike: f64,
108    dte: i64,
109    iv_pct: f64,
110) -> f64 {
111    vertical_credit(is_put, spot, short_strike, long_strike, dte, iv_pct)
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn atm_call_has_near_half_delta() {
120        let d = bs_delta(false, 100.0, 100.0, 30.0 / 365.0, 0.0, 0.20);
121        assert!((d - 0.5).abs() < 0.05, "delta={d}");
122    }
123
124    #[test]
125    fn put_credit_positive() {
126        let c = vertical_credit(true, 500.0, 480.0, 475.0, 35, 18.0);
127        assert!(c > 0.05, "credit={c}");
128    }
129}