use crate::{
ad::{dual::DualFwd, expr::FloatExt},
math::probability::{
norm_cdf::{norm_cdf, NormCDF},
norm_pdf::norm_pdf,
},
rates::yieldtermstructure::interestratestermstructure::InterestRatesTermStructure,
time::{date::Date, daycounter::DayCounter, enums::TimeUnit, period::Period},
utils::errors::{QSError, Result},
};
pub fn d1_d2(fwd: f64, strike: f64, vol: f64, tau: f64) -> Result<(f64, f64)> {
if strike <= 0.0 {
return Err(QSError::InvalidValueErr("strike must be positive".into()));
}
if tau <= 0.0 {
return Err(QSError::InvalidValueErr(
"time to expiry must be positive".into(),
));
}
if vol <= 0.0 {
return Err(QSError::InvalidValueErr(
"volatility must be positive".into(),
));
}
let sqrt_tau = tau.sqrt();
let d1 = (0.5 * vol).mul_add(sqrt_tau, (fwd / strike).ln() / (vol * sqrt_tau));
let d2 = vol.mul_add(-sqrt_tau, d1);
Ok((d1, d2))
}
pub fn black_call(fwd: f64, strike: f64, vol: f64, tau: f64) -> Result<f64> {
let (d1, d2) = d1_d2(fwd, strike, vol, tau)?;
Ok(fwd.mul_add(d1.norm_cdf(), -(strike * d2.norm_cdf())))
}
pub fn black_put(fwd: f64, strike: f64, vol: f64, tau: f64) -> Result<f64> {
let (d1, d2) = d1_d2(fwd, strike, vol, tau)?;
Ok(strike.mul_add((-d2).norm_cdf(), -(fwd * (-d1).norm_cdf())))
}
pub fn bachelier_call(fwd: f64, strike: f64, vol: f64, tau: f64) -> Result<f64> {
if tau <= 0.0 {
return Err(QSError::InvalidValueErr(
"time to expiry must be positive".into(),
));
}
if vol <= 0.0 {
return Err(QSError::InvalidValueErr(
"volatility must be positive".into(),
));
}
let sigma_sqrt_tau = vol * tau.sqrt();
let d = (fwd - strike) / sigma_sqrt_tau;
Ok((fwd - strike).mul_add(norm_cdf(d), sigma_sqrt_tau * norm_pdf(d)))
}
pub fn d1_d2_ad(fwd: DualFwd, strike: f64, vol: DualFwd, tau: f64) -> Result<(DualFwd, DualFwd)> {
if strike <= 0.0 {
return Err(QSError::InvalidValueErr("strike must be positive".into()));
}
if tau <= 0.0 {
return Err(QSError::InvalidValueErr(
"time to expiry must be positive".into(),
));
}
let sqrt_tau = tau.sqrt();
let d1: DualFwd = ((fwd / strike).ln() / (vol * sqrt_tau) + vol * sqrt_tau * 0.5).into();
let d2: DualFwd = (d1 - vol * sqrt_tau).into();
Ok((d1, d2))
}
pub fn black_call_ad(fwd: DualFwd, strike: f64, vol: DualFwd, tau: f64) -> Result<DualFwd> {
let (d1, d2) = d1_d2_ad(fwd, strike, vol, tau)?;
Ok((fwd * norm_cdf(d1) - norm_cdf(d2) * strike).into())
}
pub fn black_put_ad(fwd: DualFwd, strike: f64, vol: DualFwd, tau: f64) -> Result<DualFwd> {
let (d1, d2) = d1_d2_ad(fwd, strike, vol, tau)?;
let neg_d2: DualFwd = (-d2).into();
let neg_d1: DualFwd = (-d1).into();
Ok((norm_cdf(neg_d2) * strike - fwd * norm_cdf(neg_d1)).into())
}
pub fn swap_annuity_from_curve(
curve: &dyn InterestRatesTermStructure<f64>,
reference_date: Date,
start: Date,
end: Date,
day_counter: DayCounter,
) -> Result<f64> {
let mut annuity = 0.0;
let mut date = start;
let one_year = Period::new(1, TimeUnit::Years);
while date < end {
let next = std::cmp::min(date + one_year, end);
let t = day_counter.year_fraction(reference_date, next);
let tau = day_counter.year_fraction(date, next);
annuity = tau.mul_add(curve.discount_factor_from_time(t)?, annuity);
date = next;
}
Ok(annuity)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bachelier_call_atm_matches_closed_form() -> Result<()> {
let (fwd, vol, tau) = (0.03, 0.01, 2.0);
let price = bachelier_call(fwd, fwd, vol, tau)?;
let expected = vol * tau.sqrt() / (2.0 * std::f64::consts::PI).sqrt();
assert!((price - expected).abs() < 1e-15);
Ok(())
}
#[test]
fn bachelier_call_bounds_and_negative_rates() -> Result<()> {
let (fwd, strike, vol, tau) = (-0.005, -0.01, 0.008, 1.5);
let price = bachelier_call(fwd, strike, vol, tau)?;
assert!(price > (fwd - strike).max(0.0));
let higher = bachelier_call(fwd, strike, 2.0 * vol, tau)?;
assert!(higher > price);
assert!(bachelier_call(fwd, strike, 0.0, tau).is_err());
assert!(bachelier_call(fwd, strike, vol, 0.0).is_err());
Ok(())
}
#[test]
fn black_put_call_parity_across_ladder() -> Result<()> {
let fwd = 0.04;
for strike_mult in [0.25, 0.5, 1.0, 1.5, 4.0] {
for vol in [0.01, 0.2, 1.0, 3.0] {
for tau in [0.01, 1.0, 30.0] {
let strike = fwd * strike_mult;
let call = black_call(fwd, strike, vol, tau)?;
let put = black_put(fwd, strike, vol, tau)?;
assert!(
(call - put - (fwd - strike)).abs() < 1e-14,
"parity violated at K={strike}, vol={vol}, tau={tau}"
);
}
}
}
Ok(())
}
#[test]
fn black_call_respects_no_arbitrage_bounds_and_extreme_vol_limits() -> Result<()> {
let fwd = 100.0;
for strike in [1.0, 50.0, 100.0, 150.0, 10_000.0] {
for vol in [1e-6, 0.2, 5.0] {
for tau in [1e-6, 1.0, 50.0] {
let call = black_call(fwd, strike, vol, tau)?;
let intrinsic = (fwd - strike).max(0.0);
assert!(
call >= intrinsic - 1e-10 && call <= fwd + 1e-10,
"bounds violated: C={call} at K={strike}, vol={vol}, tau={tau}"
);
}
}
}
assert!((black_call(fwd, 80.0, 1e-9, 1.0)? - 20.0).abs() < 1e-9);
assert!(black_call(fwd, 120.0, 1e-9, 1.0)? < 1e-12);
assert!((black_call(fwd, 80.0, 40.0, 10.0)? - fwd).abs() < 1e-9);
assert!((black_put(fwd, 80.0, 40.0, 10.0)? - 80.0).abs() < 1e-9);
Ok(())
}
#[test]
fn black_call_is_monotone_in_strike_vol_and_time() -> Result<()> {
let fwd = 0.05;
let mut prev = f64::INFINITY;
for strike_mult in [0.5, 0.75, 1.0, 1.25, 1.5] {
let c = black_call(fwd, fwd * strike_mult, 0.3, 2.0)?;
assert!(c < prev, "call must decrease in strike");
prev = c;
}
prev = 0.0;
for vol in [0.05, 0.1, 0.2, 0.5, 1.0] {
let c = black_call(fwd, fwd, vol, 2.0)?;
assert!(c > prev, "call must increase in vol");
prev = c;
}
prev = 0.0;
for tau in [0.1, 0.5, 1.0, 5.0, 20.0] {
let c = black_call(fwd, fwd, 0.2, tau)?;
assert!(c > prev, "ATM call must increase in tau");
prev = c;
}
Ok(())
}
#[test]
fn black_errors_on_degenerate_inputs() {
assert!(black_call(0.04, 0.0, 0.2, 1.0).is_err());
assert!(black_call(0.04, -0.01, 0.2, 1.0).is_err());
assert!(black_call(0.04, 0.04, 0.0, 1.0).is_err());
assert!(black_call(0.04, 0.04, -0.2, 1.0).is_err());
assert!(black_call(0.04, 0.04, 0.2, 0.0).is_err());
assert!(black_put(0.04, 0.04, 0.2, -1.0).is_err());
}
#[test]
fn bachelier_put_call_symmetry_gives_parity() -> Result<()> {
for (fwd, strike) in [(0.04, 0.03), (0.04, 0.06), (-0.01, 0.005), (-0.02, -0.03)] {
for vol in [0.001, 0.01, 0.10] {
for tau in [0.1, 2.0, 20.0] {
let call = bachelier_call(fwd, strike, vol, tau)?;
let reversed = bachelier_call(strike, fwd, vol, tau)?;
assert!(
(call - reversed - (fwd - strike)).abs() < 1e-14,
"normal parity violated at F={fwd}, K={strike}, vol={vol}, tau={tau}"
);
}
}
}
Ok(())
}
#[test]
fn bachelier_call_converges_to_intrinsic_at_low_vol() -> Result<()> {
assert!((bachelier_call(0.05, 0.03, 1e-10, 1.0)? - 0.02).abs() < 1e-12);
assert!(bachelier_call(0.03, 0.05, 1e-10, 1.0)? < 1e-15);
Ok(())
}
#[test]
fn black_and_bachelier_agree_atm_for_small_total_vol() -> Result<()> {
let (fwd, tau) = (0.04, 0.5);
let sigma_b = 0.05;
let sigma_n = sigma_b * fwd;
let black = black_call(fwd, fwd, sigma_b, tau)?;
let normal = bachelier_call(fwd, fwd, sigma_n, tau)?;
assert!(
(black - normal).abs() / black < 1e-3,
"ATM Black {black} and Bachelier {normal} should agree for small vol"
);
Ok(())
}
}