use crate::error::ParamError;
pub(crate) fn stable_shape(u: f64, sigma: f64, rho: f64, radial: f64) -> f64 {
let unit = u / radial;
let scaled_sigma = sigma / radial;
if u >= 0.0 {
let correction = sigma * scaled_sigma / (1.0 + unit);
(1.0 + rho).mul_add(u, correction)
} else {
let correction = sigma * scaled_sigma / (1.0 - unit);
(rho - 1.0).mul_add(u, correction)
}
}
pub(crate) fn stable_unit_slope(u: f64, sigma: f64, rho: f64, radial: f64) -> f64 {
let unit = u / radial;
let scaled_sigma = sigma / radial;
if u >= 0.0 {
(1.0 + rho) - scaled_sigma * scaled_sigma / (1.0 + unit)
} else {
(rho - 1.0) + scaled_sigma * scaled_sigma / (1.0 - unit)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RawSvi {
pub(crate) a: f64,
pub(crate) b: f64,
pub(crate) rho: f64,
pub(crate) m: f64,
pub(crate) sigma: f64,
}
impl RawSvi {
pub fn new(a: f64, b: f64, rho: f64, m: f64, sigma: f64) -> Result<Self, ParamError> {
for (name, value) in [("a", a), ("b", b), ("rho", rho), ("m", m), ("sigma", sigma)] {
if !value.is_finite() {
return Err(ParamError::NonFinite { name });
}
}
if b < 0.0 {
return Err(ParamError::NegativeSlope { b });
}
if rho.abs() >= 1.0 {
return Err(ParamError::CorrelationOutOfRange { rho });
}
if sigma <= 0.0 {
return Err(ParamError::NonPositiveSigma { sigma });
}
let candidate = Self {
a,
b,
rho,
m,
sigma,
};
let w_min = candidate.w_min();
if !w_min.is_finite() {
return Err(ParamError::NonFinite { name: "w_min" });
}
if w_min < 0.0 {
return Err(ParamError::NegativeMinVariance { w_min });
}
if !candidate.k_min().is_finite() {
return Err(ParamError::NonFinite { name: "k_min" });
}
if !candidate.atm_total_variance().is_finite() {
return Err(ParamError::NonFinite { name: "w(0)" });
}
for (name, value) in [
("w'(0)", candidate.atm_skew()),
("w''(0)", candidate.atm_curvature()),
("left wing slope", candidate.left_wing_slope()),
("right wing slope", candidate.right_wing_slope()),
] {
if !value.is_finite() {
return Err(ParamError::NonFinite { name });
}
}
Ok(candidate)
}
pub(crate) const fn new_unchecked(a: f64, b: f64, rho: f64, m: f64, sigma: f64) -> Self {
Self {
a,
b,
rho,
m,
sigma,
}
}
#[must_use]
pub const fn a(self) -> f64 {
self.a
}
#[must_use]
pub const fn b(self) -> f64 {
self.b
}
#[must_use]
pub const fn rho(self) -> f64 {
self.rho
}
#[must_use]
pub const fn m(self) -> f64 {
self.m
}
#[must_use]
pub const fn sigma(self) -> f64 {
self.sigma
}
#[must_use]
pub const fn parameters(self) -> (f64, f64, f64, f64, f64) {
(self.a, self.b, self.rho, self.m, self.sigma)
}
pub fn validate(&self) -> Result<(), ParamError> {
Self::new(self.a, self.b, self.rho, self.m, self.sigma).map(|_| ())
}
#[must_use]
#[inline]
pub fn total_variance(&self, k: f64) -> f64 {
let u = k - self.m;
let r = u.hypot(self.sigma);
self.b
.mul_add(stable_shape(u, self.sigma, self.rho, r), self.a)
}
#[must_use]
#[inline]
pub fn w_prime(&self, k: f64) -> f64 {
let u = k - self.m;
let r = u.hypot(self.sigma);
self.b * stable_unit_slope(u, self.sigma, self.rho, r)
}
#[must_use]
#[inline]
pub fn w_double_prime(&self, k: f64) -> f64 {
let u = k - self.m;
let r = u.hypot(self.sigma);
let sigma_over_r = self.sigma / r;
self.b * sigma_over_r * sigma_over_r / r
}
pub fn implied_vol(&self, k: f64, t: f64) -> Result<f64, ParamError> {
if t <= 0.0 || !t.is_finite() {
return Err(ParamError::NonPositiveMaturity { t });
}
if !k.is_finite() {
return Err(ParamError::NonFinite {
name: "log_moneyness",
});
}
let volatility = (self.total_variance(k) / t).sqrt();
if volatility.is_finite() {
Ok(volatility)
} else {
Err(ParamError::NonFinite {
name: "implied_volatility",
})
}
}
#[must_use]
#[inline]
pub fn k_min(&self) -> f64 {
let denom = (1.0 - self.rho * self.rho).sqrt();
self.m - self.rho * self.sigma / denom
}
#[must_use]
#[inline]
pub fn w_min(&self) -> f64 {
self.b
.mul_add(self.sigma * (1.0 - self.rho * self.rho).sqrt(), self.a)
}
#[must_use]
#[inline]
pub fn atm_total_variance(&self) -> f64 {
self.total_variance(0.0)
}
#[must_use]
#[inline]
pub fn atm_skew(&self) -> f64 {
self.w_prime(0.0)
}
#[must_use]
#[inline]
pub fn atm_curvature(&self) -> f64 {
self.w_double_prime(0.0)
}
#[must_use]
#[inline]
pub fn left_wing_slope(&self) -> f64 {
self.b * (self.rho - 1.0)
}
#[must_use]
#[inline]
pub fn right_wing_slope(&self) -> f64 {
self.b * (self.rho + 1.0)
}
}
#[cfg(test)]
#[allow(clippy::expect_used)] mod tests {
use super::*;
fn slice() -> RawSvi {
RawSvi::new(0.04, 0.4, -0.3, 0.05, 0.12).expect("valid test or documentation fixture")
}
#[test]
fn new_accepts_valid_slice() {
assert!(RawSvi::new(0.04, 0.4, -0.3, 0.0, 0.1).is_ok());
}
#[test]
fn new_rejects_negative_b() {
assert!(matches!(
RawSvi::new(0.04, -0.1, 0.0, 0.0, 0.1),
Err(ParamError::NegativeSlope { .. })
));
}
#[test]
fn new_rejects_rho_out_of_range() {
assert!(matches!(
RawSvi::new(0.04, 0.4, 1.0, 0.0, 0.1),
Err(ParamError::CorrelationOutOfRange { .. })
));
assert!(matches!(
RawSvi::new(0.04, 0.4, -1.2, 0.0, 0.1),
Err(ParamError::CorrelationOutOfRange { .. })
));
}
#[test]
fn new_rejects_non_positive_sigma() {
assert!(matches!(
RawSvi::new(0.04, 0.4, 0.0, 0.0, 0.0),
Err(ParamError::NonPositiveSigma { .. })
));
}
#[test]
fn new_rejects_negative_min_variance() {
assert!(matches!(
RawSvi::new(-1.0, 0.4, 0.0, 0.0, 0.1),
Err(ParamError::NegativeMinVariance { .. })
));
}
#[test]
fn new_rejects_non_finite() {
assert!(matches!(
RawSvi::new(f64::NAN, 0.4, 0.0, 0.0, 0.1),
Err(ParamError::NonFinite { .. })
));
}
#[test]
fn new_rejects_non_finite_derived_invariants() {
assert!(matches!(
RawSvi::new(0.0, f64::MAX, 0.0, 0.0, f64::MAX),
Err(ParamError::NonFinite { name: "w_min" })
));
assert!(matches!(
RawSvi::new(0.0, f64::MAX, 0.0, -f64::MAX, f64::MIN_POSITIVE),
Err(ParamError::NonFinite { name: "w(0)" })
));
assert!(matches!(
RawSvi::new(1.0, 0.0, 0.5, -f64::MAX, f64::MAX),
Err(ParamError::NonFinite { name: "k_min" })
));
assert!(matches!(
RawSvi::new(0.0, f64::MAX / 2.0, 0.0, 0.0, f64::MIN_POSITIVE),
Err(ParamError::NonFinite {
name: "w''(0)" | "right wing slope"
})
));
}
#[test]
fn total_variance_golden_value() {
let svi =
RawSvi::new(0.04, 0.4, 0.0, 0.0, 0.1).expect("valid test or documentation fixture");
assert!((svi.total_variance(0.0) - 0.08).abs() < 1e-15);
assert!((svi.total_variance(0.5) - svi.total_variance(-0.5)).abs() < 1e-15);
}
#[test]
fn total_variance_hand_computed() {
let svi = slice();
let u = 0.2 - 0.05;
let r = (u * u + 0.12 * 0.12_f64).sqrt();
let expected = 0.04 + 0.4 * ((-0.3) * u + r);
assert!((svi.total_variance(0.2) - expected).abs() < 1e-15);
}
#[test]
fn derivative_matches_finite_difference() {
let svi = slice();
let h = 1e-6;
for &k in &[-0.3, -0.1, 0.0, 0.15, 0.4] {
let fd = (svi.total_variance(k + h) - svi.total_variance(k - h)) / (2.0 * h);
assert!((svi.w_prime(k) - fd).abs() < 1e-6, "k = {k}");
}
}
#[test]
fn derivatives_preserve_extreme_wing_scale() {
let slice = RawSvi::new(0.04, 1e-308, 0.0, 0.0, 1.0).expect("valid extreme-value fixture");
let slope = slice.w_prime(1e308);
assert!(slope > 0.0);
assert!((slope - 1e-308).abs() <= f64::MIN_POSITIVE);
assert!(slice.w_double_prime(1e308).abs() <= f64::MIN_POSITIVE);
}
#[test]
fn rationalized_wings_preserve_small_radial_correction() {
let epsilon = f64::EPSILON;
let right = RawSvi::new(0.0, 1.0, -1.0 + epsilon, 0.0, 1.0)
.expect("valid near-monotone right-wing fixture");
let left = RawSvi::new(0.0, 1.0, 1.0 - epsilon, 0.0, 1.0)
.expect("valid near-monotone left-wing fixture");
let expected_shape = 2.720_446_049_250_313e-8;
let expected_slope = 1.720_446_049_250_313e-16;
assert!((right.total_variance(1e8) - expected_shape).abs() < 1e-22);
assert!((left.total_variance(-1e8) - expected_shape).abs() < 1e-22);
assert!((right.w_prime(1e8) - expected_slope).abs() < 1e-30);
assert!((left.w_prime(-1e8) + expected_slope).abs() < 1e-30);
}
#[test]
fn second_derivative_matches_finite_difference() {
let svi = slice();
let h = 1e-4;
for &k in &[-0.3, -0.1, 0.0, 0.15, 0.4] {
let fd = (svi.total_variance(k + h) - 2.0 * svi.total_variance(k)
+ svi.total_variance(k - h))
/ (h * h);
assert!((svi.w_double_prime(k) - fd).abs() < 1e-4, "k = {k}");
}
}
#[test]
fn second_derivative_strictly_positive() {
let svi = slice();
for &k in &[-2.0, -0.5, 0.0, 0.5, 2.0] {
assert!(svi.w_double_prime(k) > 0.0, "k = {k}");
}
}
#[test]
fn vertex_is_the_minimum() {
let svi = slice();
let km = svi.k_min();
assert!(svi.w_prime(km).abs() < 1e-12);
let wmin = svi.w_min();
assert!((wmin - svi.total_variance(km)).abs() < 1e-12);
assert!(svi.total_variance(km + 0.1) > wmin);
assert!(svi.total_variance(km - 0.1) > wmin);
}
#[test]
fn implied_vol_roundtrip() {
let svi =
RawSvi::new(0.04, 0.0, 0.0, 0.0, 0.1).expect("valid test or documentation fixture");
let vol = svi
.implied_vol(0.0, 1.0)
.expect("valid test or documentation fixture");
assert!((vol - 0.2).abs() < 1e-12);
}
#[test]
fn implied_vol_rejects_bad_maturity() {
let svi = slice();
assert!(matches!(
svi.implied_vol(0.0, 0.0),
Err(ParamError::NonPositiveMaturity { .. })
));
}
#[test]
fn implied_vol_never_returns_non_finite_success() {
let slice = RawSvi::new(0.04, 0.4, -0.3, 0.0, 0.1).expect("valid test fixture");
assert!(matches!(
slice.implied_vol(f64::NAN, 1.0),
Err(ParamError::NonFinite {
name: "log_moneyness"
})
));
assert!(slice.implied_vol(f64::MAX, f64::MIN_POSITIVE).is_err());
}
#[test]
fn atm_accessors_agree_with_evaluation() {
let svi = slice();
assert!((svi.atm_total_variance() - svi.total_variance(0.0)).abs() < 1e-15);
assert!((svi.atm_skew() - svi.w_prime(0.0)).abs() < 1e-15);
assert!((svi.atm_curvature() - svi.w_double_prime(0.0)).abs() < 1e-15);
}
#[test]
fn wing_slopes_have_correct_sign() {
let svi = slice();
assert!(svi.left_wing_slope() <= 0.0);
assert!(svi.right_wing_slope() >= 0.0);
let far = (svi.total_variance(1000.0) - svi.total_variance(999.0)) / 1.0;
assert!((far - svi.right_wing_slope()).abs() < 1e-3);
}
#[test]
fn validate_round_trips() {
let svi = RawSvi::new_unchecked(0.04, 0.4, -0.3, 0.0, 0.1);
assert!(svi.validate().is_ok());
let bad = RawSvi::new_unchecked(0.04, -1.0, 0.0, 0.0, 0.1);
assert!(bad.validate().is_err());
}
}