use crate::error::{SanosError, SanosResult};
use statrs::distribution::{ContinuousCDF, Normal};
#[inline]
pub fn norm_cdf(x: f64) -> SanosResult<f64> {
if !x.is_finite() {
return Err(SanosError::NonFinite {
field: "x",
value: x,
});
}
let n = Normal::new(0.0, 1.0).expect("Normal(0,1) must be constructible");
Ok(n.cdf(x))
}
pub fn bs_call_forward_norm(k: f64, var: f64) -> SanosResult<f64> {
if !k.is_finite() {
return Err(SanosError::NonFinite {
field: "k",
value: k,
});
}
if !var.is_finite() {
return Err(SanosError::NonFinite {
field: "var",
value: var,
});
}
if k <= 0.0 {
return Err(SanosError::InvalidBound {
field: "k",
value: k,
min: f64::MIN_POSITIVE,
max: f64::INFINITY,
});
}
if var < 0.0 {
return Err(SanosError::InvalidBound {
field: "var",
value: var,
min: 0.0,
max: f64::INFINITY,
});
}
if var == 0.0 {
return Ok((1.0 - k).max(0.0));
}
let sqrt_var = var.sqrt();
let ln_k = k.ln();
let d1 = (-ln_k + 0.5 * var) / sqrt_var;
let d2 = d1 - sqrt_var;
let n = Normal::new(0.0, 1.0).expect("Normal(0,1) must be constructible");
let nd1 = n.cdf(d1);
let nd2 = n.cdf(d2);
Ok(nd1 - k * nd2)
}
pub fn bs_implied_atm_var_from_call(call_atm: f64) -> SanosResult<f64> {
if !call_atm.is_finite() {
return Err(SanosError::NonFinite {
field: "call_atm",
value: call_atm,
});
}
if !(0.0..=1.0).contains(&call_atm) {
return Err(SanosError::InvalidBound {
field: "call_atm",
value: call_atm,
min: 0.0,
max: 1.0,
});
}
let p = 0.5 * (1.0 + call_atm);
let eps = 1e-15;
let p = p.clamp(eps, 1.0 - eps);
let n = Normal::new(0.0, 1.0).expect("Normal(0,1) must be constructible");
let x = n.inverse_cdf(p); Ok(4.0 * x * x)
}
#[cfg(feature = "iv-jaeckel")]
pub fn bs_implied_vol_from_call(
call: f64,
forward: f64,
strike: f64,
maturity: f64,
) -> SanosResult<f64> {
if !call.is_finite() {
return Err(SanosError::NonFinite {
field: "call",
value: call,
});
}
if !forward.is_finite() {
return Err(SanosError::NonFinite {
field: "forward",
value: forward,
});
}
if !strike.is_finite() {
return Err(SanosError::NonFinite {
field: "strike",
value: strike,
});
}
if !maturity.is_finite() {
return Err(SanosError::NonFinite {
field: "maturity",
value: maturity,
});
}
if forward <= 0.0 {
return Err(SanosError::InvalidBound {
field: "forward",
value: forward,
min: f64::MIN_POSITIVE,
max: f64::INFINITY,
});
}
if strike <= 0.0 {
return Err(SanosError::InvalidBound {
field: "strike",
value: strike,
min: f64::MIN_POSITIVE,
max: f64::INFINITY,
});
}
if maturity <= 0.0 {
return Err(SanosError::InvalidBound {
field: "maturity",
value: maturity,
min: f64::MIN_POSITIVE,
max: f64::INFINITY,
});
}
let intrinsic = (forward - strike).max(0.0);
if call < intrinsic - 1e-14 || call > forward + 1e-14 {
return Err(SanosError::InvalidBound {
field: "call",
value: call,
min: intrinsic,
max: forward,
});
}
let iv = jaeckel::implied_black_volatility(call, forward, strike, maturity, 1.0);
if !iv.is_finite() || iv == f64::MAX || iv == -f64::MAX || iv < 0.0 {
return Err(SanosError::External {
msg: format!(
"implied vol inversion failed (call={call}, forward={forward}, strike={strike}, maturity={maturity}, iv={iv})"
),
});
}
Ok(iv)
}
#[cfg(not(feature = "iv-jaeckel"))]
pub fn bs_implied_vol_from_call(
_call: f64,
_forward: f64,
_strike: f64,
_maturity: f64,
) -> SanosResult<f64> {
Err(SanosError::NotImplemented {
what: "Enable feature `iv-jaeckel` to use implied volatility inversion.",
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bs_call_var_zero_limit() {
let c = bs_call_forward_norm(1.2, 0.0).unwrap();
assert!((c - 0.0).abs() < 1e-15);
let c = bs_call_forward_norm(0.8, 0.0).unwrap();
assert!((c - 0.2).abs() < 1e-15);
}
#[test]
fn bs_implied_atm_var_roundtrip() {
let w = 0.09;
let c = bs_call_forward_norm(1.0, w).unwrap();
let w2 = bs_implied_atm_var_from_call(c).unwrap();
assert!((w - w2).abs() < 1e-10);
}
#[cfg(feature = "iv-jaeckel")]
#[test]
fn bs_implied_vol_roundtrip() {
let k = 1.15;
let t = 0.7;
let sigma = 0.32;
let var = sigma * sigma * t;
let price = bs_call_forward_norm(k, var).unwrap();
let implied = bs_implied_vol_from_call(price, 1.0, k, t).unwrap();
assert!((implied - sigma).abs() < 1e-10);
}
}