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