Skip to main content

rustyqlib/equity/
black76.rs

1//! Black-76 (1976): European options on a future/forward price `F`.
2//!
3//! Two settlement styles:
4//! - **Discounted** (standard Black-76): the premium is paid up front and
5//!   the payoff is discounted, `call = e^{-rT}[F N(d1) - K N(d2)]`.
6//! - **Margined** (futures-style / "future-style"): the option premium is
7//!   itself margined daily like the future, so there is no discounting,
8//!   `call = F N(d1) - K N(d2)`. Common for options on futures on many
9//!   non-US derivatives exchanges (e.g. Eurex, ICE, ASX).
10//!
11//! `F` is the futures price directly — Black-76 has no spot, dividend or
12//! carry, since a future already embeds the cost of carry. All Greeks are
13//! sensitivities with respect to `F` (delta/gamma), `sigma`, `r` and time.
14
15use std::str::FromStr;
16
17use serde::{Deserialize, Serialize};
18
19use crate::core::trade::PutOrCall;
20use crate::core::utils::{dN, N};
21
22/// How an option on a future is settled.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum FuturesSettlement {
26    /// Premium paid up front; the payoff is discounted at the risk-free rate.
27    Discounted,
28    /// Futures-style: the premium is margined, so the payoff is undiscounted.
29    Margined,
30}
31
32impl FromStr for FuturesSettlement {
33    type Err = String;
34    fn from_str(s: &str) -> Result<Self, Self::Err> {
35        match s.trim().to_lowercase().as_str() {
36            "discounted" | "black76" | "premium" | "premium_settled" => {
37                Ok(FuturesSettlement::Discounted)
38            }
39            "margined" | "futures_style" | "future_style" | "futures-style" => {
40                Ok(FuturesSettlement::Margined)
41            }
42            other => Err(format!(
43                "Invalid futures settlement '{other}' (use 'discounted' or 'margined')"
44            )),
45        }
46    }
47}
48
49impl FuturesSettlement {
50    /// Discount factor applied to the payoff: `e^{-rT}` when the premium is
51    /// paid up front, `1` when it is margined.
52    pub fn discount_factor(&self, r: f64, t: f64) -> f64 {
53        match self {
54            FuturesSettlement::Discounted => (-r * t).exp(),
55            FuturesSettlement::Margined => 1.0,
56        }
57    }
58}
59
60fn d1_d2(f: f64, k: f64, sigma: f64, t: f64) -> (f64, f64) {
61    let st = sigma * t.sqrt();
62    let d1 = ((f / k).ln() + 0.5 * sigma * sigma * t) / st;
63    (d1, d1 - st)
64}
65
66/// Black-76 price of a European option on a future.
67pub fn price(
68    f: f64,
69    k: f64,
70    r: f64,
71    sigma: f64,
72    t: f64,
73    put_or_call: PutOrCall,
74    settlement: FuturesSettlement,
75) -> f64 {
76    assert!(f > 0.0 && k > 0.0, "futures price and strike must be positive");
77    let df = settlement.discount_factor(r, t);
78    if t <= 0.0 || sigma <= 0.0 {
79        let intrinsic = match put_or_call {
80            PutOrCall::Call => (f - k).max(0.0),
81            PutOrCall::Put => (k - f).max(0.0),
82        };
83        return df * intrinsic;
84    }
85    let (d1, d2) = d1_d2(f, k, sigma, t);
86    match put_or_call {
87        PutOrCall::Call => df * (f * N(d1) - k * N(d2)),
88        PutOrCall::Put => df * (k * N(-d2) - f * N(-d1)),
89    }
90}
91
92/// Delta with respect to the futures price `F`.
93pub fn delta(
94    f: f64,
95    k: f64,
96    r: f64,
97    sigma: f64,
98    t: f64,
99    put_or_call: PutOrCall,
100    settlement: FuturesSettlement,
101) -> f64 {
102    let df = settlement.discount_factor(r, t);
103    let (d1, _) = d1_d2(f, k, sigma, t);
104    match put_or_call {
105        PutOrCall::Call => df * N(d1),
106        PutOrCall::Put => -df * N(-d1),
107    }
108}
109
110/// Gamma with respect to the futures price `F` (same for calls and puts).
111pub fn gamma(
112    f: f64,
113    k: f64,
114    r: f64,
115    sigma: f64,
116    t: f64,
117    settlement: FuturesSettlement,
118) -> f64 {
119    let df = settlement.discount_factor(r, t);
120    let (d1, _) = d1_d2(f, k, sigma, t);
121    df * dN(d1) / (f * sigma * t.sqrt())
122}
123
124/// Vega (per unit of vol; same for calls and puts).
125pub fn vega(
126    f: f64,
127    k: f64,
128    r: f64,
129    sigma: f64,
130    t: f64,
131    settlement: FuturesSettlement,
132) -> f64 {
133    let df = settlement.discount_factor(r, t);
134    let (d1, _) = d1_d2(f, k, sigma, t);
135    df * f * dN(d1) * t.sqrt()
136}
137
138/// Rho (sensitivity to the risk-free rate). Zero for margined options,
139/// which have no discounting; `-T * price` for discounted options (the
140/// futures price is exogenous, so `r` enters only through the discount).
141pub fn rho(
142    f: f64,
143    k: f64,
144    r: f64,
145    sigma: f64,
146    t: f64,
147    put_or_call: PutOrCall,
148    settlement: FuturesSettlement,
149) -> f64 {
150    match settlement {
151        FuturesSettlement::Margined => 0.0,
152        FuturesSettlement::Discounted => -t * price(f, k, r, sigma, t, put_or_call, settlement),
153    }
154}
155
156/// Theta (calendar time decay, `dV/dt = -dV/dT`).
157pub fn theta(
158    f: f64,
159    k: f64,
160    r: f64,
161    sigma: f64,
162    t: f64,
163    put_or_call: PutOrCall,
164    settlement: FuturesSettlement,
165) -> f64 {
166    let df = settlement.discount_factor(r, t);
167    let (d1, _) = d1_d2(f, k, sigma, t);
168    // volatility bleed term F df dN(d1) sigma / (2 sqrt(T)), common to both
169    // settlement styles and to calls and puts
170    let bleed = df * f * dN(d1) * sigma / (2.0 * t.sqrt());
171    match settlement {
172        FuturesSettlement::Margined => -bleed,
173        FuturesSettlement::Discounted => {
174            r * price(f, k, r, sigma, t, put_or_call, settlement) - bleed
175        }
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::equity::blackscholes::bs_price;
183
184    const F: f64 = 100.0;
185    const K: f64 = 100.0;
186    const R: f64 = 0.05;
187    const SIG: f64 = 0.30;
188    const T: f64 = 1.0;
189
190    // Golden values, bump-verified against an independent reference
191    #[test]
192    fn discounted_golden_values() {
193        use FuturesSettlement::Discounted as D;
194        assert!((price(F, K, R, SIG, T, PutOrCall::Call, D) - 11.34202064).abs() < 1e-7);
195        assert!((delta(F, K, R, SIG, T, PutOrCall::Call, D) - 0.53232482).abs() < 1e-7);
196        assert!((delta(F, K, R, SIG, T, PutOrCall::Put, D) + 0.41890461).abs() < 1e-7);
197        assert!((gamma(F, K, R, SIG, T, D) - 0.01250801).abs() < 1e-7);
198        assert!((vega(F, K, R, SIG, T, D) - 37.52403469).abs() < 1e-6);
199        assert!((rho(F, K, R, SIG, T, PutOrCall::Call, D) + 11.34202064).abs() < 1e-6);
200        assert!((theta(F, K, R, SIG, T, PutOrCall::Call, D) + 5.06150417).abs() < 1e-6);
201    }
202
203    #[test]
204    fn margined_golden_values() {
205        use FuturesSettlement::Margined as M;
206        assert!((price(F, K, R, SIG, T, PutOrCall::Call, M) - 11.92353847).abs() < 1e-7);
207        assert!((delta(F, K, R, SIG, T, PutOrCall::Call, M) - 0.55961769).abs() < 1e-7);
208        assert!((gamma(F, K, R, SIG, T, M) - 0.01314931).abs() < 1e-7);
209        assert!((vega(F, K, R, SIG, T, M) - 39.44793309).abs() < 1e-6);
210        assert!((theta(F, K, R, SIG, T, PutOrCall::Call, M) + 5.91718996).abs() < 1e-6);
211    }
212
213    #[test]
214    fn margined_rho_is_zero() {
215        // no discounting -> no rate sensitivity at all
216        for pc in [PutOrCall::Call, PutOrCall::Put] {
217            assert_eq!(rho(F, K, R, SIG, T, pc, FuturesSettlement::Margined), 0.0);
218        }
219    }
220
221    #[test]
222    fn margined_exceeds_discounted() {
223        // the undiscounted premium is worth more than the discounted one
224        let disc = price(F, K, R, SIG, T, PutOrCall::Call, FuturesSettlement::Discounted);
225        let marg = price(F, K, R, SIG, T, PutOrCall::Call, FuturesSettlement::Margined);
226        assert!(marg > disc);
227        // margined = discounted / e^{-rT}
228        assert!((marg - disc * (R * T).exp()).abs() < 1e-10);
229    }
230
231    #[test]
232    fn put_call_parity_both_styles() {
233        for (s, factor) in [
234            (FuturesSettlement::Discounted, (-R * T).exp()),
235            (FuturesSettlement::Margined, 1.0),
236        ] {
237            let c = price(F, 95.0, R, SIG, T, PutOrCall::Call, s);
238            let p = price(F, 95.0, R, SIG, T, PutOrCall::Put, s);
239            assert!((c - p - factor * (F - 95.0)).abs() < 1e-10, "{s:?}");
240        }
241    }
242
243    #[test]
244    fn discounted_black76_equals_black_scholes_at_the_forward() {
245        // Black-76 on F = S e^{(r-q)T} must reproduce Black-Scholes-Merton
246        let (s, q) = (100.0, 0.02);
247        let fwd = s * ((R - q) * T).exp();
248        let b76 = price(fwd, K, R, SIG, T, PutOrCall::Call, FuturesSettlement::Discounted);
249        let bsm = bs_price(s, K, R, q, SIG, T, PutOrCall::Call);
250        assert!((b76 - bsm).abs() < 1e-10, "b76 {b76} vs bsm {bsm}");
251    }
252
253    #[test]
254    fn settlement_parses_from_strings() {
255        assert_eq!("discounted".parse::<FuturesSettlement>().unwrap(), FuturesSettlement::Discounted);
256        assert_eq!("black76".parse::<FuturesSettlement>().unwrap(), FuturesSettlement::Discounted);
257        assert_eq!("margined".parse::<FuturesSettlement>().unwrap(), FuturesSettlement::Margined);
258        assert_eq!("futures_style".parse::<FuturesSettlement>().unwrap(), FuturesSettlement::Margined);
259        assert!("bad".parse::<FuturesSettlement>().is_err());
260    }
261}