use crate::Options;
use crate::error::PricingError;
use crate::greeks::{big_n, calculate_d_values_black_76};
use crate::model::decimal::{d_mul, d_sub};
use crate::model::types::{OptionStyle, OptionType, Side};
use rust_decimal::{Decimal, MathematicalOps};
use tracing::{instrument, trace};
#[instrument(skip(option), fields(
strike = %option.strike_price,
style = ?option.option_style,
side = ?option.side,
))]
pub fn black_76(option: &Options) -> Result<Decimal, PricingError> {
let (d1, d2, expiry_time) = calculate_d1_d2_and_time(option)?;
match option.option_type {
OptionType::European => calculate_european_option_price(option, d1, d2, expiry_time),
OptionType::American => Err(PricingError::unsupported_option_type(
"American", "Black-76",
)),
OptionType::Bermuda { .. } => {
Err(PricingError::unsupported_option_type("Bermuda", "Black-76"))
}
_ => Err(PricingError::unsupported_option_type("exotic", "Black-76")),
}
}
fn calculate_d1_d2_and_time(option: &Options) -> Result<(Decimal, Decimal, Decimal), PricingError> {
let calculated_time_to_expiry: Decimal = option.time_to_expiration()?.to_dec();
let (d1, d2) = calculate_d_values_black_76(option)?;
Ok((d1, d2, calculated_time_to_expiry))
}
fn calculate_european_option_price(
option: &Options,
d1: Decimal,
d2: Decimal,
expiry_time: Decimal,
) -> Result<Decimal, PricingError> {
match option.side {
Side::Long => calculate_long_position(option, d1, d2, expiry_time),
Side::Short => Ok(-calculate_long_position(option, d1, d2, expiry_time)?),
}
}
fn calculate_long_position(
option: &Options,
d1: Decimal,
d2: Decimal,
expiry_time: Decimal,
) -> Result<Decimal, PricingError> {
match option.option_style {
OptionStyle::Call => calculate_call_option_price(option, d1, d2, expiry_time),
OptionStyle::Put => calculate_put_option_price(option, d1, d2, expiry_time),
}
}
fn calculate_call_option_price(
option: &Options,
d1: Decimal,
d2: Decimal,
t: Decimal,
) -> Result<Decimal, PricingError> {
let big_n_d1 = big_n(d1)?;
let big_n_d2 = big_n(d2)?;
let rt = d_mul(-option.risk_free_rate, t, "pricing::black_76::call::rt")?;
let discount_factor = rt.exp();
let f_leg = d_mul(
option.underlying_price.to_dec(),
big_n_d1,
"pricing::black_76::call::f_leg",
)?;
let k_leg = d_mul(
option.strike_price.to_dec(),
big_n_d2,
"pricing::black_76::call::k_leg",
)?;
let undiscounted = d_sub(f_leg, k_leg, "pricing::black_76::call::undiscounted")?;
let result = d_mul(
discount_factor,
undiscounted,
"pricing::black_76::call::price",
)?;
trace!(
"Black-76 Call: F={}, K={}, e^(-rT)={}, N(d1)={}, N(d2)={}, price={}",
option.underlying_price, option.strike_price, discount_factor, big_n_d1, big_n_d2, result
);
Ok(result)
}
fn calculate_put_option_price(
option: &Options,
d1: Decimal,
d2: Decimal,
t: Decimal,
) -> Result<Decimal, PricingError> {
let big_n_neg_d1 = big_n(-d1)?;
let big_n_neg_d2 = big_n(-d2)?;
let rt = d_mul(-option.risk_free_rate, t, "pricing::black_76::put::rt")?;
let discount_factor = rt.exp();
let k_leg = d_mul(
option.strike_price.to_dec(),
big_n_neg_d2,
"pricing::black_76::put::k_leg",
)?;
let f_leg = d_mul(
option.underlying_price.to_dec(),
big_n_neg_d1,
"pricing::black_76::put::f_leg",
)?;
let undiscounted = d_sub(k_leg, f_leg, "pricing::black_76::put::undiscounted")?;
let result = d_mul(
discount_factor,
undiscounted,
"pricing::black_76::put::price",
)?;
trace!(
"Black-76 Put: F={}, K={}, e^(-rT)={}, N(-d1)={}, N(-d2)={}, price={}",
option.underlying_price,
option.strike_price,
discount_factor,
big_n_neg_d1,
big_n_neg_d2,
result
);
Ok(result)
}
pub trait Black76 {
fn get_option(&self) -> Result<&Options, PricingError>;
fn calculate_price_black_76(&self) -> Result<Decimal, PricingError> {
black_76(self.get_option()?)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ExpirationDate;
use crate::pricing::black_scholes_model::black_scholes;
use crate::pricing::unified::{PricingEngine, price_option};
use positive::{Positive, pos_or_panic};
use rust_decimal_macros::dec;
fn create_option(
f: f64,
k: f64,
r: Decimal,
t_days: f64,
sigma: f64,
style: OptionStyle,
) -> Options {
Options::new(
OptionType::European,
Side::Long,
"FUT".to_string(),
pos_or_panic!(k),
ExpirationDate::Days(pos_or_panic!(t_days)),
pos_or_panic!(sigma),
Positive::ONE,
pos_or_panic!(f),
r,
style,
pos_or_panic!(0.0),
None,
)
}
#[test]
fn test_black_76_call_hull_reference() {
let option = create_option(20.0, 20.0, dec!(0.09), 121.67, 0.25, OptionStyle::Call);
let price = black_76(&option).unwrap();
let expected = dec!(1.1166);
let tolerance = dec!(0.008);
assert!(
(price - expected).abs() <= tolerance,
"Black-76 call price {} outside tolerance of {} from expected {}",
price,
tolerance,
expected
);
}
#[test]
fn test_black_76_put_hull_reference() {
let call_option = create_option(20.0, 20.0, dec!(0.09), 120.0, 0.25, OptionStyle::Call);
let put_option = create_option(20.0, 20.0, dec!(0.09), 120.0, 0.25, OptionStyle::Put);
let call_price = black_76(&call_option).unwrap();
let put_price = black_76(&put_option).unwrap();
let diff = (call_price - put_price).abs();
assert!(diff < dec!(0.01), "ATM call-put diff too large: {}", diff);
}
#[test]
fn test_black_76_put_call_parity_atm() {
let f = 100.0;
let k = 100.0;
let r = dec!(0.05);
let t_days = 180.0;
let sigma = 0.2;
let call_option = create_option(f, k, r, t_days, sigma, OptionStyle::Call);
let put_option = create_option(f, k, r, t_days, sigma, OptionStyle::Put);
let call_price = black_76(&call_option).unwrap();
let put_price = black_76(&put_option).unwrap();
let t = dec!(180) / dec!(365);
let rt = -r * t;
let expected_diff = rt.exp() * (dec!(100) - dec!(100));
let actual_diff = call_price - put_price;
let tolerance = dec!(0.000001);
assert!(
(actual_diff - expected_diff).abs() < tolerance,
"Parity failed: C-P={}, expected={}",
actual_diff,
expected_diff
);
}
#[test]
fn test_black_76_put_call_parity_itm() {
let f = 110.0;
let k = 100.0;
let r = dec!(0.05);
let t_days = 180.0;
let sigma = 0.2;
let call_option = create_option(f, k, r, t_days, sigma, OptionStyle::Call);
let put_option = create_option(f, k, r, t_days, sigma, OptionStyle::Put);
let call_price = black_76(&call_option).unwrap();
let put_price = black_76(&put_option).unwrap();
let t = dec!(180) / dec!(365);
let rt = -r * t;
let expected_diff = rt.exp() * (dec!(110) - dec!(100));
let actual_diff = call_price - put_price;
let tolerance = dec!(0.000001);
assert!(
(actual_diff - expected_diff).abs() < tolerance,
"ITM parity failed: C-P={}, expected={}",
actual_diff,
expected_diff
);
}
#[test]
fn test_black_76_put_call_parity_otm() {
let f = 90.0;
let k = 100.0;
let r = dec!(0.05);
let t_days = 180.0;
let sigma = 0.2;
let call_option = create_option(f, k, r, t_days, sigma, OptionStyle::Call);
let put_option = create_option(f, k, r, t_days, sigma, OptionStyle::Put);
let call_price = black_76(&call_option).unwrap();
let put_price = black_76(&put_option).unwrap();
let t = dec!(180) / dec!(365);
let rt = -r * t;
let expected_diff = rt.exp() * (dec!(90) - dec!(100));
let actual_diff = call_price - put_price;
let tolerance = dec!(0.000001);
assert!(
(actual_diff - expected_diff).abs() < tolerance,
"OTM parity failed: C-P={}, expected={}",
actual_diff,
expected_diff
);
}
#[test]
fn test_black_76_zero_volatility_returns_error() {
let option = create_option(100.0, 100.0, dec!(0.05), 180.0, 0.0, OptionStyle::Call);
let result = black_76(&option);
assert!(result.is_err(), "Zero volatility should return error");
}
#[test]
fn test_black_76_monotonicity_call_in_forward() {
let r = dec!(0.05);
let t_days = 180.0;
let k = 100.0;
let sigma = 0.2;
let opt_f_low = create_option(95.0, k, r, t_days, sigma, OptionStyle::Call);
let opt_f_mid = create_option(100.0, k, r, t_days, sigma, OptionStyle::Call);
let opt_f_high = create_option(105.0, k, r, t_days, sigma, OptionStyle::Call);
let p_low = black_76(&opt_f_low).unwrap();
let p_mid = black_76(&opt_f_mid).unwrap();
let p_high = black_76(&opt_f_high).unwrap();
assert!(p_low < p_mid, "Call price should increase as F increases");
assert!(p_mid < p_high, "Call price should increase as F increases");
}
#[test]
fn test_black_76_monotonicity_put_in_forward() {
let r = dec!(0.05);
let t_days = 180.0;
let k = 100.0;
let sigma = 0.2;
let opt_f_low = create_option(95.0, k, r, t_days, sigma, OptionStyle::Put);
let opt_f_mid = create_option(100.0, k, r, t_days, sigma, OptionStyle::Put);
let opt_f_high = create_option(105.0, k, r, t_days, sigma, OptionStyle::Put);
let p_low = black_76(&opt_f_low).unwrap();
let p_mid = black_76(&opt_f_mid).unwrap();
let p_high = black_76(&opt_f_high).unwrap();
assert!(p_low > p_mid, "Put price should decrease as F increases");
assert!(p_mid > p_high, "Put price should decrease as F increases");
}
#[test]
fn test_black_76_short_side_is_negation() {
let option_long = create_option(100.0, 95.0, dec!(0.05), 180.0, 0.2, OptionStyle::Call);
let mut option_short = option_long.clone();
option_short.side = Side::Short;
let price_long = black_76(&option_long).unwrap();
let price_short = black_76(&option_short).unwrap();
assert_eq!(price_long, -price_short, "Short should negate long price");
}
#[test]
fn test_black_76_quantity_invariance() {
let mut option = create_option(100.0, 95.0, dec!(0.05), 180.0, 0.2, OptionStyle::Call);
let price1 = black_76(&option).unwrap();
option.quantity = pos_or_panic!(2.0);
let price2 = black_76(&option).unwrap();
assert_eq!(
price1, price2,
"Per-contract price should be quantity-invariant"
);
}
#[test]
fn test_black_76_unsupported_american() {
let mut option = create_option(100.0, 100.0, dec!(0.05), 180.0, 0.2, OptionStyle::Call);
option.option_type = OptionType::American;
let result = black_76(&option);
assert!(result.is_err(), "American options should return error");
}
#[test]
fn test_black_76_unsupported_bermuda() {
let mut option = create_option(100.0, 100.0, dec!(0.05), 180.0, 0.2, OptionStyle::Call);
option.option_type = OptionType::Bermuda {
exercise_dates: vec![],
};
let result = black_76(&option);
assert!(result.is_err(), "Bermuda options should return error");
}
#[test]
fn test_black_76_trait_impl() {
struct FutureOption {
option: Options,
}
impl Black76 for FutureOption {
fn get_option(&self) -> Result<&Options, PricingError> {
Ok(&self.option)
}
}
let option = create_option(100.0, 100.0, dec!(0.05), 180.0, 0.2, OptionStyle::Call);
let fut_opt = FutureOption { option };
let price_direct = black_76(fut_opt.get_option().unwrap()).unwrap();
let price_via_trait = fut_opt.calculate_price_black_76().unwrap();
assert_eq!(price_direct, price_via_trait);
}
fn assert_matches_bsm(f: f64, k: f64, r: Decimal, t_days: f64, sigma: f64, style: OptionStyle) {
let opt_b76 = create_option(f, k, r, t_days, sigma, style);
let years = opt_b76.expiration_date.get_years().unwrap().to_dec();
let discount_factor = (-r * years).exp();
let s_decimal = Decimal::from_f64_retain(f).unwrap() * discount_factor;
let s = Positive::new_decimal(s_decimal).unwrap();
let mut opt_bsm = opt_b76.clone();
opt_bsm.underlying_price = s;
let price_b76 = black_76(&opt_b76).unwrap();
let price_bsm = black_scholes(&opt_bsm).unwrap();
let tolerance = dec!(1e-9);
assert!(
(price_b76 - price_bsm).abs() < tolerance,
"Black-76 vs BSM mismatch (style={:?}, F={}, K={}, r={}, T_days={}, sigma={}): \
B76={}, BSM={}, diff={}",
style,
f,
k,
r,
t_days,
sigma,
price_b76,
price_bsm,
(price_b76 - price_bsm).abs()
);
}
#[test]
fn test_black_76_matches_bsm_with_discounted_spot_call_atm() {
assert_matches_bsm(100.0, 100.0, dec!(0.05), 180.0, 0.2, OptionStyle::Call);
}
#[test]
fn test_black_76_matches_bsm_with_discounted_spot_call_itm() {
assert_matches_bsm(110.0, 100.0, dec!(0.05), 180.0, 0.2, OptionStyle::Call);
}
#[test]
fn test_black_76_matches_bsm_with_discounted_spot_call_otm() {
assert_matches_bsm(90.0, 100.0, dec!(0.05), 180.0, 0.2, OptionStyle::Call);
}
#[test]
fn test_black_76_matches_bsm_with_discounted_spot_put_atm() {
assert_matches_bsm(100.0, 100.0, dec!(0.05), 180.0, 0.2, OptionStyle::Put);
}
#[test]
fn test_black_76_matches_bsm_with_discounted_spot_put_otm() {
assert_matches_bsm(110.0, 100.0, dec!(0.05), 180.0, 0.2, OptionStyle::Put);
}
#[test]
fn test_pricing_engine_closed_form_black_76_dispatch() {
let option = create_option(20.0, 20.0, dec!(0.09), 122.4, 0.25, OptionStyle::Call);
let price_via_engine = price_option(&option, &PricingEngine::ClosedFormBlack76).unwrap();
let price_direct = black_76(&option).unwrap();
assert_eq!(price_via_engine.to_dec(), price_direct);
}
#[test]
fn test_pricing_engine_closed_form_black_76_short_uses_abs() {
let mut option = create_option(20.0, 20.0, dec!(0.09), 122.4, 0.25, OptionStyle::Call);
option.side = Side::Short;
let price_via_engine = price_option(&option, &PricingEngine::ClosedFormBlack76).unwrap();
let price_direct = black_76(&option).unwrap();
assert_eq!(price_via_engine.to_dec(), price_direct.abs());
}
}