Skip to main content

portfolio_analyzer/strategy/
pricing.rs

1//! # Pricing: what an option costs
2//!
3//! We have 25 years of stock prices but no historical option quotes.  So we
4//! *reprice* options with Black-Scholes, the textbook formula that turns
5//! (spot, strike, time, volatility, interest rate) into a fair price.
6//!
7//! The one input Black-Scholes cannot know is **implied volatility**: the
8//! market's own forecast of how much the stock will move.  Get that wrong and
9//! the whole backtest is fiction, which is why `sim.rs` uses, in order of
10//! preference:
11//!
12//! 1. **Real Cboe volatility indices** (VIX for SPY, VXN for QQQ, VXD for
13//!    DIA, GVZ for GLD, OVX for USO), daily since 2001-2008; then
14//! 2. a **proxy** fitted to those indices from realized volatility
15//!    (`Sd2Model::iv_proxy`, ρ ≈ 0.94), clearly flagged in every report.
16//!
17//! Volatility indices measure a 30-day *variance swap* level, which sits a
18//! little above at-the-money implied volatility because of put skew; we
19//! take ATM IV = 0.88 × index and add a simple linear skew so that
20//! out-of-the-money puts cost more than calls, as they do in real life.
21
22/// Standard normal CDF (Abramowitz & Stegun 7.1.26 via erf, |error| < 1.5e-7).
23pub fn norm_cdf(x: f64) -> f64 {
24    let t = 1.0 / (1.0 + 0.327_591_1 * x.abs() / std::f64::consts::SQRT_2);
25    let y = 1.0
26        - (((((1.061_405_429 * t - 1.453_152_027) * t) + 1.421_413_741) * t - 0.284_496_736) * t
27            + 0.254_829_592)
28            * t
29            * (-(x * x) / 2.0).exp();
30    if x >= 0.0 { 0.5 * (1.0 + y) } else { 0.5 * (1.0 - y) }
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
34pub enum Kind {
35    Call,
36    Put,
37}
38
39/// Black-Scholes price per share.  `t` in years, `sigma` annualized.
40pub fn bs_price(kind: Kind, spot: f64, strike: f64, t: f64, sigma: f64, rate: f64) -> f64 {
41    if t <= 0.0 || sigma <= 0.0 {
42        return match kind {
43            Kind::Call => (spot - strike).max(0.0),
44            Kind::Put => (strike - spot).max(0.0),
45        };
46    }
47    let sq = sigma * t.sqrt();
48    let d1 = ((spot / strike).ln() + (rate + 0.5 * sigma * sigma) * t) / sq;
49    let d2 = d1 - sq;
50    let df = (-rate * t).exp();
51    match kind {
52        Kind::Call => spot * norm_cdf(d1) - strike * df * norm_cdf(d2),
53        Kind::Put => strike * df * norm_cdf(-d2) - spot * norm_cdf(-d1),
54    }
55}
56
57/// ATM implied vol from a vol-index level (both annualized, as fractions).
58pub const ATM_FROM_INDEX: f64 = 0.88;
59
60/// Skewed implied vol at `strike`: puts below spot are dearer.
61pub fn skewed_iv(atm: f64, spot: f64, strike: f64) -> f64 {
62    let m = (strike / spot).ln();
63    (atm * (1.0 - 1.2 * m)).clamp(atm * 0.7, atm * 1.8)
64}
65
66/// Mean absolute move implied by an annualized vol over `days` trading days:
67/// `σ·√(days/252)·√(2/π)`.  Comparable to SD's `expected_move_pct`.
68pub fn implied_abs_move(iv: f64, days: f64) -> f64 {
69    iv * (days / 252.0).sqrt() * (2.0 / std::f64::consts::PI).sqrt()
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75
76    #[test]
77    fn put_call_parity_holds() {
78        let (s, k, t, v, r) = (100.0, 105.0, 0.25, 0.3, 0.03);
79        let c = bs_price(Kind::Call, s, k, t, v, r);
80        let p = bs_price(Kind::Put, s, k, t, v, r);
81        assert!((c - p - (s - k * (-r * t).exp())).abs() < 1e-6);
82    }
83
84    #[test]
85    fn atm_call_matches_the_textbook_value() {
86        // S=K=100, T=1, σ=20%, r=5% → 10.4506
87        assert!((bs_price(Kind::Call, 100.0, 100.0, 1.0, 0.2, 0.05) - 10.4506).abs() < 1e-3);
88    }
89}