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}