use crate::errors::PricingError;
use crate::math::{ncdf, npdf};
use crate::types::OptionType;
#[derive(Debug, Clone, Copy)]
pub struct BachelierParams {
pub option_type: OptionType,
pub forward: f64,
pub strike: f64,
pub rate: f64,
pub normal_vol: f64,
pub time: f64,
}
#[inline]
#[allow(clippy::many_single_char_names)]
pub fn price(params: &BachelierParams) -> Result<f64, PricingError> {
if params.time < 0.0_f64 {
return Err(PricingError::NegativeTime);
}
if params.normal_vol < 0.0_f64 {
return Err(PricingError::NegativeVolatility);
}
let f = params.forward;
let k = params.strike;
let r = params.rate;
let sigma_n = params.normal_vol;
let t = params.time;
if t == 0.0_f64 {
let intrinsic = match params.option_type {
OptionType::Call => {
if f > k {
f - k
} else {
0.0_f64
}
}
OptionType::Put => {
if k > f {
k - f
} else {
0.0_f64
}
}
};
return Err(PricingError::IntrinsicOnly { intrinsic });
}
let sqrt_t = t.sqrt();
let sigma_sqrt_t = sigma_n * sqrt_t;
let discount = (-r * t).exp();
if sigma_sqrt_t == 0.0_f64 {
let intrinsic = match params.option_type {
OptionType::Call => {
if f > k {
f - k
} else {
0.0_f64
}
}
OptionType::Put => {
if k > f {
k - f
} else {
0.0_f64
}
}
};
return Ok(discount * intrinsic);
}
let d = (f - k) / sigma_sqrt_t;
let phi_d = npdf(d);
let price_val = match params.option_type {
OptionType::Call => discount * (f - k).mul_add(ncdf(d), sigma_sqrt_t * phi_d),
OptionType::Put => discount * (k - f).mul_add(ncdf(-d), sigma_sqrt_t * phi_d),
};
Ok(price_val)
}
#[cfg(test)]
mod tests {
use super::*;
const LOOSE: f64 = 1e-4_f64;
#[test]
fn test_call_atm_matches_golden_value() {
let params = BachelierParams {
option_type: OptionType::Call,
forward: 100.0_f64,
strike: 100.0_f64,
rate: 0.05_f64,
normal_vol: 5.0_f64,
time: 1.0_f64,
};
let p = price(¶ms).unwrap();
assert!((p - 1.8974_f64).abs() < LOOSE, "call ATM: got {p}");
}
#[test]
fn test_put_atm_matches_golden_value() {
let params = BachelierParams {
option_type: OptionType::Put,
forward: 100.0_f64,
strike: 100.0_f64,
rate: 0.05_f64,
normal_vol: 5.0_f64,
time: 1.0_f64,
};
let p = price(¶ms).unwrap();
assert!((p - 1.8974_f64).abs() < LOOSE, "put ATM: got {p}");
}
#[test]
fn test_call_otm_matches_golden_value() {
let params = BachelierParams {
option_type: OptionType::Call,
forward: 100.0_f64,
strike: 105.0_f64,
rate: 0.05_f64,
normal_vol: 5.0_f64,
time: 1.0_f64,
};
let p = price(¶ms).unwrap();
assert!((p - 0.3963_f64).abs() < LOOSE, "call OTM: got {p}");
}
#[test]
fn test_put_otm_matches_golden_value() {
let params = BachelierParams {
option_type: OptionType::Put,
forward: 100.0_f64,
strike: 105.0_f64,
rate: 0.05_f64,
normal_vol: 5.0_f64,
time: 1.0_f64,
};
let p = price(¶ms).unwrap();
assert!((p - 5.1524_f64).abs() < LOOSE, "put OTM: got {p}");
}
#[test]
fn test_call_high_vol_matches_golden_value() {
let params = BachelierParams {
option_type: OptionType::Call,
forward: 100.0_f64,
strike: 100.0_f64,
rate: 0.05_f64,
normal_vol: 10.0_f64,
time: 1.0_f64,
};
let p = price(¶ms).unwrap();
assert!((p - 3.7949_f64).abs() < LOOSE, "call high vol: got {p}");
}
#[test]
fn test_put_high_vol_matches_golden_value() {
let params = BachelierParams {
option_type: OptionType::Put,
forward: 100.0_f64,
strike: 100.0_f64,
rate: 0.05_f64,
normal_vol: 10.0_f64,
time: 1.0_f64,
};
let p = price(¶ms).unwrap();
assert!((p - 3.7949_f64).abs() < LOOSE, "put high vol: got {p}");
}
#[test]
fn test_put_call_parity_atm() {
let call_params = BachelierParams {
option_type: OptionType::Call,
forward: 100.0_f64,
strike: 100.0_f64,
rate: 0.05_f64,
normal_vol: 5.0_f64,
time: 1.0_f64,
};
let put_params = BachelierParams {
option_type: OptionType::Put,
..call_params
};
let c = price(&call_params).unwrap();
let p = price(&put_params).unwrap();
assert!((c - p).abs() < 1e-10_f64, "parity ATM: C-P={}", c - p);
}
#[test]
fn test_put_call_parity_otm() {
let call_params = BachelierParams {
option_type: OptionType::Call,
forward: 100.0_f64,
strike: 105.0_f64,
rate: 0.05_f64,
normal_vol: 5.0_f64,
time: 1.0_f64,
};
let put_params = BachelierParams {
option_type: OptionType::Put,
..call_params
};
let c = price(&call_params).unwrap();
let p = price(&put_params).unwrap();
let parity = (-0.05_f64 * 1.0_f64).exp() * (100.0_f64 - 105.0_f64);
assert!(
(c - p - parity).abs() < 1e-10_f64,
"parity: C-P={}, expected={parity}",
c - p
);
}
#[test]
fn test_negative_forward_is_valid() {
let params = BachelierParams {
option_type: OptionType::Call,
forward: -1.0_f64,
strike: 0.0_f64,
rate: 0.05_f64,
normal_vol: 5.0_f64,
time: 1.0_f64,
};
assert!(price(¶ms).is_ok());
}
#[test]
fn test_negative_strike_is_valid() {
let params = BachelierParams {
option_type: OptionType::Put,
forward: 0.0_f64,
strike: -1.0_f64,
rate: 0.05_f64,
normal_vol: 5.0_f64,
time: 1.0_f64,
};
assert!(price(¶ms).is_ok());
}
#[test]
fn test_negative_time_returns_error() {
let params = BachelierParams {
option_type: OptionType::Call,
forward: 100.0_f64,
strike: 100.0_f64,
rate: 0.05_f64,
normal_vol: 5.0_f64,
time: -1.0_f64,
};
assert_eq!(price(¶ms), Err(PricingError::NegativeTime));
}
#[test]
fn test_negative_vol_returns_error() {
let params = BachelierParams {
option_type: OptionType::Call,
forward: 100.0_f64,
strike: 100.0_f64,
rate: 0.05_f64,
normal_vol: -5.0_f64,
time: 1.0_f64,
};
assert_eq!(price(¶ms), Err(PricingError::NegativeVolatility));
}
#[test]
fn test_t_zero_returns_intrinsic_call_itm() {
let params = BachelierParams {
option_type: OptionType::Call,
forward: 110.0_f64,
strike: 100.0_f64,
rate: 0.05_f64,
normal_vol: 5.0_f64,
time: 0.0_f64,
};
match price(¶ms) {
Err(PricingError::IntrinsicOnly { intrinsic }) => {
assert!((intrinsic - 10.0_f64).abs() < 1e-10_f64);
}
other => panic!("expected IntrinsicOnly, got {other:?}"),
}
}
#[test]
fn test_t_zero_returns_intrinsic_put_otm() {
let params = BachelierParams {
option_type: OptionType::Put,
forward: 110.0_f64,
strike: 100.0_f64,
rate: 0.05_f64,
normal_vol: 5.0_f64,
time: 0.0_f64,
};
match price(¶ms) {
Err(PricingError::IntrinsicOnly { intrinsic }) => {
assert!((intrinsic - 0.0_f64).abs() < 1e-10_f64);
}
other => panic!("expected IntrinsicOnly, got {other:?}"),
}
}
#[test]
fn test_zero_vol_returns_discounted_intrinsic() {
let params = BachelierParams {
option_type: OptionType::Call,
forward: 110.0_f64,
strike: 100.0_f64,
rate: 0.05_f64,
normal_vol: 0.0_f64,
time: 1.0_f64,
};
let p = price(¶ms).unwrap();
let expected = (-0.05_f64).exp() * 10.0_f64;
assert!(
(p - expected).abs() < 1e-10_f64,
"zero vol: got {p}, expected {expected}"
);
}
#[test]
fn test_atm_call_equals_put() {
let call_params = BachelierParams {
option_type: OptionType::Call,
forward: 100.0_f64,
strike: 100.0_f64,
rate: 0.05_f64,
normal_vol: 5.0_f64,
time: 1.0_f64,
};
let put_params = BachelierParams {
option_type: OptionType::Put,
..call_params
};
let c = price(&call_params).unwrap();
let p = price(&put_params).unwrap();
assert!((c - p).abs() < 1e-10_f64, "ATM symmetry: C={c}, P={p}");
}
#[test]
fn test_negative_rate_no_panic() {
let params = BachelierParams {
option_type: OptionType::Call,
forward: 100.0_f64,
strike: 100.0_f64,
rate: -0.02_f64,
normal_vol: 5.0_f64,
time: 1.0_f64,
};
let p = price(¶ms).unwrap();
assert!(p.is_finite(), "negative rate produced non-finite: {p}");
assert!(p > 0.0_f64);
}
#[test]
fn test_price_proportional_to_vol_atm() {
let p1 = price(&BachelierParams {
option_type: OptionType::Call,
forward: 100.0_f64,
strike: 100.0_f64,
rate: 0.05_f64,
normal_vol: 5.0_f64,
time: 1.0_f64,
})
.unwrap();
let p2 = price(&BachelierParams {
option_type: OptionType::Call,
forward: 100.0_f64,
strike: 100.0_f64,
rate: 0.05_f64,
normal_vol: 10.0_f64,
time: 1.0_f64,
})
.unwrap();
assert!((p2 / p1 - 2.0_f64).abs() < 1e-10_f64, "ratio: {}", p2 / p1);
}
}