Skip to main content

fin_primitives/
greeks.rs

1//! Black-Scholes pricing, the five Greeks, implied volatility and multi-leg spreads.
2//!
3//! ## Responsibility
4//! Black-Scholes option pricing, Greeks computation, implied volatility via bisection,
5//! and multi-leg spread Greeks aggregation.
6//!
7//! ## Guarantees
8//! - All math returns `Result<T, FinError>`; no panics on edge-case inputs
9//! - Intermediate floating-point (f64) is used only for transcendental functions,
10//!   then converted back to `Decimal` before returning
11//!
12//! ## NOT Responsible For
13//! - American-style option pricing (European only)
14//! - Dividend adjustments
15
16use crate::error::FinError;
17use rust_decimal::prelude::ToPrimitive;
18use rust_decimal::Decimal;
19
20// ─── helpers ─────────────────────────────────────────────────────────────────
21
22/// Standard normal probability density function.
23fn phi(x: f64) -> f64 {
24    (-0.5 * x * x).exp() / (2.0 * std::f64::consts::PI).sqrt()
25}
26
27/// Standard normal cumulative distribution function (Abramowitz & Stegun approximation).
28/// Maximum absolute error ≈ 7.5 × 10⁻⁸.
29fn big_phi(x: f64) -> f64 {
30    let t = 1.0 / (1.0 + 0.2316419 * x.abs());
31    let poly = t
32        * (0.319_381_530
33            + t * (-0.356_563_782
34                + t * (1.781_477_937 + t * (-1.821_255_978 + t * 1.330_274_429))));
35    let pdf = phi(x);
36    let cdf_pos = 1.0 - pdf * poly;
37    if x >= 0.0 { cdf_pos } else { 1.0 - cdf_pos }
38}
39
40/// Convert `Decimal` to `f64`, returning `FinError::ArithmeticOverflow` on failure.
41fn to_f64(d: Decimal) -> Result<f64, FinError> {
42    d.to_f64().ok_or(FinError::ArithmeticOverflow)
43}
44
45/// Convert `f64` to `Decimal`, returning `FinError::ArithmeticOverflow` on failure.
46fn from_f64(f: f64) -> Result<Decimal, FinError> {
47    if !f.is_finite() {
48        return Err(FinError::ArithmeticOverflow);
49    }
50    Decimal::try_from(f).map_err(|_| FinError::ArithmeticOverflow)
51}
52
53// ─── types ────────────────────────────────────────────────────────────────────
54
55/// Whether the option grants the right to buy (Call) or sell (Put).
56#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
57pub enum OptionType {
58    /// The right to buy the underlying at the strike price.
59    Call,
60    /// The right to sell the underlying at the strike price.
61    Put,
62}
63
64/// Complete specification of a European option.
65#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
66pub struct OptionSpec {
67    /// Strike / exercise price (must be positive).
68    pub strike: Decimal,
69    /// Time to expiry in calendar days (must be > 0).
70    pub expiry_days: u32,
71    /// Current underlying spot price (must be positive).
72    pub spot: Decimal,
73    /// Continuously compounded annual risk-free rate (e.g. `dec!(0.05)` for 5%).
74    pub risk_free_rate: Decimal,
75    /// Annual implied or historical volatility (e.g. `dec!(0.20)` for 20%; must be > 0).
76    pub volatility: Decimal,
77    /// Call or Put.
78    pub option_type: OptionType,
79}
80
81impl OptionSpec {
82    /// Validates all fields and returns `FinError::InvalidInput` on the first violation.
83    fn validate(&self) -> Result<(), FinError> {
84        if self.strike <= Decimal::ZERO {
85            return Err(FinError::InvalidInput(
86                "strike must be positive".to_owned(),
87            ));
88        }
89        if self.expiry_days == 0 {
90            return Err(FinError::InvalidInput(
91                "expiry_days must be > 0".to_owned(),
92            ));
93        }
94        if self.spot <= Decimal::ZERO {
95            return Err(FinError::InvalidInput(
96                "spot must be positive".to_owned(),
97            ));
98        }
99        if self.volatility <= Decimal::ZERO {
100            return Err(FinError::InvalidInput(
101                "volatility must be positive".to_owned(),
102            ));
103        }
104        Ok(())
105    }
106}
107
108/// The five standard Black-Scholes option Greeks.
109#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
110pub struct OptionGreeks {
111    /// Rate of change of option price with respect to spot (∂V/∂S).
112    pub delta: Decimal,
113    /// Rate of change of delta with respect to spot (∂²V/∂S²).
114    pub gamma: Decimal,
115    /// Rate of change of option price with respect to time (∂V/∂t), expressed per calendar day.
116    pub theta: Decimal,
117    /// Rate of change of option price with respect to volatility (∂V/∂σ), per 1-point move.
118    pub vega: Decimal,
119    /// Rate of change of option price with respect to the risk-free rate (∂V/∂r).
120    pub rho: Decimal,
121}
122
123// ─── BlackScholes ─────────────────────────────────────────────────────────────
124
125/// Black-Scholes European option pricing model.
126///
127/// All arithmetic is done in `f64` for transcendental functions, then the final
128/// results are converted back to `Decimal`.
129pub struct BlackScholes;
130
131impl BlackScholes {
132    /// Computes the five standard Greeks for a European option.
133    ///
134    /// # Errors
135    /// Returns [`FinError::InvalidInput`] if any spec field is invalid.
136    /// Returns [`FinError::ArithmeticOverflow`] if a conversion fails.
137    pub fn greeks(spec: &OptionSpec) -> Result<OptionGreeks, FinError> {
138        spec.validate()?;
139
140        let s = to_f64(spec.spot)?;
141        let k = to_f64(spec.strike)?;
142        let r = to_f64(spec.risk_free_rate)?;
143        let v = to_f64(spec.volatility)?;
144        // Years to expiry (trading-calendar-agnostic: use 365 days / year)
145        let t = f64::from(spec.expiry_days) / 365.0;
146
147        let sqrt_t = t.sqrt();
148        let ln_sk = (s / k).ln();
149        let d1 = (ln_sk + (r + 0.5 * v * v) * t) / (v * sqrt_t);
150        let d2 = d1 - v * sqrt_t;
151        let exp_rt = (-r * t).exp();
152
153        let (delta, gamma, theta, vega, rho) = match spec.option_type {
154            OptionType::Call => {
155                let nd1 = big_phi(d1);
156                let nd2 = big_phi(d2);
157                let phi_d1 = phi(d1);
158
159                let delta = nd1;
160                let gamma = phi_d1 / (s * v * sqrt_t);
161                // Theta: daily decay (divide annualised by 365)
162                let theta =
163                    (-(s * phi_d1 * v) / (2.0 * sqrt_t) - r * k * exp_rt * nd2) / 365.0;
164                let vega = s * phi_d1 * sqrt_t / 100.0; // per 1 vol-point
165                let rho = k * t * exp_rt * nd2 / 100.0; // per 1 rate-point
166
167                (delta, gamma, theta, vega, rho)
168            }
169            OptionType::Put => {
170                // N(d1) - 1 = -N(-d1); we compute N(-d1) and negate for delta
171                let nd1_neg = big_phi(-d1); // N(-d1)
172                let nd2_neg = big_phi(-d2); // N(-d2)
173                let phi_d1 = phi(d1);
174
175                // put delta = N(d1) - 1 = -N(-d1)
176                let delta = -nd1_neg;
177                let gamma = phi_d1 / (s * v * sqrt_t);
178                let theta =
179                    (-(s * phi_d1 * v) / (2.0 * sqrt_t) + r * k * exp_rt * nd2_neg) / 365.0;
180                let vega = s * phi_d1 * sqrt_t / 100.0;
181                let rho = -k * t * exp_rt * nd2_neg / 100.0;
182
183                (delta, gamma, theta, vega, rho)
184            }
185        };
186
187        Ok(OptionGreeks {
188            delta: from_f64(delta)?,
189            gamma: from_f64(gamma)?,
190            theta: from_f64(theta)?,
191            vega: from_f64(vega)?,
192            rho: from_f64(rho)?,
193        })
194    }
195
196    /// Computes the Black-Scholes theoretical price of a European option.
197    ///
198    /// # Errors
199    /// Returns [`FinError::InvalidInput`] if any spec field is invalid.
200    /// Returns [`FinError::ArithmeticOverflow`] if a conversion fails.
201    pub fn price(spec: &OptionSpec) -> Result<Decimal, FinError> {
202        spec.validate()?;
203
204        let s = to_f64(spec.spot)?;
205        let k = to_f64(spec.strike)?;
206        let r = to_f64(spec.risk_free_rate)?;
207        let v = to_f64(spec.volatility)?;
208        let t = f64::from(spec.expiry_days) / 365.0;
209
210        let sqrt_t = t.sqrt();
211        let d1 = ((s / k).ln() + (r + 0.5 * v * v) * t) / (v * sqrt_t);
212        let d2 = d1 - v * sqrt_t;
213        let exp_rt = (-r * t).exp();
214
215        let price = match spec.option_type {
216            OptionType::Call => s * big_phi(d1) - k * exp_rt * big_phi(d2),
217            OptionType::Put => k * exp_rt * big_phi(-d2) - s * big_phi(-d1),
218        };
219
220        from_f64(price)
221    }
222
223    /// Computes implied volatility via bisection search.
224    ///
225    /// Searches in the interval `[1e-6, 5.0]` (0.0001% – 500% annualised vol).
226    /// Converges to within `tol = 1e-7` or fails after `MAX_ITER = 200` iterations.
227    ///
228    /// # Errors
229    /// - [`FinError::InvalidInput`] if `market_price` is non-positive, or any spec field is
230    ///   invalid (volatility field is ignored during the search).
231    /// - [`FinError::InvalidInput`] if the market price is outside the no-arbitrage bounds.
232    /// - [`FinError::ArithmeticOverflow`] on conversion failure.
233    pub fn implied_vol(market_price: Decimal, spec: &OptionSpec) -> Result<Decimal, FinError> {
234        if market_price <= Decimal::ZERO {
235            return Err(FinError::InvalidInput(
236                "market_price must be positive".to_owned(),
237            ));
238        }
239        // Validate all fields except volatility (which we are solving for)
240        if spec.strike <= Decimal::ZERO {
241            return Err(FinError::InvalidInput(
242                "strike must be positive".to_owned(),
243            ));
244        }
245        if spec.expiry_days == 0 {
246            return Err(FinError::InvalidInput(
247                "expiry_days must be > 0".to_owned(),
248            ));
249        }
250        if spec.spot <= Decimal::ZERO {
251            return Err(FinError::InvalidInput(
252                "spot must be positive".to_owned(),
253            ));
254        }
255
256        let target = to_f64(market_price)?;
257
258        const LOW_VOL: f64 = 1e-6;
259        const HIGH_VOL: f64 = 5.0;
260        const TOL: f64 = 1e-7;
261        const MAX_ITER: usize = 200;
262
263        let price_at = |vol: f64| -> Result<f64, FinError> {
264            let trial_spec = OptionSpec {
265                volatility: from_f64(vol)?,
266                ..spec.clone()
267            };
268            to_f64(Self::price(&trial_spec)?)
269        };
270
271        let mut lo = LOW_VOL;
272        let mut hi = HIGH_VOL;
273
274        let p_lo = price_at(lo)?;
275        let p_hi = price_at(hi)?;
276
277        // Check that target is bracketed
278        if target < p_lo || target > p_hi {
279            return Err(FinError::InvalidInput(
280                "market_price is outside no-arbitrage vol bounds [1e-6, 500%]".to_owned(),
281            ));
282        }
283
284        for _ in 0..MAX_ITER {
285            let mid = (lo + hi) / 2.0;
286            let p_mid = price_at(mid)?;
287            let err = p_mid - target;
288            if err.abs() < TOL {
289                return from_f64(mid);
290            }
291            if err < 0.0 {
292                lo = mid;
293            } else {
294                hi = mid;
295            }
296        }
297
298        // Return best midpoint after max iterations
299        from_f64((lo + hi) / 2.0)
300    }
301}
302
303// ─── Spread Greeks ────────────────────────────────────────────────────────────
304
305/// A single leg within a multi-leg spread position.
306#[derive(Debug, Clone)]
307pub struct Leg {
308    /// Option specification for this leg.
309    pub spec: OptionSpec,
310    /// Number of contracts (positive = long, negative = short).
311    pub quantity: i32,
312}
313
314impl Leg {
315    /// Creates a new leg.
316    pub fn new(spec: OptionSpec, quantity: i32) -> Self {
317        Self { spec, quantity }
318    }
319
320    /// Computes the Greeks for this leg, scaled by `quantity`.
321    fn scaled_greeks(&self) -> Result<OptionGreeks, FinError> {
322        let g = BlackScholes::greeks(&self.spec)?;
323        let q = from_f64(f64::from(self.quantity))?;
324        Ok(OptionGreeks {
325            delta: g.delta * q,
326            gamma: g.gamma * q,
327            theta: g.theta * q,
328            vega: g.vega * q,
329            rho: g.rho * q,
330        })
331    }
332}
333
334/// Aggregates Greeks across a user-defined set of legs.
335#[derive(Debug, Clone)]
336pub struct SpreadGreeks {
337    legs: Vec<Leg>,
338}
339
340impl SpreadGreeks {
341    /// Creates a `SpreadGreeks` from an arbitrary list of legs.
342    pub fn new(legs: Vec<Leg>) -> Self {
343        Self { legs }
344    }
345
346    /// Constructs a bull call spread: long lower-strike call, short higher-strike call.
347    ///
348    /// Both legs share the same `spot`, `expiry_days`, `risk_free_rate`, and `volatility`.
349    pub fn bull_call_spread(
350        spot: Decimal,
351        low_strike: Decimal,
352        high_strike: Decimal,
353        expiry_days: u32,
354        risk_free_rate: Decimal,
355        volatility: Decimal,
356    ) -> Self {
357        let base = |strike| OptionSpec {
358            strike,
359            expiry_days,
360            spot,
361            risk_free_rate,
362            volatility,
363            option_type: OptionType::Call,
364        };
365        Self::new(vec![
366            Leg::new(base(low_strike), 1),
367            Leg::new(base(high_strike), -1),
368        ])
369    }
370
371    /// Constructs a bear put spread: long higher-strike put, short lower-strike put.
372    pub fn bear_put_spread(
373        spot: Decimal,
374        low_strike: Decimal,
375        high_strike: Decimal,
376        expiry_days: u32,
377        risk_free_rate: Decimal,
378        volatility: Decimal,
379    ) -> Self {
380        let base = |strike, ot| OptionSpec {
381            strike,
382            expiry_days,
383            spot,
384            risk_free_rate,
385            volatility,
386            option_type: ot,
387        };
388        Self::new(vec![
389            Leg::new(base(high_strike, OptionType::Put), 1),
390            Leg::new(base(low_strike, OptionType::Put), -1),
391        ])
392    }
393
394    /// Constructs an ATM straddle: long call and long put at the same strike.
395    pub fn straddle(
396        spot: Decimal,
397        strike: Decimal,
398        expiry_days: u32,
399        risk_free_rate: Decimal,
400        volatility: Decimal,
401    ) -> Self {
402        let base = |ot| OptionSpec {
403            strike,
404            expiry_days,
405            spot,
406            risk_free_rate,
407            volatility,
408            option_type: ot,
409        };
410        Self::new(vec![
411            Leg::new(base(OptionType::Call), 1),
412            Leg::new(base(OptionType::Put), 1),
413        ])
414    }
415
416    /// Constructs an iron condor: short put spread + short call spread.
417    ///
418    /// - Long put at `put_low`, short put at `put_high`
419    /// - Short call at `call_low`, long call at `call_high`
420    ///
421    /// Strikes must satisfy: `put_low < put_high < call_low < call_high`
422    #[allow(clippy::too_many_arguments)]
423    pub fn iron_condor(
424        spot: Decimal,
425        put_low: Decimal,
426        put_high: Decimal,
427        call_low: Decimal,
428        call_high: Decimal,
429        expiry_days: u32,
430        risk_free_rate: Decimal,
431        volatility: Decimal,
432    ) -> Self {
433        let mk = |strike, ot, qty| {
434            Leg::new(
435                OptionSpec {
436                    strike,
437                    expiry_days,
438                    spot,
439                    risk_free_rate,
440                    volatility,
441                    option_type: ot,
442                },
443                qty,
444            )
445        };
446        Self::new(vec![
447            mk(put_low, OptionType::Put, 1),
448            mk(put_high, OptionType::Put, -1),
449            mk(call_low, OptionType::Call, -1),
450            mk(call_high, OptionType::Call, 1),
451        ])
452    }
453
454    /// Returns the net (aggregated) Greeks across all legs.
455    ///
456    /// # Errors
457    /// Returns the first [`FinError`] encountered while computing any leg's Greeks.
458    pub fn net_greeks(&self) -> Result<OptionGreeks, FinError> {
459        let mut delta = Decimal::ZERO;
460        let mut gamma = Decimal::ZERO;
461        let mut theta = Decimal::ZERO;
462        let mut vega = Decimal::ZERO;
463        let mut rho = Decimal::ZERO;
464
465        for leg in &self.legs {
466            let g = leg.scaled_greeks()?;
467            delta += g.delta;
468            gamma += g.gamma;
469            theta += g.theta;
470            vega += g.vega;
471            rho += g.rho;
472        }
473
474        Ok(OptionGreeks { delta, gamma, theta, vega, rho })
475    }
476
477    /// Returns the number of legs in this spread.
478    pub fn leg_count(&self) -> usize {
479        self.legs.len()
480    }
481}
482
483// ─── f64-native interface ─────────────────────────────────────────────────────
484//
485// The following types provide a direct f64-based Black-Scholes API that is
486// more convenient for numerical computation (e.g. calibration loops and ML
487// feature generation) than the Decimal-based `OptionSpec` / `BlackScholes`
488// interface above.  Both APIs share the same underlying math.
489
490/// Black-Scholes parameters (f64 native, no `Decimal` conversion).
491#[derive(Debug, Clone, Copy)]
492pub struct BSParams {
493    /// Current underlying spot price (must be positive).
494    pub spot: f64,
495    /// Strike / exercise price (must be positive).
496    pub strike: f64,
497    /// Time to expiry in **years** (must be > 0).
498    pub time_to_expiry: f64,
499    /// Continuously compounded annual risk-free rate (e.g. `0.05` for 5%).
500    pub risk_free_rate: f64,
501    /// Annual implied or historical volatility (e.g. `0.20` for 20%; must be > 0).
502    pub volatility: f64,
503    /// Call or Put.
504    pub option_type: OptionType,
505}
506
507/// Full set of first- and second-order Black-Scholes Greeks.
508///
509/// All Greeks use standard market conventions:
510/// - `theta` is per **calendar day** (divide by 365 from annual).
511/// - `vega` is per **1 percentage-point** move in vol (i.e. per 0.01 σ).
512/// - `rho` is per **1 percentage-point** move in the risk-free rate.
513#[derive(Debug, Clone, Copy)]
514pub struct Greeks {
515    /// ∂V/∂S — sensitivity to spot price.
516    pub delta: f64,
517    /// ∂²V/∂S² — rate of change of delta with respect to spot.
518    pub gamma: f64,
519    /// ∂V/∂t per calendar day (negative for long options = time decay).
520    pub theta: f64,
521    /// ∂V/∂σ per 1% vol move.
522    pub vega: f64,
523    /// ∂V/∂r per 1% rate move.
524    pub rho: f64,
525    /// ∂Delta/∂σ (also written dDelta/dVol or dVega/dS).
526    pub vanna: f64,
527    /// ∂²V/∂σ² per 1% vol move (also called Vomma or Volga).
528    pub volga: f64,
529    /// ∂Delta/∂t per calendar day — rate of change of delta due to time decay.
530    pub charm: f64,
531    /// ∂Gamma/∂S — third-order spot sensitivity.
532    pub speed: f64,
533}
534
535/// Black-Scholes pricing and Greeks calculator using native `f64`.
536///
537/// All methods are pure functions; none mutate state.  Edge-case inputs
538/// (e.g. zero time, negative vol) return `None` rather than panicking.
539pub struct BSCalculator;
540
541impl BSCalculator {
542    /// Black-Scholes theoretical price.
543    ///
544    /// Returns `None` if any parameter is non-positive (spot, strike, vol) or
545    /// time-to-expiry is zero.
546    pub fn price(p: &BSParams) -> Option<f64> {
547        let (d1, d2) = Self::d1_d2(p)?;
548        let exp_rt = (-p.risk_free_rate * p.time_to_expiry).exp();
549        let price = match p.option_type {
550            OptionType::Call => p.spot * norm_cdf(d1) - p.strike * exp_rt * norm_cdf(d2),
551            OptionType::Put => p.strike * exp_rt * norm_cdf(-d2) - p.spot * norm_cdf(-d1),
552        };
553        Some(price)
554    }
555
556    /// Full set of first- and second-order Greeks.
557    ///
558    /// Returns `None` if parameters are invalid (same conditions as [`price`]).
559    ///
560    /// [`price`]: BSCalculator::price
561    pub fn greeks(p: &BSParams) -> Option<Greeks> {
562        let (d1, d2) = Self::d1_d2(p)?;
563        let s = p.spot;
564        let k = p.strike;
565        let r = p.risk_free_rate;
566        let v = p.volatility;
567        let t = p.time_to_expiry;
568        let sqrt_t = t.sqrt();
569        let exp_rt = (-r * t).exp();
570        let phi_d1 = norm_pdf(d1);
571
572        // ── First-order Greeks ───────────────────────────────────────────────
573        let (delta, theta, rho) = match p.option_type {
574            OptionType::Call => {
575                let nd1 = norm_cdf(d1);
576                let nd2 = norm_cdf(d2);
577                let delta = nd1;
578                let theta = (-(s * phi_d1 * v) / (2.0 * sqrt_t)
579                    - r * k * exp_rt * nd2)
580                    / 365.0;
581                let rho = k * t * exp_rt * nd2 / 100.0;
582                (delta, theta, rho)
583            }
584            OptionType::Put => {
585                let nd1_neg = norm_cdf(-d1);
586                let nd2_neg = norm_cdf(-d2);
587                let delta = nd1_neg - 1.0;
588                let theta = (-(s * phi_d1 * v) / (2.0 * sqrt_t)
589                    + r * k * exp_rt * nd2_neg)
590                    / 365.0;
591                let rho = -k * t * exp_rt * nd2_neg / 100.0;
592                (delta, theta, rho)
593            }
594        };
595
596        let gamma = phi_d1 / (s * v * sqrt_t);
597        let vega = s * phi_d1 * sqrt_t / 100.0; // per 1% vol move
598
599        // ── Second-order Greeks ──────────────────────────────────────────────
600        // Vanna: dDelta/dVol  (= dVega/dS * 1/spot)
601        let vanna = -phi_d1 * d2 / v;
602
603        // Volga / Vomma: dVega/dVol  per 1% vol move
604        // Raw: S * phi(d1) * sqrt(T) * d1 * d2 / vol
605        // Scaled by /100 twice (vega is per 1%, so volga is per 1%²)
606        let volga = vega * d1 * d2 / v;
607
608        // Charm: dDelta/dTime  per calendar day
609        // Call: -phi(d1) * [2rT - d2 * v * sqrt(T)] / [2T * v * sqrt(T)] / 365
610        let charm = match p.option_type {
611            OptionType::Call => {
612                (-phi_d1
613                    * (2.0 * r * t - d2 * v * sqrt_t)
614                    / (2.0 * t * v * sqrt_t))
615                    / 365.0
616            }
617            OptionType::Put => {
618                (phi_d1
619                    * (2.0 * r * t - d2 * v * sqrt_t)
620                    / (2.0 * t * v * sqrt_t))
621                    / 365.0
622            }
623        };
624
625        // Speed: dGamma/dS = -Gamma/S * (d1 / (v * sqrt_t) + 1)
626        let speed = -gamma / s * (d1 / (v * sqrt_t) + 1.0);
627
628        Some(Greeks { delta, gamma, theta, vega, rho, vanna, volga, charm, speed })
629    }
630
631    /// Delta only (faster than computing all Greeks).
632    pub fn delta(p: &BSParams) -> Option<f64> {
633        let (d1, _) = Self::d1_d2(p)?;
634        Some(match p.option_type {
635            OptionType::Call => norm_cdf(d1),
636            OptionType::Put => norm_cdf(-d1) - 1.0,
637        })
638    }
639
640    /// Gamma (same for calls and puts).
641    pub fn gamma(p: &BSParams) -> Option<f64> {
642        let (d1, _) = Self::d1_d2(p)?;
643        Some(norm_pdf(d1) / (p.spot * p.volatility * p.time_to_expiry.sqrt()))
644    }
645
646    /// Theta per calendar day.
647    pub fn theta(p: &BSParams) -> Option<f64> {
648        let (d1, d2) = Self::d1_d2(p)?;
649        let phi_d1 = norm_pdf(d1);
650        let exp_rt = (-p.risk_free_rate * p.time_to_expiry).exp();
651        let sqrt_t = p.time_to_expiry.sqrt();
652        let base = -(p.spot * phi_d1 * p.volatility) / (2.0 * sqrt_t);
653        let theta = match p.option_type {
654            OptionType::Call => (base - p.risk_free_rate * p.strike * exp_rt * norm_cdf(d2)) / 365.0,
655            OptionType::Put => (base + p.risk_free_rate * p.strike * exp_rt * norm_cdf(-d2)) / 365.0,
656        };
657        Some(theta)
658    }
659
660    /// Vega per 1% vol move.
661    pub fn vega(p: &BSParams) -> Option<f64> {
662        let (d1, _) = Self::d1_d2(p)?;
663        Some(p.spot * norm_pdf(d1) * p.time_to_expiry.sqrt() / 100.0)
664    }
665
666    /// Rho per 1% rate move.
667    pub fn rho(p: &BSParams) -> Option<f64> {
668        let (_, d2) = Self::d1_d2(p)?;
669        let exp_rt = (-p.risk_free_rate * p.time_to_expiry).exp();
670        Some(match p.option_type {
671            OptionType::Call => p.strike * p.time_to_expiry * exp_rt * norm_cdf(d2) / 100.0,
672            OptionType::Put => -p.strike * p.time_to_expiry * exp_rt * norm_cdf(-d2) / 100.0,
673        })
674    }
675
676    /// Newton-Raphson implied volatility solver.
677    ///
678    /// Searches for the volatility `σ` such that `BS_price(σ) == market_price`.
679    ///
680    /// - `tolerance` — convergence threshold for the price error (e.g. `1e-6`).
681    /// - `max_iter` — maximum Newton-Raphson iterations (e.g. `100`).
682    ///
683    /// Returns `None` when the solver does not converge or parameters are invalid.
684    pub fn implied_volatility(
685        market_price: f64,
686        p: &BSParams,
687        tolerance: f64,
688        max_iter: usize,
689    ) -> Option<f64> {
690        if market_price <= 0.0 || p.spot <= 0.0 || p.strike <= 0.0 || p.time_to_expiry <= 0.0 {
691            return None;
692        }
693
694        // Initial guess: Brenner-Subrahmanyam approximation
695        let mut sigma = (2.0 * std::f64::consts::PI / p.time_to_expiry).sqrt()
696            * market_price
697            / p.spot;
698        // Clamp to a reasonable range
699        sigma = sigma.clamp(1e-4, 5.0);
700
701        for _ in 0..max_iter {
702            let trial = BSParams { volatility: sigma, ..*p };
703            let price = Self::price(&trial)?;
704            let error = price - market_price;
705            if error.abs() < tolerance {
706                return Some(sigma);
707            }
708            let v = Self::vega(&trial)?;
709            // vega is per 1% → convert back to per unit for Newton step
710            let vega_raw = v * 100.0;
711            if vega_raw.abs() < 1e-10 {
712                break; // near-zero vega; cannot converge
713            }
714            sigma -= error / vega_raw;
715            sigma = sigma.clamp(1e-4, 5.0);
716        }
717
718        // Last attempt: return current sigma if within 10× tolerance
719        let trial = BSParams { volatility: sigma, ..*p };
720        let final_price = Self::price(&trial)?;
721        if (final_price - market_price).abs() < tolerance * 10.0 {
722            Some(sigma)
723        } else {
724            None
725        }
726    }
727
728    // ── private helpers ───────────────────────────────────────────────────────
729
730    /// Computes d1 and d2.  Returns `None` for invalid parameters.
731    fn d1_d2(p: &BSParams) -> Option<(f64, f64)> {
732        if p.spot <= 0.0 || p.strike <= 0.0 || p.volatility <= 0.0 || p.time_to_expiry <= 0.0 {
733            return None;
734        }
735        let sqrt_t = p.time_to_expiry.sqrt();
736        let d1 = ((p.spot / p.strike).ln()
737            + (p.risk_free_rate + 0.5 * p.volatility * p.volatility) * p.time_to_expiry)
738            / (p.volatility * sqrt_t);
739        let d2 = d1 - p.volatility * sqrt_t;
740        Some((d1, d2))
741    }
742}
743
744/// Standard normal PDF: φ(x) = exp(-x²/2) / √(2π).
745fn norm_pdf(x: f64) -> f64 {
746    (-0.5 * x * x).exp() / (2.0 * std::f64::consts::PI).sqrt()
747}
748
749/// Standard normal CDF via the Horner-form polynomial approximation.
750///
751/// Abramowitz & Stegun 26.2.17 — maximum absolute error ≈ 7.5 × 10⁻⁸.
752fn norm_cdf(x: f64) -> f64 {
753    let t = 1.0 / (1.0 + 0.2316419 * x.abs());
754    let poly = t
755        * (0.319_381_530
756            + t * (-0.356_563_782
757                + t * (1.781_477_937 + t * (-1.821_255_978 + t * 1.330_274_429))));
758    let cdf_pos = 1.0 - norm_pdf(x) * poly;
759    if x >= 0.0 { cdf_pos } else { 1.0 - cdf_pos }
760}
761
762// ─── BSCalculator tests ───────────────────────────────────────────────────────
763
764#[cfg(test)]
765mod bs_tests {
766    use super::*;
767
768    fn atm_call() -> BSParams {
769        BSParams {
770            spot: 100.0,
771            strike: 100.0,
772            time_to_expiry: 30.0 / 365.0,
773            risk_free_rate: 0.05,
774            volatility: 0.20,
775            option_type: OptionType::Call,
776        }
777    }
778
779    fn atm_put() -> BSParams {
780        BSParams { option_type: OptionType::Put, ..atm_call() }
781    }
782
783    #[test]
784    fn price_call_positive() {
785        let p = BSCalculator::price(&atm_call()).unwrap();
786        assert!(p > 0.0 && p < 10.0, "call price out of range: {p}");
787    }
788
789    #[test]
790    fn put_call_parity() {
791        let c = BSCalculator::price(&atm_call()).unwrap();
792        let p = BSCalculator::price(&atm_put()).unwrap();
793        let params = atm_call();
794        let forward = params.spot
795            - params.strike * (-params.risk_free_rate * params.time_to_expiry).exp();
796        assert!((c - p - forward).abs() < 1e-6, "put-call parity violated: {}", c - p - forward);
797    }
798
799    #[test]
800    fn delta_call_between_zero_and_one() {
801        let d = BSCalculator::delta(&atm_call()).unwrap();
802        assert!(d > 0.0 && d < 1.0, "call delta out of range: {d}");
803    }
804
805    #[test]
806    fn delta_put_between_neg_one_and_zero() {
807        let d = BSCalculator::delta(&atm_put()).unwrap();
808        assert!(d > -1.0 && d < 0.0, "put delta out of range: {d}");
809    }
810
811    #[test]
812    fn gamma_positive() {
813        let g = BSCalculator::gamma(&atm_call()).unwrap();
814        assert!(g > 0.0, "gamma should be positive: {g}");
815    }
816
817    #[test]
818    fn theta_negative_call() {
819        let t = BSCalculator::theta(&atm_call()).unwrap();
820        assert!(t < 0.0, "theta should be negative for long call: {t}");
821    }
822
823    #[test]
824    fn vega_positive() {
825        let v = BSCalculator::vega(&atm_call()).unwrap();
826        assert!(v > 0.0, "vega should be positive: {v}");
827    }
828
829    #[test]
830    fn all_greeks_available() {
831        let g = BSCalculator::greeks(&atm_call()).unwrap();
832        assert!(g.delta > 0.0);
833        assert!(g.gamma > 0.0);
834        assert!(g.theta < 0.0);
835        assert!(g.vega > 0.0);
836    }
837
838    #[test]
839    fn implied_vol_roundtrip() {
840        let params = atm_call();
841        let market_price = BSCalculator::price(&params).unwrap();
842        let iv = BSCalculator::implied_volatility(market_price, &params, 1e-6, 100).unwrap();
843        assert!((iv - params.volatility).abs() < 1e-4, "IV roundtrip error: {}", iv - params.volatility);
844    }
845
846    #[test]
847    fn implied_vol_invalid_price_returns_none() {
848        assert!(BSCalculator::implied_volatility(-1.0, &atm_call(), 1e-6, 100).is_none());
849    }
850
851    #[test]
852    fn invalid_params_return_none() {
853        let bad = BSParams { spot: -1.0, ..atm_call() };
854        assert!(BSCalculator::price(&bad).is_none());
855        assert!(BSCalculator::greeks(&bad).is_none());
856    }
857
858    #[test]
859    fn vanna_sign_for_atm_call() {
860        // Vanna for a call: -phi(d1)*d2/vol
861        // Near ATM d2 is small/positive → vanna should be near zero or slightly negative
862        let g = BSCalculator::greeks(&atm_call()).unwrap();
863        // Just verify it computes without NaN/Inf
864        assert!(g.vanna.is_finite(), "vanna should be finite: {}", g.vanna);
865    }
866
867    #[test]
868    fn speed_is_finite() {
869        let g = BSCalculator::greeks(&atm_call()).unwrap();
870        assert!(g.speed.is_finite(), "speed should be finite: {}", g.speed);
871    }
872}
873
874// ─── tests ────────────────────────────────────────────────────────────────────
875
876#[cfg(test)]
877mod tests {
878    use super::*;
879    use rust_decimal_macros::dec;
880
881    fn sample_call() -> OptionSpec {
882        OptionSpec {
883            strike: dec!(100),
884            expiry_days: 30,
885            spot: dec!(100),
886            risk_free_rate: dec!(0.05),
887            volatility: dec!(0.20),
888            option_type: OptionType::Call,
889        }
890    }
891
892    fn sample_put() -> OptionSpec {
893        OptionSpec { option_type: OptionType::Put, ..sample_call() }
894    }
895
896    #[test]
897    fn test_bs_price_call_atm_sanity() {
898        let price = BlackScholes::price(&sample_call()).unwrap();
899        // ATM call with 30-day expiry, 20% vol, 5% rf should be around 2.30–2.50
900        assert!(price > dec!(1) && price < dec!(5), "call price={price}");
901    }
902
903    #[test]
904    fn test_bs_price_put_atm_sanity() {
905        let price = BlackScholes::price(&sample_put()).unwrap();
906        assert!(price > dec!(1) && price < dec!(5), "put price={price}");
907    }
908
909    #[test]
910    fn test_put_call_parity() {
911        // C - P = S - K*e^{-rT}
912        let call_price = to_f64(BlackScholes::price(&sample_call()).unwrap()).unwrap();
913        let put_price = to_f64(BlackScholes::price(&sample_put()).unwrap()).unwrap();
914        let s = 100.0_f64;
915        let k = 100.0_f64;
916        let r = 0.05_f64;
917        let t = 30.0_f64 / 365.0;
918        let parity_rhs = s - k * (-r * t).exp();
919        let diff = (call_price - put_price - parity_rhs).abs();
920        assert!(diff < 1e-6, "put-call parity violation: {diff}");
921    }
922
923    #[test]
924    fn test_greeks_call_delta_between_zero_and_one() {
925        let g = BlackScholes::greeks(&sample_call()).unwrap();
926        assert!(g.delta > dec!(0) && g.delta < dec!(1));
927    }
928
929    #[test]
930    fn test_greeks_put_delta_between_neg_one_and_zero() {
931        let g = BlackScholes::greeks(&sample_put()).unwrap();
932        assert!(g.delta > dec!(-1) && g.delta < dec!(0));
933    }
934
935    #[test]
936    fn test_greeks_gamma_positive() {
937        let g = BlackScholes::greeks(&sample_call()).unwrap();
938        assert!(g.gamma > dec!(0));
939    }
940
941    #[test]
942    fn test_greeks_theta_negative_call() {
943        let g = BlackScholes::greeks(&sample_call()).unwrap();
944        assert!(g.theta < dec!(0));
945    }
946
947    #[test]
948    fn test_greeks_vega_positive() {
949        let g = BlackScholes::greeks(&sample_call()).unwrap();
950        assert!(g.vega > dec!(0));
951    }
952
953    #[test]
954    fn test_implied_vol_roundtrip() {
955        let spec = sample_call();
956        let market_price = BlackScholes::price(&spec).unwrap();
957        let iv = BlackScholes::implied_vol(market_price, &spec).unwrap();
958        let diff = (iv - spec.volatility).abs();
959        assert!(diff < dec!(0.0001), "IV roundtrip error: {diff}");
960    }
961
962    #[test]
963    fn test_invalid_strike_errors() {
964        let spec = OptionSpec { strike: dec!(0), ..sample_call() };
965        assert!(BlackScholes::price(&spec).is_err());
966    }
967
968    #[test]
969    fn test_invalid_spot_errors() {
970        let spec = OptionSpec { spot: dec!(-1), ..sample_call() };
971        assert!(BlackScholes::greeks(&spec).is_err());
972    }
973
974    #[test]
975    fn test_straddle_delta_near_zero_atm() {
976        let spread = SpreadGreeks::straddle(
977            dec!(100),
978            dec!(100),
979            30,
980            dec!(0.05),
981            dec!(0.20),
982        );
983        let g = spread.net_greeks().unwrap();
984        // ATM straddle: delta ≈ 0 (call ~0.5, put ~-0.5)
985        assert!(g.delta.abs() < dec!(0.1), "straddle delta={}", g.delta);
986    }
987
988    #[test]
989    fn test_bull_call_spread_positive_delta() {
990        let spread = SpreadGreeks::bull_call_spread(
991            dec!(100),
992            dec!(95),
993            dec!(105),
994            30,
995            dec!(0.05),
996            dec!(0.20),
997        );
998        let g = spread.net_greeks().unwrap();
999        assert!(g.delta > dec!(0));
1000    }
1001
1002    #[test]
1003    fn test_iron_condor_has_four_legs() {
1004        let spread = SpreadGreeks::iron_condor(
1005            dec!(100),
1006            dec!(85),
1007            dec!(90),
1008            dec!(110),
1009            dec!(115),
1010            30,
1011            dec!(0.05),
1012            dec!(0.20),
1013        );
1014        assert_eq!(spread.leg_count(), 4);
1015    }
1016
1017    #[test]
1018    fn test_bear_put_spread_negative_delta() {
1019        let spread = SpreadGreeks::bear_put_spread(
1020            dec!(100),
1021            dec!(95),
1022            dec!(105),
1023            30,
1024            dec!(0.05),
1025            dec!(0.20),
1026        );
1027        let g = spread.net_greeks().unwrap();
1028        assert!(g.delta < dec!(0));
1029    }
1030}