Skip to main content

kestrel_chartkit/
option.rs

1//! European vanilla option pricing, analytical Greeks, and implied volatility solver.
2//!
3//! Implements Black-Scholes-Merton (BSM) for spot/equity with continuous dividend yield,
4//! Black-76 for futures/forwards, analytical Greeks, put-call parity verification,
5//! and robust root-finding for implied volatility.
6
7use std::f64::consts::PI;
8use std::fmt;
9
10#[cfg(feature = "serde")]
11use serde::{Deserialize, Serialize};
12
13/// Type of option contract.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15#[cfg_attr(
16    feature = "serde",
17    derive(Serialize, Deserialize),
18    serde(rename_all = "snake_case")
19)]
20pub enum OptionType {
21    Call,
22    Put,
23}
24
25/// Exercise style of the option.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
27#[cfg_attr(
28    feature = "serde",
29    derive(Serialize, Deserialize),
30    serde(rename_all = "snake_case")
31)]
32pub enum OptionStyle {
33    #[default]
34    European,
35    American,
36}
37
38/// Core inputs required for Black-Scholes-Merton pricing.
39#[derive(Debug, Clone, PartialEq)]
40#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
41pub struct BlackScholesInputs {
42    /// Underlying spot price (must be positive).
43    pub spot: f64,
44    /// Option strike price (must be positive).
45    pub strike: f64,
46    /// Time to expiration in years (must be non-negative).
47    pub time_to_expiry_years: f64,
48    /// Continuous risk-free interest rate (e.g. 0.05 for 5%).
49    pub risk_free_rate: f64,
50    /// Continuous dividend yield or foreign interest rate (e.g. 0.02 for 2%).
51    pub dividend_yield: f64,
52    /// Annualized volatility (must be non-negative, e.g. 0.20 for 20%).
53    pub volatility: f64,
54}
55
56impl BlackScholesInputs {
57    /// Validates the input parameters.
58    pub fn validate(&self) -> Result<(), OptionError> {
59        if !self.spot.is_finite() || self.spot <= 0.0 {
60            return Err(OptionError::InvalidInput(
61                "spot price must be positive and finite",
62            ));
63        }
64        if !self.strike.is_finite() || self.strike <= 0.0 {
65            return Err(OptionError::InvalidInput(
66                "strike price must be positive and finite",
67            ));
68        }
69        if !self.time_to_expiry_years.is_finite() || self.time_to_expiry_years < 0.0 {
70            return Err(OptionError::InvalidInput(
71                "time to expiry must be non-negative and finite",
72            ));
73        }
74        if !self.risk_free_rate.is_finite() {
75            return Err(OptionError::InvalidInput("risk-free rate must be finite"));
76        }
77        if !self.dividend_yield.is_finite() {
78            return Err(OptionError::InvalidInput("dividend yield must be finite"));
79        }
80        if !self.volatility.is_finite() || self.volatility < 0.0 {
81            return Err(OptionError::InvalidInput(
82                "volatility must be non-negative and finite",
83            ));
84        }
85        Ok(())
86    }
87}
88
89/// First- and second-order price sensitivities (Greeks).
90#[derive(Debug, Clone, Copy, PartialEq)]
91#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
92pub struct OptionGreeks {
93    /// Delta ($\partial V / \partial S$): Sensitivity to underlying spot price.
94    pub delta: f64,
95    /// Gamma ($\partial^2 V / \partial S^2$): Rate of change of Delta.
96    pub gamma: f64,
97    /// Vega ($\partial V / \partial \sigma$): Sensitivity to a 1.0 (100 percentage point) change in volatility.
98    pub vega: f64,
99    /// Theta ($\partial V / \partial t$): Annualized rate of time decay (negative for standard long options).
100    pub theta_annual: f64,
101    /// Theta per calendar day (`theta_annual / 365.0`).
102    pub theta_daily: f64,
103    /// Rho ($\partial V / \partial r$): Sensitivity to a 1.0 (100 percentage point) change in risk-free rate.
104    pub rho: f64,
105}
106
107/// Complete evaluated outcome of an option pricing computation.
108#[derive(Debug, Clone, PartialEq)]
109#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
110pub struct OptionPricingResult {
111    /// Theoretical option price.
112    pub price: f64,
113    /// Immediate intrinsic exercise value ($\max(S - K, 0)$ for Call, $\max(K - S, 0)$ for Put).
114    pub intrinsic_value: f64,
115    /// Remaining time value (`price - intrinsic_value`).
116    pub time_value: f64,
117    /// Evaluated sensitivities (Greeks).
118    pub greeks: OptionGreeks,
119}
120
121/// Errors originating from option pricing or volatility inversion.
122#[derive(Debug, Clone, PartialEq)]
123pub enum OptionError {
124    InvalidInput(&'static str),
125    PriceBelowIntrinsic,
126    PriceAboveBoundary,
127    SolverMaxIterationsExceeded,
128    SolverFailedToConverge,
129}
130
131impl fmt::Display for OptionError {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        match self {
134            Self::InvalidInput(msg) => write!(f, "invalid option input: {msg}"),
135            Self::PriceBelowIntrinsic => write!(
136                f,
137                "option price violates lower arbitrage bound (below intrinsic value)"
138            ),
139            Self::PriceAboveBoundary => write!(f, "option price violates upper arbitrage bound"),
140            Self::SolverMaxIterationsExceeded => {
141                write!(f, "implied volatility solver exceeded maximum iterations")
142            }
143            Self::SolverFailedToConverge => {
144                write!(f, "implied volatility solver failed to converge")
145            }
146        }
147    }
148}
149
150impl std::error::Error for OptionError {}
151
152/// Evaluates a polynomial in Horner form: `lead` is the highest-order coefficient, `rest` the
153/// remaining ones in descending order.
154fn horner(x: f64, lead: f64, rest: &[f64]) -> f64 {
155    rest.iter()
156        .fold(lead, |acc, coefficient| acc * x + coefficient)
157}
158
159/// Standard normal cumulative distribution function $\Phi(x)$, evaluated with Hart's rational
160/// approximation.
161///
162/// Every option price, delta, theta and rho in this module passes through here, so this
163/// function's accuracy is their ceiling. Hart's form holds close to double precision across the
164/// body and both tails — `tests/golden_reference_option_diff.rs` pins it against independently
165/// generated reference values. The earlier Abramowitz & Stegun 7.1.26 approximation used here was accurate
166/// to about $1.5 \times 10^{-7}$ absolute, which showed up as errors of that order times the
167/// price level in every quantity derived from it.
168///
169/// Beyond $|x| = 37$ the result is 0 or 1 in double precision, and is returned as such.
170pub fn normal_cdf(x: f64) -> f64 {
171    let abs_x = x.abs();
172    if abs_x > 37.0 {
173        return if x > 0.0 { 1.0 } else { 0.0 };
174    }
175
176    let exponential = (-0.5 * abs_x * abs_x).exp();
177    let upper_tail = if abs_x < 7.071_067_811_865_475 {
178        // Rational approximation for the body, both polynomials in Horner form.
179        let numerator = horner(
180            abs_x,
181            3.526_249_659_989_109e-2,
182            &[
183                0.700_383_064_443_688,
184                6.373_962_203_531_65,
185                33.912_866_078_383,
186                112.079_291_497_871,
187                221.213_596_169_931,
188                220.206_867_912_376,
189            ],
190        );
191        let denominator = horner(
192            abs_x,
193            8.838_834_764_831_844e-2,
194            &[
195                1.755_667_163_182_64,
196                16.064_177_579_207,
197                86.780_732_202_946_1,
198                296.564_248_779_674,
199                637.333_633_378_831,
200                793.826_512_519_948,
201                440.413_735_824_752,
202            ],
203        );
204        exponential * numerator / denominator
205    } else {
206        // Continued fraction for the far tail, where the quotient above loses its digits.
207        let mut fraction = abs_x + 0.65;
208        for term in [4.0, 3.0, 2.0, 1.0] {
209            fraction = abs_x + term / fraction;
210        }
211        exponential / (fraction * 2.506_628_274_631_000_5)
212    };
213
214    if x > 0.0 {
215        1.0 - upper_tail
216    } else {
217        upper_tail
218    }
219}
220
221/// Standard normal probability density function $\phi(x) = \frac{1}{\sqrt{2\pi}} e^{-x^2 / 2}$.
222pub fn normal_pdf(x: f64) -> f64 {
223    (1.0 / (2.0 * PI).sqrt()) * (-0.5 * x * x).exp()
224}
225
226/// Evaluates a European vanilla option using the Black-Scholes-Merton formula with dividend yield.
227pub fn black_scholes_merton(
228    option_type: OptionType,
229    inputs: &BlackScholesInputs,
230) -> Result<OptionPricingResult, OptionError> {
231    inputs.validate()?;
232
233    let s = inputs.spot;
234    let k = inputs.strike;
235    let t = inputs.time_to_expiry_years;
236    let r = inputs.risk_free_rate;
237    let q = inputs.dividend_yield;
238    let sigma = inputs.volatility;
239
240    let intrinsic = match option_type {
241        OptionType::Call => (s - k).max(0.0),
242        OptionType::Put => (k - s).max(0.0),
243    };
244
245    // Boundary case: At expiration (T = 0) or zero volatility
246    if t <= 1e-12 || sigma <= 1e-12 {
247        let delta = match option_type {
248            OptionType::Call => {
249                if s > k {
250                    1.0
251                } else if (s - k).abs() < 1e-12 {
252                    0.5
253                } else {
254                    0.0
255                }
256            }
257            OptionType::Put => {
258                if s < k {
259                    -1.0
260                } else if (s - k).abs() < 1e-12 {
261                    -0.5
262                } else {
263                    0.0
264                }
265            }
266        };
267        return Ok(OptionPricingResult {
268            price: intrinsic,
269            intrinsic_value: intrinsic,
270            time_value: 0.0,
271            greeks: OptionGreeks {
272                delta,
273                gamma: 0.0,
274                vega: 0.0,
275                theta_annual: 0.0,
276                theta_daily: 0.0,
277                rho: 0.0,
278            },
279        });
280    }
281
282    let sqrt_t = t.sqrt();
283    let d1 = ((s / k).ln() + (r - q + 0.5 * sigma * sigma) * t) / (sigma * sqrt_t);
284    let d2 = d1 - sigma * sqrt_t;
285
286    let df_q = (-q * t).exp();
287    let df_r = (-r * t).exp();
288
289    let pdf_d1 = normal_pdf(d1);
290
291    let price = match option_type {
292        OptionType::Call => s * df_q * normal_cdf(d1) - k * df_r * normal_cdf(d2),
293        OptionType::Put => k * df_r * normal_cdf(-d2) - s * df_q * normal_cdf(-d1),
294    };
295
296    // Greeks
297    let delta = match option_type {
298        OptionType::Call => df_q * normal_cdf(d1),
299        OptionType::Put => df_q * (normal_cdf(d1) - 1.0),
300    };
301
302    let gamma = (df_q * pdf_d1) / (s * sigma * sqrt_t);
303    let vega = s * df_q * sqrt_t * pdf_d1;
304
305    let theta_common = -(s * df_q * pdf_d1 * sigma) / (2.0 * sqrt_t);
306    let theta_annual = match option_type {
307        OptionType::Call => {
308            theta_common - r * k * df_r * normal_cdf(d2) + q * s * df_q * normal_cdf(d1)
309        }
310        OptionType::Put => {
311            theta_common + r * k * df_r * normal_cdf(-d2) - q * s * df_q * normal_cdf(-d1)
312        }
313    };
314    let theta_daily = theta_annual / 365.0;
315
316    let rho = match option_type {
317        OptionType::Call => k * t * df_r * normal_cdf(d2),
318        OptionType::Put => -k * t * df_r * normal_cdf(-d2),
319    };
320
321    let time_value = (price - intrinsic).max(0.0);
322
323    Ok(OptionPricingResult {
324        price,
325        intrinsic_value: intrinsic,
326        time_value,
327        greeks: OptionGreeks {
328            delta,
329            gamma,
330            vega,
331            theta_annual,
332            theta_daily,
333            rho,
334        },
335    })
336}
337
338/// Evaluates a European option on a forward or futures contract using Black's 1976 model.
339pub fn black_76(
340    option_type: OptionType,
341    forward: f64,
342    strike: f64,
343    time_to_expiry_years: f64,
344    risk_free_rate: f64,
345    volatility: f64,
346) -> Result<OptionPricingResult, OptionError> {
347    if forward <= 0.0 {
348        return Err(OptionError::InvalidInput(
349            "forward must be positive and finite",
350        ));
351    }
352    // In Black-76, Spot = Forward and dividend yield q = risk_free_rate r so df_q = df_r.
353    let inputs = BlackScholesInputs {
354        spot: forward,
355        strike,
356        time_to_expiry_years,
357        risk_free_rate,
358        dividend_yield: risk_free_rate,
359        volatility,
360    };
361    black_scholes_merton(option_type, &inputs)
362}
363
364/// Inverts the Black-Scholes-Merton formula to find the implied volatility from a market price.
365///
366/// Uses bounded Brent-Newton hybrid iteration. Returns volatility as an annualized fraction (e.g. 0.20 for 20%).
367pub fn implied_volatility(
368    option_type: OptionType,
369    market_price: f64,
370    spot: f64,
371    strike: f64,
372    time_to_expiry_years: f64,
373    risk_free_rate: f64,
374    dividend_yield: f64,
375) -> Result<f64, OptionError> {
376    if !market_price.is_finite() || market_price <= 0.0 {
377        return Err(OptionError::InvalidInput(
378            "market price must be positive and finite",
379        ));
380    }
381    if time_to_expiry_years <= 1e-12 {
382        return Err(OptionError::InvalidInput(
383            "cannot solve IV for expired option (T=0)",
384        ));
385    }
386
387    let df_q = (-dividend_yield * time_to_expiry_years).exp();
388    let df_r = (-risk_free_rate * time_to_expiry_years).exp();
389
390    // Arbitrage bounds check
391    let (lower_bound, upper_bound) = match option_type {
392        OptionType::Call => ((spot * df_q - strike * df_r).max(0.0), spot * df_q),
393        OptionType::Put => ((strike * df_r - spot * df_q).max(0.0), strike * df_r),
394    };
395
396    if market_price < lower_bound - 1e-7 {
397        return Err(OptionError::PriceBelowIntrinsic);
398    }
399    if market_price > upper_bound + 1e-7 {
400        return Err(OptionError::PriceAboveBoundary);
401    }
402
403    // Base template
404    let mut inputs = BlackScholesInputs {
405        spot,
406        strike,
407        time_to_expiry_years,
408        risk_free_rate,
409        dividend_yield,
410        volatility: 0.20,
411    };
412
413    // Bracket search: find lower and upper vol bounds
414    let mut vol_low = 1e-4f64;
415    let mut vol_high = 5.0f64;
416
417    inputs.volatility = vol_low;
418    let price_low = black_scholes_merton(option_type, &inputs)?.price;
419    if (price_low - market_price).abs() < 1e-7 {
420        return Ok(vol_low);
421    }
422
423    inputs.volatility = vol_high;
424    let mut price_high = black_scholes_merton(option_type, &inputs)?.price;
425    while price_high < market_price && vol_high < 20.0 {
426        vol_high *= 2.0;
427        inputs.volatility = vol_high;
428        price_high = black_scholes_merton(option_type, &inputs)?.price;
429    }
430
431    if price_high < market_price {
432        return Err(OptionError::PriceAboveBoundary);
433    }
434
435    // Hybrid Newton-Raphson / Bisection
436    let mut current_vol = 0.5 * (vol_low + vol_high);
437    let max_iter = 100;
438    let tol = 1e-8;
439
440    for _ in 0..max_iter {
441        inputs.volatility = current_vol;
442        let res = black_scholes_merton(option_type, &inputs)?;
443        let diff = res.price - market_price;
444
445        if diff.abs() < tol {
446            return Ok(current_vol);
447        }
448
449        // Update bracket
450        if diff > 0.0 {
451            vol_high = current_vol;
452        } else {
453            vol_low = current_vol;
454        }
455
456        let vega = res.greeks.vega;
457        let mut step_accepted = false;
458
459        if vega > 1e-12 {
460            let next_newton = current_vol - diff / vega;
461            if next_newton > vol_low && next_newton < vol_high {
462                current_vol = next_newton;
463                step_accepted = true;
464            }
465        }
466
467        if !step_accepted {
468            // Fallback to bisection
469            current_vol = 0.5 * (vol_low + vol_high);
470        }
471
472        if (vol_high - vol_low) < 1e-10 {
473            return Ok(current_vol);
474        }
475    }
476
477    Ok(current_vol)
478}
479
480/// Verifies European put-call parity and returns the pricing discrepancy.
481///
482/// Parity equation: $C - P = S e^{-q T} - K e^{-r T}$.
483/// Returns $(C - P) - (S e^{-q T} - K e^{-r T})$.
484/// A value close to zero (e.g. $|diff| < 10^{-6}$) indicates exact put-call parity.
485pub fn verify_put_call_parity(
486    call_price: f64,
487    put_price: f64,
488    spot: f64,
489    strike: f64,
490    time_to_expiry_years: f64,
491    risk_free_rate: f64,
492    dividend_yield: f64,
493) -> f64 {
494    let forward_term = spot * (-dividend_yield * time_to_expiry_years).exp();
495    let discount_strike = strike * (-risk_free_rate * time_to_expiry_years).exp();
496    (call_price - put_price) - (forward_term - discount_strike)
497}