Skip to main content

finance_solution/derivatives/
black_scholes.rs

1//! # Black–Scholes–Merton European options
2//!
3//! Continuous dividend yield `q`. Call/put **prices** and first-order **Greeks**.
4//!
5//! ---
6//!
7//! ## Trading perspective — reading the Greeks
8//!
9//! | Greek | Sign (long call, typical) | How desks use it |
10//! |-------|---------------------------|------------------|
11//! | **Delta** | 0…1 | Hedge ratio; “30Δ call” screening; book Δ sum |
12//! | **Gamma** | ≥ 0 | Convexity; how often to re-hedge; pin risk near expiry |
13//! | **Vega** | ≥ 0 | IV long/short; event premium; surface relative value |
14//! | **Theta** | often ≤ 0 for long options | Overnight bleed; why short premium earns carry |
15//! | **Rho** | call ≥ 0, put ≤ 0 | Rate sensitivity; larger for long-dated |
16//!
17//! **Hedge sketch (per long call):** short ≈ `delta` shares of underlier to flatten Δ.
18//! Gamma makes that hedge wrong as spot moves — traders who are long gamma rebalance
19//! productively; short gamma pay for stability.
20//!
21//! **IV vs realized:** model vol is an *input*. Market IV is solved from mid
22//! ([`crate::derivatives::bsm_implied_vol`]). Comparing IV to realized vol (from
23//! [`crate::stocks`]) is a classic relative-value story — join those in *your* engine.
24//!
25//! ---
26//!
27//! ## Engineering perspective
28//!
29//! - Hot path: [`bsm_price`] / [`bsm_greeks`] or [`ValidatedBsm`] after one validate.  
30//! - Live tape: [`crate::derivatives::BsmState`] per contract.  
31//! - Teaching / audit: [`bsm_solution`] + [`BsmSolution::print_table`].  
32//! - Vega is per **+1.0** in σ; for “per vol point (1%)” use [`BsmGreeks::vega_per_vol_point`].  
33//! - Theta is per **year**; for daily use [`BsmGreeks::theta_per_calendar_day`].
34//!
35//! Limits handled explicitly: `T = 0` → intrinsic; `σ = 0` → discounted forward intrinsic.
36//!
37//! ---
38//!
39//! ## Word problem
40//!
41//! > Spot 100, strike 100, T=1y, r=5%, q=0, σ=20%. What is the BSM call roughly?
42//!
43//! Expect: about **10.45** (classic textbook ATM).
44//!
45//! ```
46//! use finance_solution::derivatives::{bsm_price, BsmParams, OptionType};
47//! let p = BsmParams::atm_one_year(100.0, 0.05, 0.20);
48//! let c = bsm_price(p, OptionType::Call).unwrap();
49//! assert!((c - 10.4506).abs() < 1e-3);
50//! ```
51//!
52//! ## Sample `bsm_solution` table
53//!
54//! ```text
55//! type    price   delta   gamma     vega    theta      rho
56//! ----  -------  ------  ------  -------  -------  -------
57//! Call  10.4506  0.6368  0.0188  37.5240  -6.4140  53.2325
58//! ```
59
60use crate::derivatives::norm::{norm_cdf, norm_pdf};
61use crate::derivatives::types::{
62    forward_moneyness, intrinsic, time_value, validate_bsm_params, BsmParams, OptionType,
63    ValidatedBsm,
64};
65use crate::util::error::FinanceResult;
66use crate::{columns_with_strings, print_table_locale_opt};
67
68/// First-order BSM Greeks.
69///
70/// # Units (critical)
71///
72/// | Field | Unit |
73/// |-------|------|
74/// | `delta` | ∂V/∂S (share equivalent per option) |
75/// | `gamma` | ∂²V/∂S² |
76/// | `vega` | ∂V/∂σ per **+1.0** absolute vol (not per 1%) |
77/// | `theta` | ∂V/∂T per **year** |
78/// | `rho` | ∂V/∂r per +1.0 absolute rate |
79///
80/// **Trading:** risk systems often show vega “per 1%” and theta “per day” — use the helpers
81/// below so you do not silently mis-scale P&amp;L.
82#[derive(Clone, Copy, Debug, PartialEq)]
83pub struct BsmGreeks {
84    pub delta: f64,
85    pub gamma: f64,
86    /// Per +1.0 absolute vol (divide by 100 for “per vol point”).
87    pub vega: f64,
88    /// Calendar year basis (same time unit as `time_years`).
89    pub theta: f64,
90    pub rho: f64,
91}
92
93impl BsmGreeks {
94    /// Vega per **one percentage point** of vol (desk convention): `vega / 100`.
95    ///
96    /// Example: if IV rises from 20% to 21%, P&amp;L ≈ `vega_per_vol_point()` per option.
97    #[inline]
98    pub fn vega_per_vol_point(self) -> f64 {
99        self.vega / 100.0
100    }
101
102    /// Theta per **calendar day** using 365.25 days/year: `theta / 365.25`.
103    ///
104    /// Business-day or trading-day conventions differ by desk — override externally if needed.
105    #[inline]
106    pub fn theta_per_calendar_day(self) -> f64 {
107        self.theta / 365.25
108    }
109}
110
111/// Intermediate terms shared by price and Greeks (`d1`, `d2`, discounts).
112///
113/// **Teaching:** show `d1`/`d2` next to N(d1) stories.  
114/// **Engineering:** rarely needed on the hot path if you only consume price/greeks.
115#[derive(Clone, Copy, Debug, PartialEq)]
116pub struct BsmTerms {
117    pub d1: f64,
118    pub d2: f64,
119    pub discount: f64,
120    pub dividend_discount: f64,
121    pub sqrt_t: f64,
122}
123
124/// Teaching solution: price, greeks, parity check, formulas.
125///
126/// Prefer this for notebooks and audit logs. Prefer [`bsm_price`] / [`BsmState`] in production.
127#[derive(Clone, Debug)]
128pub struct BsmSolution {
129    pub option_type: OptionType,
130    pub params: BsmParams,
131    pub price: f64,
132    pub greeks: BsmGreeks,
133    pub terms: BsmTerms,
134    pub intrinsic: f64,
135    pub time_value: f64,
136    pub forward_moneyness: f64,
137    /// `C − P − (S e^{-qT} − K e^{-rT})`; model-consistent prices → ≈ 0.
138    pub parity_residual: f64,
139    formula: String,
140    symbolic_formula: String,
141}
142
143impl BsmSolution {
144    pub fn formula(&self) -> &str {
145        &self.formula
146    }
147    pub fn symbolic_formula(&self) -> &str {
148        &self.symbolic_formula
149    }
150
151    /// Print a one-row summary table of price + Greeks.
152    ///
153    /// ```text
154    /// type    price   delta   gamma     vega    theta      rho
155    /// ----  -------  ------  ------  -------  -------  -------
156    /// Call  10.4506  0.6368  0.0188  37.5240  -6.4140  53.2325
157    /// ```
158    pub fn print_table(&self) {
159        self.print_table_locale_opt(None, None);
160    }
161
162    pub fn print_table_locale(&self, locale: &num_format::Locale, precision: usize) {
163        self.print_table_locale_opt(Some(locale), Some(precision));
164    }
165
166    fn print_table_locale_opt(
167        &self,
168        locale: Option<&num_format::Locale>,
169        precision: Option<usize>,
170    ) {
171        let columns = columns_with_strings(&[
172            ("type", "s", true),
173            ("price", "f", true),
174            ("delta", "f", true),
175            ("gamma", "f", true),
176            ("vega", "f", true),
177            ("theta", "f", true),
178            ("rho", "f", true),
179        ]);
180        let data = vec![vec![
181            self.option_type.to_string(),
182            self.price.to_string(),
183            self.greeks.delta.to_string(),
184            self.greeks.gamma.to_string(),
185            self.greeks.vega.to_string(),
186            self.greeks.theta.to_string(),
187            self.greeks.rho.to_string(),
188        ]];
189        print_table_locale_opt(&columns, data, locale, precision);
190    }
191}
192
193/// European BSM price.
194///
195/// **Trading:** model mark for edge vs mid (after fees).  
196/// **Engineering:** pure function of [`BsmParams`]; validate domain first.
197///
198/// # Errors
199/// Non-finite or non-positive spot/strike; negative time or vol.
200pub fn bsm_price(params: BsmParams, option_type: OptionType) -> FinanceResult<f64> {
201    validate_bsm_params(params)?;
202    Ok(price_unchecked(params, option_type))
203}
204
205/// European BSM Greeks (see [`BsmGreeks`] for units).
206///
207/// **Trading:** feed risk aggregators and hedge ratios.  
208/// **Engineering:** one call returns all first-order Greeks for a snapshot.
209pub fn bsm_greeks(params: BsmParams, option_type: OptionType) -> FinanceResult<BsmGreeks> {
210    validate_bsm_params(params)?;
211    Ok(greeks_unchecked(params, option_type))
212}
213
214/// d1/d2 and discount factors (for teaching / advanced use).
215pub fn bsm_terms(params: BsmParams) -> FinanceResult<BsmTerms> {
216    validate_bsm_params(params)?;
217    Ok(terms_unchecked(params))
218}
219
220/// Put–call parity residual: `C − P − (S e^{−qT} − K e^{−rT})` (≈ 0 for BSM).
221///
222/// **Trading:** large residual on **market** mids may mean bad quotes, early exercise
223/// premium (American), or dividends — not free arb without costs.  
224/// **Engineering:** use on **model** prices as a self-consistency test (should be ~0).
225pub fn put_call_parity_residual(params: BsmParams) -> FinanceResult<f64> {
226    let c = bsm_price(params, OptionType::Call)?;
227    let p = bsm_price(params, OptionType::Put)?;
228    let disc = (-params.rate * params.time_years).exp();
229    let div = (-params.dividend_yield * params.time_years).exp();
230    Ok(c - p - (params.spot * div - params.strike * disc))
231}
232
233/// Full teaching solution (price, greeks, intrinsic, parity, formulas).
234///
235/// # Examples
236/// ```
237/// use finance_solution::derivatives::{bsm_solution, BsmParams, OptionType};
238/// let sol = bsm_solution(BsmParams::atm_one_year(100.0, 0.05, 0.20), OptionType::Call).unwrap();
239/// assert!(sol.price > 0.0);
240/// assert!(sol.parity_residual.abs() < 1e-8);
241/// assert!(sol.greeks.vega_per_vol_point() > 0.0);
242/// // sol.print_table();
243/// ```
244pub fn bsm_solution(params: BsmParams, option_type: OptionType) -> FinanceResult<BsmSolution> {
245    let _ = ValidatedBsm::new(params)?;
246    let price = price_unchecked(params, option_type);
247    let greeks = greeks_unchecked(params, option_type);
248    let terms = terms_unchecked(params);
249    let intrinsic_v = intrinsic(params.spot, params.strike, option_type)?;
250    let tv = time_value(price, params.spot, params.strike, option_type)?;
251    let fm = forward_moneyness(params)?;
252    let parity = put_call_parity_residual(params)?;
253    let formula = format!(
254        "{option_type} BSM S={} K={} T={} r={} q={} σ={} → price={:.6}",
255        params.spot,
256        params.strike,
257        params.time_years,
258        params.rate,
259        params.dividend_yield,
260        params.vol,
261        price
262    );
263    let symbolic = match option_type {
264        OptionType::Call => {
265            "C = S e^{-qT} N(d1) - K e^{-rT} N(d2); d1 = [ln(S/K)+(r-q+σ²/2)T]/(σ√T); d2 = d1-σ√T"
266                .to_string()
267        }
268        OptionType::Put => "P = K e^{-rT} N(-d2) - S e^{-qT} N(-d1); d1,d2 as in call".to_string(),
269    };
270    Ok(BsmSolution {
271        option_type,
272        params,
273        price,
274        greeks,
275        terms,
276        intrinsic: intrinsic_v,
277        time_value: tv,
278        forward_moneyness: fm,
279        parity_residual: parity,
280        formula,
281        symbolic_formula: symbolic,
282    })
283}
284
285fn terms_unchecked(p: BsmParams) -> BsmTerms {
286    let sqrt_t = p.time_years.sqrt();
287    let discount = (-p.rate * p.time_years).exp();
288    let dividend_discount = (-p.dividend_yield * p.time_years).exp();
289
290    if p.time_years == 0.0 || p.vol == 0.0 {
291        let forward = p.spot * ((p.rate - p.dividend_yield) * p.time_years).exp();
292        let d1 = if forward > p.strike {
293            f64::INFINITY
294        } else if forward < p.strike {
295            f64::NEG_INFINITY
296        } else {
297            0.0
298        };
299        return BsmTerms {
300            d1,
301            d2: d1,
302            discount,
303            dividend_discount,
304            sqrt_t,
305        };
306    }
307
308    let sig_s = p.vol * sqrt_t;
309    let d1 = ((p.spot / p.strike).ln()
310        + (p.rate - p.dividend_yield + 0.5 * p.vol * p.vol) * p.time_years)
311        / sig_s;
312    let d2 = d1 - sig_s;
313    BsmTerms {
314        d1,
315        d2,
316        discount,
317        dividend_discount,
318        sqrt_t,
319    }
320}
321
322fn price_unchecked(p: BsmParams, option_type: OptionType) -> f64 {
323    if p.time_years == 0.0 {
324        return match option_type {
325            OptionType::Call => (p.spot - p.strike).max(0.0),
326            OptionType::Put => (p.strike - p.spot).max(0.0),
327        };
328    }
329    if p.vol == 0.0 {
330        let f = p.spot * ((p.rate - p.dividend_yield) * p.time_years).exp();
331        let disc = (-p.rate * p.time_years).exp();
332        return match option_type {
333            OptionType::Call => disc * (f - p.strike).max(0.0),
334            OptionType::Put => disc * (p.strike - f).max(0.0),
335        };
336    }
337
338    let t = terms_unchecked(p);
339    let df_q = t.dividend_discount;
340    let df_r = t.discount;
341    match option_type {
342        OptionType::Call => p.spot * df_q * norm_cdf(t.d1) - p.strike * df_r * norm_cdf(t.d2),
343        OptionType::Put => p.strike * df_r * norm_cdf(-t.d2) - p.spot * df_q * norm_cdf(-t.d1),
344    }
345}
346
347fn greeks_unchecked(p: BsmParams, option_type: OptionType) -> BsmGreeks {
348    if p.time_years == 0.0 {
349        let delta = match option_type {
350            OptionType::Call => {
351                if p.spot > p.strike {
352                    1.0
353                } else if p.spot < p.strike {
354                    0.0
355                } else {
356                    0.5
357                }
358            }
359            OptionType::Put => {
360                if p.spot < p.strike {
361                    -1.0
362                } else if p.spot > p.strike {
363                    0.0
364                } else {
365                    -0.5
366                }
367            }
368        };
369        return BsmGreeks {
370            delta,
371            gamma: 0.0,
372            vega: 0.0,
373            theta: 0.0,
374            rho: 0.0,
375        };
376    }
377
378    if p.vol == 0.0 {
379        let price_up = {
380            let mut q = p;
381            q.spot *= 1.0 + 1e-6;
382            price_unchecked(q, option_type)
383        };
384        let price_0 = price_unchecked(p, option_type);
385        let delta = (price_up - price_0) / (p.spot * 1e-6);
386        return BsmGreeks {
387            delta,
388            gamma: 0.0,
389            vega: 0.0,
390            theta: 0.0,
391            rho: 0.0,
392        };
393    }
394
395    let t = terms_unchecked(p);
396    let df_q = t.dividend_discount;
397    let df_r = t.discount;
398    let n_d1 = norm_pdf(t.d1);
399    let sqrt_t = t.sqrt_t;
400    let gamma = df_q * n_d1 / (p.spot * p.vol * sqrt_t);
401    let vega = p.spot * df_q * n_d1 * sqrt_t;
402
403    let (delta, theta, rho) = match option_type {
404        OptionType::Call => {
405            let delta = df_q * norm_cdf(t.d1);
406            let theta = -p.spot * df_q * n_d1 * p.vol / (2.0 * sqrt_t)
407                - p.rate * p.strike * df_r * norm_cdf(t.d2)
408                + p.dividend_yield * p.spot * df_q * norm_cdf(t.d1);
409            let rho = p.strike * p.time_years * df_r * norm_cdf(t.d2);
410            (delta, theta, rho)
411        }
412        OptionType::Put => {
413            let delta = df_q * (norm_cdf(t.d1) - 1.0);
414            let theta = -p.spot * df_q * n_d1 * p.vol / (2.0 * sqrt_t)
415                + p.rate * p.strike * df_r * norm_cdf(-t.d2)
416                - p.dividend_yield * p.spot * df_q * norm_cdf(-t.d1);
417            let rho = -p.strike * p.time_years * df_r * norm_cdf(-t.d2);
418            (delta, theta, rho)
419        }
420    };
421
422    BsmGreeks {
423        delta,
424        gamma,
425        vega,
426        theta,
427        rho,
428    }
429}
430
431pub(crate) fn price_raw(params: BsmParams, option_type: OptionType) -> FinanceResult<f64> {
432    bsm_price(params, option_type)
433}
434
435pub(crate) fn vega_raw(params: BsmParams, option_type: OptionType) -> FinanceResult<f64> {
436    Ok(bsm_greeks(params, option_type)?.vega)
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    #[test]
444    fn atm_call_textbook() {
445        let p = BsmParams::atm_one_year(100.0, 0.05, 0.20);
446        let c = bsm_price(p, OptionType::Call).unwrap();
447        assert!((c - 10.450_583_57).abs() < 1e-4);
448    }
449
450    #[test]
451    fn put_call_parity() {
452        let p = BsmParams {
453            spot: 100.0,
454            strike: 95.0,
455            time_years: 0.5,
456            rate: 0.03,
457            dividend_yield: 0.01,
458            vol: 0.25,
459        };
460        assert!(put_call_parity_residual(p).unwrap().abs() < 1e-10);
461    }
462
463    #[test]
464    fn expiry_intrinsic() {
465        let p = BsmParams {
466            spot: 110.0,
467            strike: 100.0,
468            time_years: 0.0,
469            rate: 0.05,
470            dividend_yield: 0.0,
471            vol: 0.2,
472        };
473        assert!((bsm_price(p, OptionType::Call).unwrap() - 10.0).abs() < 1e-12);
474        assert!((bsm_price(p, OptionType::Put).unwrap()).abs() < 1e-12);
475    }
476
477    #[test]
478    fn delta_bounds_call() {
479        let p = BsmParams::atm_one_year(100.0, 0.05, 0.2);
480        let d = bsm_greeks(p, OptionType::Call).unwrap().delta;
481        assert!(d > 0.0 && d < 1.0);
482    }
483
484    #[test]
485    fn vega_scale_helpers() {
486        let g = bsm_greeks(BsmParams::atm_one_year(100.0, 0.05, 0.2), OptionType::Call).unwrap();
487        assert!((g.vega_per_vol_point() * 100.0 - g.vega).abs() < 1e-12);
488        assert!((g.theta_per_calendar_day() * 365.25 - g.theta).abs() < 1e-12);
489    }
490
491    #[test]
492    fn rejects_bad_spot() {
493        let mut p = BsmParams::atm_one_year(100.0, 0.05, 0.2);
494        p.spot = 0.0;
495        assert!(bsm_price(p, OptionType::Call).is_err());
496    }
497}