Skip to main content

hypercall_margin/
black_76.rs

1//! Black-76 pricing for European options on futures contracts.
2//!
3//! Black-76 is the forward-price variant of Black-Scholes, used for options
4//! whose underlying is a futures contract. It is mathematically equivalent to
5//! running Black-Scholes with spot = forward and r = 0, then discounting the
6//! result by e^(-r * tau).
7
8use crate::black_scholes::{black_scholes, calculate_greeks};
9use crate::types::OptionType;
10
11/// Price a European option on a futures contract under Black-76.
12///
13/// At or past expiry (`time_to_expiry <= 0`) the price is just intrinsic
14/// value. There is nothing left to discount, and the `e^(-r * tau)` factor
15/// would be greater than 1 for negative `tau`, inflating expired options above
16/// intrinsic.
17pub fn black_76_price(
18    option_type: &OptionType,
19    forward: f64,
20    strike: f64,
21    time_to_expiry: f64,
22    risk_free_rate: f64,
23    volatility: f64,
24) -> f64 {
25    if time_to_expiry <= 0.0 {
26        return match option_type {
27            OptionType::Call => (forward - strike).max(0.0),
28            OptionType::Put => (strike - forward).max(0.0),
29        };
30    }
31
32    let discount = (-risk_free_rate * time_to_expiry).exp();
33    discount
34        * black_scholes(
35            option_type,
36            forward,
37            strike,
38            time_to_expiry,
39            0.0,
40            volatility,
41        )
42}
43
44/// Solve for implied volatility under Black-76, given an observed market
45/// price. Returns `None` when the price violates no-arbitrage bounds or the
46/// solver fails to converge.
47pub fn black_76_implied_vol(
48    option_type: &OptionType,
49    forward: f64,
50    strike: f64,
51    time_to_expiry: f64,
52    risk_free_rate: f64,
53    market_price: f64,
54) -> Option<f64> {
55    let undiscounted_price = market_price * (risk_free_rate * time_to_expiry).exp();
56    calculate_implied_volatility(
57        option_type,
58        forward,
59        strike,
60        time_to_expiry,
61        0.0,
62        undiscounted_price,
63        None,
64    )
65}
66
67fn calculate_implied_volatility(
68    option_type: &OptionType,
69    spot: f64,
70    strike: f64,
71    time_to_expiry: f64,
72    risk_free_rate: f64,
73    market_price: f64,
74    initial_vol: Option<f64>,
75) -> Option<f64> {
76    if time_to_expiry <= 0.0 {
77        return None;
78    }
79
80    let intrinsic = match option_type {
81        OptionType::Call => (spot - strike).max(0.0),
82        OptionType::Put => (strike - spot).max(0.0),
83    };
84    if market_price < intrinsic {
85        return None;
86    }
87
88    let mut vol = initial_vol.unwrap_or(0.3);
89    let max_iterations = 50;
90    let tolerance = 1e-6;
91
92    for _ in 0..max_iterations {
93        let (price, _, _, vega, _) = calculate_greeks(
94            option_type,
95            spot,
96            strike,
97            time_to_expiry,
98            risk_free_rate,
99            vol,
100        );
101        let price_diff = price - market_price;
102
103        if price_diff.abs() < tolerance {
104            return Some(vol);
105        }
106
107        let vega_full = vega * 100.0;
108        if vega_full.abs() < 1e-10 {
109            break;
110        }
111
112        vol -= price_diff / vega_full;
113        vol = vol.clamp(0.001, 5.0);
114    }
115
116    None
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn black_76_implied_vol_roundtrips_a_known_sigma() {
125        let forward = 4700.0;
126        let strike = 4700.0;
127        let tau = 0.1;
128        let r = 0.05;
129        let sigma = 0.35;
130
131        let call_price = black_76_price(&OptionType::Call, forward, strike, tau, r, sigma);
132        let recovered =
133            black_76_implied_vol(&OptionType::Call, forward, strike, tau, r, call_price)
134                .expect("IV solver must converge for a well-formed input");
135
136        assert!(
137            (recovered - sigma).abs() < 1e-5,
138            "roundtrip failed: input sigma = {sigma}, recovered = {recovered}",
139        );
140    }
141
142    #[test]
143    fn black_76_put_call_parity_holds_otm() {
144        let forward = 4700.0;
145        let strike = 4800.0;
146        let tau = 0.25;
147        let r = 0.05;
148        let sigma = 0.35;
149
150        let call = black_76_price(&OptionType::Call, forward, strike, tau, r, sigma);
151        let put = black_76_price(&OptionType::Put, forward, strike, tau, r, sigma);
152        let discount = (-r * tau).exp();
153        let rhs = discount * (forward - strike);
154
155        assert!(
156            ((call - put) - rhs).abs() < 1e-9,
157            "put-call parity failed: C - P = {}, expected {}",
158            call - put,
159            rhs,
160        );
161    }
162
163    #[test]
164    fn black_76_implied_vol_returns_none_below_intrinsic() {
165        let forward = 5000.0;
166        let strike = 4000.0;
167        let tau = 0.25;
168        let r = 0.05;
169        let impossible_price = 500.0;
170
171        let result =
172            black_76_implied_vol(&OptionType::Call, forward, strike, tau, r, impossible_price);
173        assert!(
174            result.is_none(),
175            "solver should refuse arb-violating prices, got {result:?}",
176        );
177    }
178
179    #[test]
180    fn black_76_implied_vol_returns_none_at_zero_tau() {
181        let result = black_76_implied_vol(&OptionType::Call, 4700.0, 4700.0, 0.0, 0.05, 0.0);
182        assert!(result.is_none(), "zero tau must yield None");
183    }
184
185    #[test]
186    fn black_76_price_returns_intrinsic_at_or_past_expiry() {
187        let call_zero = black_76_price(&OptionType::Call, 4700.0, 4500.0, 0.0, 0.05, 0.35);
188        assert!(
189            (call_zero - 200.0).abs() < 1e-9,
190            "expired ITM call must equal intrinsic, got {call_zero}"
191        );
192
193        let call_neg = black_76_price(&OptionType::Call, 4700.0, 4500.0, -0.01, 0.05, 0.35);
194        assert!(
195            (call_neg - 200.0).abs() < 1e-9,
196            "past-expiry ITM call must equal intrinsic, got {call_neg}"
197        );
198
199        let otm_call = black_76_price(&OptionType::Call, 4500.0, 4700.0, 0.0, 0.05, 0.35);
200        assert!(
201            otm_call.abs() < 1e-9,
202            "expired OTM call must be zero, got {otm_call}"
203        );
204
205        let put_zero = black_76_price(&OptionType::Put, 4500.0, 4700.0, 0.0, 0.05, 0.35);
206        assert!(
207            (put_zero - 200.0).abs() < 1e-9,
208            "expired ITM put must equal intrinsic, got {put_zero}"
209        );
210    }
211}