Skip to main content

finance_solution/derivatives/
black76.rs

1//! # Black ’76 — European options on a forward / futures
2//!
3//! Prices an option when the natural underlier is a **forward** \(F\) (futures mark,
4//! index future, etc.), discounted at continuous rate \(r\).
5//!
6//! ---
7//!
8//! ## Trading perspective
9//!
10//! | Use | Why Black ’76 not spot BSM |
11//! |-----|----------------------------|
12//! | Options on **futures** | Underlier is \(F\), not cash equity \(S\) |
13//! | Many **index** listed products | Quoted vs futures strip |
14//! | Rates / commodity vanillas | Forward-first modeling |
15//!
16//! **Parity (discounted):** \(C - P = e^{-rT}(F - K)\).
17//!
18//! ---
19//!
20//! ## Engineering perspective
21//!
22//! Same layering as BSM: [`Black76Params`] → [`ValidatedBlack76`] / free functions →
23//! [`Black76State`] → [`black76_solution`].
24//!
25//! **Equivalence:** Black ’76 with \((F,K,T,r,\sigma)\) matches BSM with
26//! \(S=F\), \(q=r\) (so the equity forward is \(F\)). Prefer this module when your
27//! inputs are already forwards — clearer field names and forward Δ.
28//!
29//! ## Units
30//!
31//! | Field | Unit |
32//! |-------|------|
33//! | `forward`, `strike` | same money units |
34//! | `time_years` | years |
35//! | `rate` | continuous discount rate |
36//! | `vol` | annualized absolute |
37//! | `delta` | ∂V/∂F (forward delta) |
38//! | `vega` | per +1.0 absolute vol |
39//! | `theta` | per year |
40//!
41//! ## Word problem
42//!
43//! > Futures 100, strike 100, T=1y, r=5%, σ=20%. Black ’76 call?
44//!
45//! Expect: about **7.58** (less than equity BSM ATM with \(q=0\) ≈ 10.45).
46//!
47//! ```
48//! use finance_solution::derivatives::{black76_price, Black76Params, OptionType};
49//! let p = Black76Params::atm_one_year(100.0, 0.05, 0.20);
50//! let c = black76_price(p, OptionType::Call).unwrap();
51//! assert!((c - 7.577_082).abs() < 1e-3);
52//! ```
53
54use crate::derivatives::norm::{norm_cdf, norm_pdf};
55use crate::derivatives::types::OptionType;
56use crate::util::error::{require_finite, FinanceError, FinanceResult};
57use crate::{columns_with_strings, print_table_locale_opt};
58
59/// Black ’76 inputs (European option on a forward).
60#[derive(Clone, Copy, Debug, PartialEq)]
61pub struct Black76Params {
62    /// Forward / futures price \(F\).
63    pub forward: f64,
64    pub strike: f64,
65    pub time_years: f64,
66    /// Continuous discount rate (funding of the option premium).
67    pub rate: f64,
68    pub vol: f64,
69}
70
71impl Black76Params {
72    pub const fn atm_one_year(forward: f64, rate: f64, vol: f64) -> Self {
73        Self {
74            forward,
75            strike: forward,
76            time_years: 1.0,
77            rate,
78            vol,
79        }
80    }
81
82    pub fn with_days_365_25(forward: f64, strike: f64, days: f64, rate: f64, vol: f64) -> Self {
83        Self {
84            forward,
85            strike,
86            time_years: days / 365.25,
87            rate,
88            vol,
89        }
90    }
91
92    /// Map to BSM with \(S=F\), \(q=r\) (same prices and most Greeks).
93    pub fn to_bsm_equiv(self) -> crate::derivatives::types::BsmParams {
94        crate::derivatives::types::BsmParams {
95            spot: self.forward,
96            strike: self.strike,
97            time_years: self.time_years,
98            rate: self.rate,
99            dividend_yield: self.rate,
100            vol: self.vol,
101        }
102    }
103}
104
105/// Validated Black ’76 snapshot.
106#[derive(Clone, Copy, Debug, PartialEq)]
107pub struct ValidatedBlack76 {
108    params: Black76Params,
109}
110
111impl ValidatedBlack76 {
112    pub fn new(params: Black76Params) -> FinanceResult<Self> {
113        validate_black76_params(params)?;
114        Ok(Self { params })
115    }
116
117    pub fn params(self) -> Black76Params {
118        self.params
119    }
120
121    pub fn price(self, option_type: OptionType) -> FinanceResult<f64> {
122        black76_price(self.params, option_type)
123    }
124
125    pub fn greeks(self, option_type: OptionType) -> FinanceResult<Black76Greeks> {
126        black76_greeks(self.params, option_type)
127    }
128}
129
130/// First-order Black ’76 Greeks (Δ is **forward** delta).
131#[derive(Clone, Copy, Debug, PartialEq)]
132pub struct Black76Greeks {
133    /// ∂V/∂F
134    pub delta: f64,
135    pub gamma: f64,
136    pub vega: f64,
137    pub theta: f64,
138    /// ∂V/∂r with **F held fixed** (pure discount rho) ≈ −T × price.
139    pub rho: f64,
140}
141
142impl Black76Greeks {
143    #[inline]
144    pub fn vega_per_vol_point(self) -> f64 {
145        self.vega / 100.0
146    }
147
148    #[inline]
149    pub fn theta_per_calendar_day(self) -> f64 {
150        self.theta / 365.25
151    }
152}
153
154/// d1/d2 and discount for Black ’76.
155#[derive(Clone, Copy, Debug, PartialEq)]
156pub struct Black76Terms {
157    pub d1: f64,
158    pub d2: f64,
159    pub discount: f64,
160    pub sqrt_t: f64,
161}
162
163/// Teaching solution for Black ’76.
164#[derive(Clone, Debug)]
165pub struct Black76Solution {
166    pub option_type: OptionType,
167    pub params: Black76Params,
168    pub price: f64,
169    pub greeks: Black76Greeks,
170    pub terms: Black76Terms,
171    /// `C − P − e^{-rT}(F − K)`; model → ≈ 0.
172    pub parity_residual: f64,
173    formula: String,
174    symbolic_formula: String,
175}
176
177impl Black76Solution {
178    pub fn formula(&self) -> &str {
179        &self.formula
180    }
181    pub fn symbolic_formula(&self) -> &str {
182        &self.symbolic_formula
183    }
184
185    pub fn print_table(&self) {
186        self.print_table_locale_opt(None, None);
187    }
188
189    pub fn print_table_locale(&self, locale: &num_format::Locale, precision: usize) {
190        self.print_table_locale_opt(Some(locale), Some(precision));
191    }
192
193    fn print_table_locale_opt(
194        &self,
195        locale: Option<&num_format::Locale>,
196        precision: Option<usize>,
197    ) {
198        let columns = columns_with_strings(&[
199            ("type", "s", true),
200            ("price", "f", true),
201            ("delta", "f", true),
202            ("gamma", "f", true),
203            ("vega", "f", true),
204            ("theta", "f", true),
205            ("rho", "f", true),
206        ]);
207        let data = vec![vec![
208            self.option_type.to_string(),
209            self.price.to_string(),
210            self.greeks.delta.to_string(),
211            self.greeks.gamma.to_string(),
212            self.greeks.vega.to_string(),
213            self.greeks.theta.to_string(),
214            self.greeks.rho.to_string(),
215        ]];
216        print_table_locale_opt(&columns, data, locale, precision);
217    }
218}
219
220/// Live Black ’76 contract state (forward / vol / time updates).
221#[derive(Clone, Debug, PartialEq)]
222pub struct Black76State {
223    params: Black76Params,
224    option_type: OptionType,
225}
226
227impl Black76State {
228    pub fn new(params: Black76Params, option_type: OptionType) -> FinanceResult<Self> {
229        validate_black76_params(params)?;
230        Ok(Self {
231            params,
232            option_type,
233        })
234    }
235
236    pub fn params(&self) -> Black76Params {
237        self.params
238    }
239
240    pub fn option_type(&self) -> OptionType {
241        self.option_type
242    }
243
244    pub fn set_forward(&mut self, forward: f64) -> FinanceResult<()> {
245        require_finite("forward", forward)?;
246        let mut p = self.params;
247        p.forward = forward;
248        validate_black76_params(p)?;
249        self.params = p;
250        Ok(())
251    }
252
253    pub fn set_vol(&mut self, vol: f64) -> FinanceResult<()> {
254        require_finite("vol", vol)?;
255        let mut p = self.params;
256        p.vol = vol;
257        validate_black76_params(p)?;
258        self.params = p;
259        Ok(())
260    }
261
262    pub fn set_time_years(&mut self, time_years: f64) -> FinanceResult<()> {
263        require_finite("time_years", time_years)?;
264        let mut p = self.params;
265        p.time_years = time_years;
266        validate_black76_params(p)?;
267        self.params = p;
268        Ok(())
269    }
270
271    pub fn set_vol_from_price(&mut self, market_price: f64) -> FinanceResult<f64> {
272        let iv = black76_implied_vol(self.params, self.option_type, market_price)?;
273        self.set_vol(iv)?;
274        Ok(iv)
275    }
276
277    pub fn price(&self) -> FinanceResult<f64> {
278        black76_price(self.params, self.option_type)
279    }
280
281    pub fn greeks(&self) -> FinanceResult<Black76Greeks> {
282        black76_greeks(self.params, self.option_type)
283    }
284}
285
286pub fn black76_price(params: Black76Params, option_type: OptionType) -> FinanceResult<f64> {
287    validate_black76_params(params)?;
288    Ok(price_unchecked(params, option_type))
289}
290
291pub fn black76_greeks(
292    params: Black76Params,
293    option_type: OptionType,
294) -> FinanceResult<Black76Greeks> {
295    validate_black76_params(params)?;
296    Ok(greeks_unchecked(params, option_type))
297}
298
299pub fn black76_terms(params: Black76Params) -> FinanceResult<Black76Terms> {
300    validate_black76_params(params)?;
301    Ok(terms_unchecked(params))
302}
303
304/// `C − P − e^{-rT}(F − K)`.
305pub fn black76_parity_residual(params: Black76Params) -> FinanceResult<f64> {
306    let c = black76_price(params, OptionType::Call)?;
307    let p = black76_price(params, OptionType::Put)?;
308    let disc = (-params.rate * params.time_years).exp();
309    Ok(c - p - disc * (params.forward - params.strike))
310}
311
312pub fn black76_solution(
313    params: Black76Params,
314    option_type: OptionType,
315) -> FinanceResult<Black76Solution> {
316    let _ = ValidatedBlack76::new(params)?;
317    let price = price_unchecked(params, option_type);
318    let greeks = greeks_unchecked(params, option_type);
319    let terms = terms_unchecked(params);
320    let parity = black76_parity_residual(params)?;
321    let formula = format!(
322        "{option_type} Black76 F={} K={} T={} r={} σ={} → price={:.6}",
323        params.forward, params.strike, params.time_years, params.rate, params.vol, price
324    );
325    let symbolic = match option_type {
326        OptionType::Call => {
327            "C = e^{-rT}[F N(d1) - K N(d2)]; d1=[ln(F/K)+σ²T/2]/(σ√T); d2=d1-σ√T".to_string()
328        }
329        OptionType::Put => "P = e^{-rT}[K N(-d2) - F N(-d1)]; d1,d2 as in call".to_string(),
330    };
331    Ok(Black76Solution {
332        option_type,
333        params,
334        price,
335        greeks,
336        terms,
337        parity_residual: parity,
338        formula,
339        symbolic_formula: symbolic,
340    })
341}
342
343/// Implied vol for Black ’76 given a market premium.
344pub fn black76_implied_vol(
345    params: Black76Params,
346    option_type: OptionType,
347    market_price: f64,
348) -> FinanceResult<f64> {
349    validate_black76_params(params)?;
350    require_finite("market_price", market_price)?;
351    if market_price < 0.0 {
352        return Err(FinanceError::Unsolvable {
353            message: "market_price must be non-negative",
354        });
355    }
356    if params.time_years == 0.0 {
357        return Err(FinanceError::Unsolvable {
358            message: "implied vol undefined at expiry (T=0)",
359        });
360    }
361
362    crate::derivatives::implied_vol::solve_implied_vol(
363        market_price,
364        |sigma| {
365            let mut p = params;
366            p.vol = sigma;
367            price_unchecked(p, option_type)
368        },
369        |sigma| {
370            let mut p = params;
371            p.vol = sigma;
372            greeks_unchecked(p, option_type).vega
373        },
374    )
375}
376
377pub(crate) fn validate_black76_params(p: Black76Params) -> FinanceResult<()> {
378    require_finite("forward", p.forward)?;
379    require_finite("strike", p.strike)?;
380    require_finite("time_years", p.time_years)?;
381    require_finite("rate", p.rate)?;
382    require_finite("vol", p.vol)?;
383    if p.forward <= 0.0 {
384        return Err(FinanceError::InvalidCashflow {
385            message: "forward must be strictly positive",
386        });
387    }
388    if p.strike <= 0.0 {
389        return Err(FinanceError::InvalidCashflow {
390            message: "strike must be strictly positive",
391        });
392    }
393    if p.time_years < 0.0 {
394        return Err(FinanceError::Unsolvable {
395            message: "time_years must be non-negative",
396        });
397    }
398    if p.vol < 0.0 {
399        return Err(FinanceError::Unsolvable {
400            message: "vol must be non-negative",
401        });
402    }
403    Ok(())
404}
405
406fn terms_unchecked(p: Black76Params) -> Black76Terms {
407    let sqrt_t = p.time_years.sqrt();
408    let discount = (-p.rate * p.time_years).exp();
409    if p.time_years == 0.0 || p.vol == 0.0 {
410        let d1 = if p.forward > p.strike {
411            f64::INFINITY
412        } else if p.forward < p.strike {
413            f64::NEG_INFINITY
414        } else {
415            0.0
416        };
417        return Black76Terms {
418            d1,
419            d2: d1,
420            discount,
421            sqrt_t,
422        };
423    }
424    let sig_s = p.vol * sqrt_t;
425    let d1 = ((p.forward / p.strike).ln() + 0.5 * p.vol * p.vol * p.time_years) / sig_s;
426    let d2 = d1 - sig_s;
427    Black76Terms {
428        d1,
429        d2,
430        discount,
431        sqrt_t,
432    }
433}
434
435fn price_unchecked(p: Black76Params, option_type: OptionType) -> f64 {
436    if p.time_years == 0.0 {
437        return match option_type {
438            OptionType::Call => (p.forward - p.strike).max(0.0),
439            OptionType::Put => (p.strike - p.forward).max(0.0),
440        };
441    }
442    if p.vol == 0.0 {
443        let disc = (-p.rate * p.time_years).exp();
444        return match option_type {
445            OptionType::Call => disc * (p.forward - p.strike).max(0.0),
446            OptionType::Put => disc * (p.strike - p.forward).max(0.0),
447        };
448    }
449    let t = terms_unchecked(p);
450    let df = t.discount;
451    match option_type {
452        OptionType::Call => df * (p.forward * norm_cdf(t.d1) - p.strike * norm_cdf(t.d2)),
453        OptionType::Put => df * (p.strike * norm_cdf(-t.d2) - p.forward * norm_cdf(-t.d1)),
454    }
455}
456
457fn greeks_unchecked(p: Black76Params, option_type: OptionType) -> Black76Greeks {
458    if p.time_years == 0.0 {
459        let delta = match option_type {
460            OptionType::Call => {
461                if p.forward > p.strike {
462                    1.0
463                } else if p.forward < p.strike {
464                    0.0
465                } else {
466                    0.5
467                }
468            }
469            OptionType::Put => {
470                if p.forward < p.strike {
471                    -1.0
472                } else if p.forward > p.strike {
473                    0.0
474                } else {
475                    -0.5
476                }
477            }
478        };
479        return Black76Greeks {
480            delta,
481            gamma: 0.0,
482            vega: 0.0,
483            theta: 0.0,
484            rho: 0.0,
485        };
486    }
487
488    if p.vol == 0.0 {
489        let price = price_unchecked(p, option_type);
490        let disc = (-p.rate * p.time_years).exp();
491        let delta = match option_type {
492            OptionType::Call => {
493                if p.forward > p.strike {
494                    disc
495                } else {
496                    0.0
497                }
498            }
499            OptionType::Put => {
500                if p.forward < p.strike {
501                    -disc
502                } else {
503                    0.0
504                }
505            }
506        };
507        return Black76Greeks {
508            delta,
509            gamma: 0.0,
510            vega: 0.0,
511            theta: 0.0,
512            rho: -p.time_years * price,
513        };
514    }
515
516    let t = terms_unchecked(p);
517    let df = t.discount;
518    let n_d1 = norm_pdf(t.d1);
519    let sqrt_t = t.sqrt_t;
520    let gamma = df * n_d1 / (p.forward * p.vol * sqrt_t);
521    let vega = df * p.forward * n_d1 * sqrt_t;
522    let price = price_unchecked(p, option_type);
523    let rho = -p.time_years * price;
524
525    // Diffusion term shared; discount bleed −r·V with F held fixed.
526    let theta = -df * p.forward * n_d1 * p.vol / (2.0 * sqrt_t) - p.rate * price;
527    let delta = match option_type {
528        OptionType::Call => df * norm_cdf(t.d1),
529        OptionType::Put => -df * norm_cdf(-t.d1),
530    };
531
532    Black76Greeks {
533        delta,
534        gamma,
535        vega,
536        theta,
537        rho,
538    }
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544    use crate::derivatives::black_scholes::bsm_price;
545    use crate::derivatives::types::BsmParams;
546
547    #[test]
548    fn matches_bsm_with_q_eq_r() {
549        let b = Black76Params::atm_one_year(100.0, 0.05, 0.20);
550        let bsm = BsmParams {
551            spot: 100.0,
552            strike: 100.0,
553            time_years: 1.0,
554            rate: 0.05,
555            dividend_yield: 0.05,
556            vol: 0.20,
557        };
558        let c76 = black76_price(b, OptionType::Call).unwrap();
559        let c_bsm = bsm_price(bsm, OptionType::Call).unwrap();
560        assert!((c76 - c_bsm).abs() < 1e-10);
561        assert!((c76 - 7.577_082).abs() < 1e-3);
562    }
563
564    #[test]
565    fn parity() {
566        let p = Black76Params {
567            forward: 105.0,
568            strike: 100.0,
569            time_years: 0.5,
570            rate: 0.03,
571            vol: 0.22,
572        };
573        assert!(black76_parity_residual(p).unwrap().abs() < 1e-10);
574    }
575
576    #[test]
577    fn iv_round_trip() {
578        let p = Black76Params::atm_one_year(100.0, 0.04, 0.28);
579        let mkt = black76_price(p, OptionType::Call).unwrap();
580        let iv = black76_implied_vol(p, OptionType::Call, mkt).unwrap();
581        assert!((iv - 0.28).abs() < 1e-6);
582    }
583
584    #[test]
585    fn state_forward_moves_delta() {
586        let p = Black76Params::atm_one_year(100.0, 0.05, 0.2);
587        let mut s = Black76State::new(p, OptionType::Call).unwrap();
588        let d0 = s.greeks().unwrap().delta;
589        s.set_forward(110.0).unwrap();
590        assert!(s.greeks().unwrap().delta > d0);
591    }
592
593    #[test]
594    fn expiry_intrinsic_undiscounted() {
595        let p = Black76Params {
596            forward: 120.0,
597            strike: 100.0,
598            time_years: 0.0,
599            rate: 0.05,
600            vol: 0.2,
601        };
602        assert!((black76_price(p, OptionType::Call).unwrap() - 20.0).abs() < 1e-12);
603        assert!(black76_price(p, OptionType::Put).unwrap().abs() < 1e-12);
604    }
605
606    #[test]
607    fn put_delta_negative() {
608        let g = black76_greeks(
609            Black76Params::atm_one_year(100.0, 0.05, 0.2),
610            OptionType::Put,
611        )
612        .unwrap();
613        assert!(g.delta < 0.0 && g.delta > -1.0);
614    }
615
616    #[test]
617    fn rejects_nonpositive_forward() {
618        let mut p = Black76Params::atm_one_year(100.0, 0.05, 0.2);
619        p.forward = 0.0;
620        assert!(black76_price(p, OptionType::Call).is_err());
621    }
622
623    #[test]
624    fn rho_equals_minus_t_times_price() {
625        let p = Black76Params::atm_one_year(100.0, 0.05, 0.2);
626        let px = black76_price(p, OptionType::Call).unwrap();
627        let g = black76_greeks(p, OptionType::Call).unwrap();
628        assert!((g.rho + p.time_years * px).abs() < 1e-10);
629    }
630}