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::{norm_pdf, norm_cdf};
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 * norm_cdf(d1) - k * norm_cdf(d2)),
88        PutOrCall::Put => df * (k * norm_cdf(-d2) - f * norm_cdf(-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 * norm_cdf(d1),
106        PutOrCall::Put => -df * norm_cdf(-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 * norm_pdf(d1) / (f * sigma * t.sqrt())
122}
123
124/// Delta elasticity (also called percentage gamma), `F * gamma / delta`.
125/// It is undefined when delta is zero and returns `NaN` in that case.
126pub fn gamma_p(
127    f: f64,
128    k: f64,
129    r: f64,
130    sigma: f64,
131    t: f64,
132    put_or_call: PutOrCall,
133    settlement: FuturesSettlement,
134) -> f64 {
135    let d = delta(f, k, r, sigma, t, put_or_call, settlement);
136    if d == 0.0 {
137        f64::NAN
138    } else {
139        f * gamma(f, k, r, sigma, t, settlement) / d
140    }
141}
142
143/// Zomma, the change in futures gamma per unit change in volatility.
144pub fn zomma(
145    f: f64,
146    k: f64,
147    r: f64,
148    sigma: f64,
149    t: f64,
150    settlement: FuturesSettlement,
151) -> f64 {
152    let (d1, d2) = d1_d2(f, k, sigma, t);
153    gamma(f, k, r, sigma, t, settlement) * (d1 * d2 - 1.0) / sigma
154}
155
156/// Vega (per unit of vol; same for calls and puts).
157pub fn vega(
158    f: f64,
159    k: f64,
160    r: f64,
161    sigma: f64,
162    t: f64,
163    settlement: FuturesSettlement,
164) -> f64 {
165    let df = settlement.discount_factor(r, t);
166    let (d1, _) = d1_d2(f, k, sigma, t);
167    df * f * norm_pdf(d1) * t.sqrt()
168}
169
170/// Volga (also called vomma), the change in vega per unit change in
171/// volatility, `d(vega)/d(sigma) = vega * d1 * d2 / sigma`. Same for calls
172/// and puts, since put-call parity is volatility-independent. It is negative
173/// near the money (vega is concave in vol there) and positive in the wings.
174pub fn volga(
175    f: f64,
176    k: f64,
177    r: f64,
178    sigma: f64,
179    t: f64,
180    settlement: FuturesSettlement,
181) -> f64 {
182    let (d1, d2) = d1_d2(f, k, sigma, t);
183    vega(f, k, r, sigma, t, settlement) * d1 * d2 / sigma
184}
185
186/// Vanna, the change in futures delta per unit change in volatility.
187pub fn vanna(
188    f: f64,
189    k: f64,
190    r: f64,
191    sigma: f64,
192    t: f64,
193    settlement: FuturesSettlement,
194) -> f64 {
195    let df = settlement.discount_factor(r, t);
196    let (d1, _) = d1_d2(f, k, sigma, t);
197    df * norm_pdf(d1) * (t.sqrt() - d1 / sigma)
198}
199
200/// Charm, the change in futures delta per year of calendar time.
201pub fn charm(
202    f: f64,
203    k: f64,
204    r: f64,
205    sigma: f64,
206    t: f64,
207    put_or_call: PutOrCall,
208    settlement: FuturesSettlement,
209) -> f64 {
210    let df = settlement.discount_factor(r, t);
211    let (d1, _) = d1_d2(f, k, sigma, t);
212    let d1_dt = sigma / (2.0 * t.sqrt()) - d1 / (2.0 * t);
213    let delta_component = match put_or_call {
214        PutOrCall::Call => norm_cdf(d1),
215        PutOrCall::Put => norm_cdf(d1) - 1.0,
216    };
217    let discount_decay = match settlement {
218        FuturesSettlement::Discounted => r * df * delta_component,
219        FuturesSettlement::Margined => 0.0,
220    };
221    discount_decay - df * norm_pdf(d1) * d1_dt
222}
223
224/// Rho (sensitivity to the risk-free rate). Zero for margined options,
225/// which have no discounting; `-T * price` for discounted options (the
226/// futures price is exogenous, so `r` enters only through the discount).
227pub fn rho(
228    f: f64,
229    k: f64,
230    r: f64,
231    sigma: f64,
232    t: f64,
233    put_or_call: PutOrCall,
234    settlement: FuturesSettlement,
235) -> f64 {
236    match settlement {
237        FuturesSettlement::Margined => 0.0,
238        FuturesSettlement::Discounted => -t * price(f, k, r, sigma, t, put_or_call, settlement),
239    }
240}
241
242/// Theta (calendar time decay, `dV/dt = -dV/dT`).
243pub fn theta(
244    f: f64,
245    k: f64,
246    r: f64,
247    sigma: f64,
248    t: f64,
249    put_or_call: PutOrCall,
250    settlement: FuturesSettlement,
251) -> f64 {
252    let df = settlement.discount_factor(r, t);
253    let (d1, _) = d1_d2(f, k, sigma, t);
254    // volatility bleed term F df dN(d1) sigma / (2 sqrt(T)), common to both
255    // settlement styles and to calls and puts
256    let bleed = df * f * norm_pdf(d1) * sigma / (2.0 * t.sqrt());
257    match settlement {
258        FuturesSettlement::Margined => -bleed,
259        FuturesSettlement::Discounted => {
260            r * price(f, k, r, sigma, t, put_or_call, settlement) - bleed
261        }
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use crate::equity::blackscholes::bs_price;
269
270    const F: f64 = 100.0;
271    const K: f64 = 100.0;
272    const R: f64 = 0.05;
273    const SIG: f64 = 0.30;
274    const T: f64 = 1.0;
275
276    // Golden values, bump-verified against an independent reference
277    #[test]
278    fn discounted_golden_values() {
279        use FuturesSettlement::Discounted as D;
280        assert!((price(F, K, R, SIG, T, PutOrCall::Call, D) - 11.34202064).abs() < 1e-7);
281        assert!((delta(F, K, R, SIG, T, PutOrCall::Call, D) - 0.53232482).abs() < 1e-7);
282        assert!((delta(F, K, R, SIG, T, PutOrCall::Put, D) + 0.41890461).abs() < 1e-7);
283        assert!((gamma(F, K, R, SIG, T, D) - 0.01250801).abs() < 1e-7);
284        assert!((vega(F, K, R, SIG, T, D) - 37.52403469).abs() < 1e-6);
285        let h_vol = 1e-5;
286        let bumped_vanna = (delta(F, K, R, SIG + h_vol, T, PutOrCall::Call, D)
287            - delta(F, K, R, SIG - h_vol, T, PutOrCall::Call, D))
288            / (2.0 * h_vol);
289        assert!((vanna(F, K, R, SIG, T, D) - bumped_vanna).abs() < 1e-9);
290        let h_time = 1e-5;
291        let bumped_charm = -(delta(F, K, R, SIG, T + h_time, PutOrCall::Call, D)
292            - delta(F, K, R, SIG, T - h_time, PutOrCall::Call, D))
293            / (2.0 * h_time);
294        assert!((charm(F, K, R, SIG, T, PutOrCall::Call, D) - bumped_charm).abs() < 1e-9);
295        assert!((rho(F, K, R, SIG, T, PutOrCall::Call, D) + 11.34202064).abs() < 1e-6);
296        assert!((theta(F, K, R, SIG, T, PutOrCall::Call, D) + 5.06150417).abs() < 1e-6);
297    }
298
299    #[test]
300    fn margined_golden_values() {
301        use FuturesSettlement::Margined as M;
302        assert!((price(F, K, R, SIG, T, PutOrCall::Call, M) - 11.92353847).abs() < 1e-7);
303        assert!((delta(F, K, R, SIG, T, PutOrCall::Call, M) - 0.55961769).abs() < 1e-7);
304        assert!((gamma(F, K, R, SIG, T, M) - 0.01314931).abs() < 1e-7);
305        assert!((vega(F, K, R, SIG, T, M) - 39.44793309).abs() < 1e-6);
306        assert!((theta(F, K, R, SIG, T, PutOrCall::Call, M) + 5.91718996).abs() < 1e-6);
307    }
308
309    #[test]
310    fn volga_matches_vega_bump_and_smile_sign() {
311        for s in [FuturesSettlement::Discounted, FuturesSettlement::Margined] {
312            // volga = d(vega)/d(sigma), checked against a central bump
313            let h = 1e-5;
314            let bump =
315                (vega(F, K, R, SIG + h, T, s) - vega(F, K, R, SIG - h, T, s)) / (2.0 * h);
316            assert!((volga(F, K, R, SIG, T, s) - bump).abs() < 1e-6, "{s:?}");
317            // at the money volga is negative; the wings are positive
318            assert!(volga(F, 100.0, R, SIG, T, s) < 0.0);
319            assert!(volga(F, 70.0, R, SIG, T, s) > 0.0);
320            assert!(volga(F, 130.0, R, SIG, T, s) > 0.0);
321        }
322        // golden values, bump-verified against an independent reference
323        assert!(
324            (volga(F, K, R, SIG, T, FuturesSettlement::Discounted) + 2.81430260).abs() < 1e-6
325        );
326        assert!(
327            (volga(F, K, R, SIG, T, FuturesSettlement::Margined) + 2.95859498).abs() < 1e-6
328        );
329    }
330
331    #[test]
332    fn margined_rho_is_zero() {
333        // no discounting -> no rate sensitivity at all
334        for pc in [PutOrCall::Call, PutOrCall::Put] {
335            assert_eq!(rho(F, K, R, SIG, T, pc, FuturesSettlement::Margined), 0.0);
336        }
337    }
338
339    #[test]
340    fn margined_exceeds_discounted() {
341        // the undiscounted premium is worth more than the discounted one
342        let disc = price(F, K, R, SIG, T, PutOrCall::Call, FuturesSettlement::Discounted);
343        let marg = price(F, K, R, SIG, T, PutOrCall::Call, FuturesSettlement::Margined);
344        assert!(marg > disc);
345        // margined = discounted / e^{-rT}
346        assert!((marg - disc * (R * T).exp()).abs() < 1e-10);
347    }
348
349    #[test]
350    fn put_call_parity_both_styles() {
351        for (s, factor) in [
352            (FuturesSettlement::Discounted, (-R * T).exp()),
353            (FuturesSettlement::Margined, 1.0),
354        ] {
355            let c = price(F, 95.0, R, SIG, T, PutOrCall::Call, s);
356            let p = price(F, 95.0, R, SIG, T, PutOrCall::Put, s);
357            assert!((c - p - factor * (F - 95.0)).abs() < 1e-10, "{s:?}");
358        }
359    }
360
361    #[test]
362    fn discounted_black76_equals_black_scholes_at_the_forward() {
363        // Black-76 on F = S e^{(r-q)T} must reproduce Black-Scholes-Merton
364        let (s, q) = (100.0, 0.02);
365        let fwd = s * ((R - q) * T).exp();
366        let b76 = price(fwd, K, R, SIG, T, PutOrCall::Call, FuturesSettlement::Discounted);
367        let bsm = bs_price(s, K, R, q, SIG, T, PutOrCall::Call);
368        assert!((b76 - bsm).abs() < 1e-10, "b76 {b76} vs bsm {bsm}");
369    }
370
371    #[test]
372    fn settlement_parses_from_strings() {
373        assert_eq!("discounted".parse::<FuturesSettlement>().unwrap(), FuturesSettlement::Discounted);
374        assert_eq!("black76".parse::<FuturesSettlement>().unwrap(), FuturesSettlement::Discounted);
375        assert_eq!("margined".parse::<FuturesSettlement>().unwrap(), FuturesSettlement::Margined);
376        assert_eq!("futures_style".parse::<FuturesSettlement>().unwrap(), FuturesSettlement::Margined);
377        assert!("bad".parse::<FuturesSettlement>().is_err());
378    }
379}