regit-svi 2.0.0

Arbitrage-free SVI volatility surfaces in pure Rust. Raw, Jump-Wings and SSVI parametrisations, calibration, and static-arbitrage checks. Zero dependencies.
Documentation
// Copyright 2026 Regit.io — Nicolas Koenig
// SPDX-License-Identifier: Apache-2.0

//! Conversions between the Raw, Jump-Wings, and SSVI parametrisations.
//!
//! Three parametrisations are supported; the conversions form this graph:
//!
//! ```text
//! SSVI --slice at fixed theta--> Raw --forward map--> Jump-Wings
//!                                ^
//!                                | regular identifiable inverse only
//!                                +------------------- Jump-Wings
//! ```
//!
//! - **Raw -> JW** is defined for positive ATM variance. **JW -> Raw** is
//!   unique only on the regular identifiable domain; flat wings and a vertex
//!   at ATM lose scale information and return a typed conversion error.
//! - **SSVI -> Raw** — every SSVI slice at fixed `theta` is a raw SVI slice
//!   (see [`crate::surface::ssvi::Ssvi::slice_at`]).
//! - **Raw -> SSVI** is not defined: SSVI is a constrained three-parameter
//!   family, so a generic raw slice has no SSVI pre-image.
//!
//! # Raw -> JW (forward map)
//!
//! Given `{a, b, rho, m, sigma}` and maturity `t`, with
//! `w0 = a + b*(-rho*m + sqrt(m^2+sigma^2))`:
//!
//! ```text
//! v_t       = w0 / t
//! psi_t     = (b/(2*sqrt(w0))) * (rho - m/sqrt(m^2+sigma^2))
//! p_t       = (b/sqrt(w0)) * (1 - rho)
//! c_t       = (b/sqrt(w0)) * (1 + rho)
//! v_tilde_t = (a + b*sigma*sqrt(1-rho^2)) / t
//! ```
//!
//! # JW -> Raw (inverse map)
//!
//! Given `{v_t, psi_t, p_t, c_t, v_tilde_t}` and maturity `t`, with
//! `w = v_t * t`:
//!
//! ```text
//! b     = sqrt(w)*(c_t + p_t)/2
//! rho   = (c_t - p_t)/(c_t + p_t)
//! beta  = rho - 2*psi_t*sqrt(w)/b
//! R     = sqrt(1-rho^2),  B = sqrt(1-beta^2)
//! A     = ((rho-beta)^2 + (R-B)^2)/2
//! r     = (v_t-v_tilde_t)*t/(b*A)
//! m     = beta*r
//! sigma = B*r
//! a     = v_tilde_t*t - b*sigma*sqrt(1-rho^2)
//! ```
//!
//! `B` is real only if `|beta| < 1`; otherwise the JW tuple has no regular raw
//! pre-image and a [`ConvertError`] is returned. When `v_t = v_tilde_t`, the
//! variance minimum is ATM and the positive Raw scale is not identifiable.
//!
//! # References
//!
//! - Gatheral, J. & Jacquier, A., "Arbitrage-free SVI volatility surfaces",
//!   *Quantitative Finance* 14(1):59-71 (2014), Sections 3.2 and 4.

use crate::error::ConvertError;
use crate::smile::jump_wings::SviJw;
use crate::smile::raw::RawSvi;
use crate::surface::ssvi::Ssvi;

/// Converts a raw SVI slice to the Jump-Wings parametrisation at maturity `t`.
///
/// Implements the forward map of MATH.md §4. The result is always a valid
/// Jump-Wings tuple for a valid raw slice and a positive maturity.
///
/// # Errors
///
/// - [`ConvertError::NonPositiveAtmVariance`] if `t <= 0` or the ATM total
///   variance `w0` is not strictly positive (a degenerate slice).
/// - [`ConvertError::Param`] if the resulting tuple fails validation.
///
/// # Examples
///
/// ```
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// use regit_svi::smile::raw::RawSvi;
/// use regit_svi::smile::conversion::raw_to_jw;
///
/// let raw = RawSvi::new(0.04, 0.4, -0.3, 0.05, 0.12)?;
/// let jw = raw_to_jw(&raw, 1.0)?;
/// // v_t is the ATM variance.
/// assert!((jw.atm_variance() - raw.total_variance(0.0)).abs() < 1e-12);
/// # Ok(())
/// # }
/// ```
pub fn raw_to_jw(raw: &RawSvi, t: f64) -> Result<SviJw, ConvertError> {
    if t <= 0.0 || !t.is_finite() {
        return Err(ConvertError::NonPositiveAtmVariance { w: t });
    }
    let w0 = raw.atm_total_variance();
    if w0 <= 0.0 || !w0.is_finite() {
        return Err(ConvertError::NonPositiveAtmVariance { w: w0 });
    }
    let sqrt_w0 = w0.sqrt();
    let root_m = raw.m.hypot(raw.sigma);

    let v_t = w0 / t;
    let psi_t = (raw.b / (2.0 * sqrt_w0)) * (raw.rho - raw.m / root_m);
    let p_t = (raw.b / sqrt_w0) * (1.0 - raw.rho);
    let c_t = (raw.b / sqrt_w0) * (1.0 + raw.rho);
    let v_tilde_t = raw.w_min() / t;

    SviJw::new(v_t, psi_t, p_t, c_t, v_tilde_t, t).map_err(ConvertError::Param)
}

/// Converts a Jump-Wings slice back to the raw SVI parametrisation.
///
/// Implements the inverse map of MATH.md §4, including the `|beta| <= 1`
/// existence check and rejection of non-identifiable flat/ATM-vertex cases.
///
/// # Errors
///
/// - [`ConvertError::NonPositiveAtmVariance`] if `v_t * t` is non-positive or
///   non-finite.
/// - [`ConvertError::DegenerateJw`] if either wing is flat, minimum variance
///   is not strictly below ATM variance, recovered `b` is non-positive or
///   non-finite, or the inverse scale is numerically unidentifiable.
/// - [`ConvertError::JwHasNoRawPreimage`] if recovered `beta` is non-finite or
///   outside the open interval `(-1, 1)`.
/// - [`ConvertError::Param`] if any recovered Raw parameter or derived Raw
///   invariant is invalid or non-finite.
///
/// # Examples
///
/// ```
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// use regit_svi::smile::raw::RawSvi;
/// use regit_svi::smile::conversion::{raw_to_jw, jw_to_raw};
///
/// let raw = RawSvi::new(0.04, 0.4, -0.3, 0.05, 0.12)?;
/// let back = jw_to_raw(&raw_to_jw(&raw, 1.0)?)?;
/// // Raw -> JW -> Raw is the identity.
/// assert!((back.a() - raw.a()).abs() < 1e-9);
/// assert!((back.sigma() - raw.sigma()).abs() < 1e-9);
/// # Ok(())
/// # }
/// ```
// The raw SVI parameters (a, b, m, t, w) keep their canonical single-letter
// names from MATH.md §4; renaming them would obscure the inverse-map formula.
#[allow(clippy::many_single_char_names)]
pub fn jw_to_raw(jw: &SviJw) -> Result<RawSvi, ConvertError> {
    let t = jw.t;
    let w = jw.v_t * t;
    if w <= 0.0 || !w.is_finite() {
        return Err(ConvertError::NonPositiveAtmVariance { w });
    }
    let sqrt_w = w.sqrt();

    if jw.p_t <= 0.0 || jw.c_t <= 0.0 || jw.v_tilde_t >= jw.v_t {
        return Err(ConvertError::DegenerateJw);
    }

    let b = (sqrt_w / 2.0) * (jw.c_t + jw.p_t);
    if b <= 0.0 || !b.is_finite() {
        return Err(ConvertError::DegenerateJw);
    }

    let rho = (jw.c_t - jw.p_t) / (jw.c_t + jw.p_t);

    // beta = rho - 2*psi_t*sqrt(w)/b.
    let beta = rho - 2.0 * jw.psi_t * sqrt_w / b;
    if beta.abs() >= 1.0 || !beta.is_finite() {
        return Err(ConvertError::JwHasNoRawPreimage { beta });
    }

    let r_rho = (1.0 - rho * rho).sqrt();
    let r_beta = (1.0 - beta * beta).sqrt();
    let a_scale = 0.5 * ((rho - beta).powi(2) + (r_rho - r_beta).powi(2));
    let tolerance = 64.0 * f64::EPSILON;
    if a_scale <= tolerance || !a_scale.is_finite() {
        return Err(ConvertError::DegenerateJw);
    }
    let radial = (jw.v_t - jw.v_tilde_t) * t / (b * a_scale);
    let m = beta * radial;
    let sigma = r_beta * radial;
    let a = jw.v_tilde_t * t - b * sigma * r_rho;

    RawSvi::new(a, b, rho, m, sigma).map_err(ConvertError::Param)
}

/// Converts an SSVI surface at fixed `theta` to a raw SVI slice.
///
/// A thin wrapper over [`Ssvi::slice_at`], provided so all conversions are
/// reachable from one module.
///
/// # Errors
///
/// Returns [`ConvertError::Param`] for the same conditions as
/// [`Ssvi::slice_at`].
///
/// # Examples
///
/// ```
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// use regit_svi::surface::ssvi::{Phi, Ssvi};
/// use regit_svi::smile::conversion::ssvi_to_raw;
///
/// let ssvi = Ssvi::new(-0.3, Phi::modified_power_law(0.5, 0.5)?)?;
/// let raw = ssvi_to_raw(&ssvi, 0.04)?;
/// assert!(raw.total_variance(0.0) > 0.0);
/// # Ok(())
/// # }
/// ```
pub fn ssvi_to_raw(ssvi: &Ssvi, theta: f64) -> Result<RawSvi, ConvertError> {
    ssvi.slice_at(theta).map_err(ConvertError::Param)
}

#[cfg(test)]
#[allow(clippy::expect_used)] // Validated fixtures use contextual expectations.
mod tests {
    use super::*;
    use crate::surface::ssvi::Phi;

    /// Asserts two raw slices agree to a tolerance on every parameter.
    fn assert_raw_close(a: &RawSvi, b: &RawSvi, tol: f64) {
        assert!((a.a - b.a).abs() < tol, "a: {} vs {}", a.a, b.a);
        assert!((a.b - b.b).abs() < tol, "b: {} vs {}", a.b, b.b);
        assert!((a.rho - b.rho).abs() < tol, "rho: {} vs {}", a.rho, b.rho);
        assert!((a.m - b.m).abs() < tol, "m: {} vs {}", a.m, b.m);
        assert!(
            (a.sigma - b.sigma).abs() < tol,
            "sigma: {} vs {}",
            a.sigma,
            b.sigma
        );
    }

    #[test]
    fn raw_jw_roundtrip_identity() {
        let raw =
            RawSvi::new(0.04, 0.4, -0.3, 0.05, 0.12).expect("valid test or documentation fixture");
        let jw = raw_to_jw(&raw, 1.0).expect("valid test or documentation fixture");
        let back = jw_to_raw(&jw).expect("valid test or documentation fixture");
        assert_raw_close(&raw, &back, 1e-9);
    }

    #[test]
    fn raw_jw_roundtrip_positive_rho() {
        let raw =
            RawSvi::new(0.05, 0.3, 0.4, -0.1, 0.2).expect("valid test or documentation fixture");
        let jw = raw_to_jw(&raw, 2.0).expect("valid test or documentation fixture");
        let back = jw_to_raw(&jw).expect("valid test or documentation fixture");
        assert_raw_close(&raw, &back, 1e-8);
    }

    #[test]
    fn raw_jw_roundtrip_various_maturities() {
        for &t in &[0.25, 0.5, 1.0, 2.0, 5.0] {
            let raw = RawSvi::new(0.03, 0.35, -0.2, 0.02, 0.15)
                .expect("valid test or documentation fixture");
            let jw = raw_to_jw(&raw, t).expect("valid test or documentation fixture");
            let back = jw_to_raw(&jw).expect("valid test or documentation fixture");
            assert_raw_close(&raw, &back, 1e-8);
        }
    }

    #[test]
    fn raw_to_jw_v_t_is_atm_variance() {
        let raw =
            RawSvi::new(0.04, 0.4, -0.3, 0.05, 0.12).expect("valid test or documentation fixture");
        let jw = raw_to_jw(&raw, 1.0).expect("valid test or documentation fixture");
        assert!((jw.v_t * jw.t - raw.atm_total_variance()).abs() < 1e-12);
        assert!((jw.v_tilde_t * jw.t - raw.w_min()).abs() < 1e-12);
    }

    #[test]
    fn raw_to_jw_rejects_bad_maturity() {
        let raw =
            RawSvi::new(0.04, 0.4, -0.3, 0.05, 0.12).expect("valid test or documentation fixture");
        assert!(raw_to_jw(&raw, 0.0).is_err());
    }

    #[test]
    fn jw_to_raw_handles_m_zero_branch() {
        // A raw slice with m = 0 exactly (and rho != 0) is the genuine m = 0
        // degenerate branch (beta = m/sqrt(m^2+sigma^2) = 0). The inverse map
        // recovers it through the dedicated sigma formula.
        let raw =
            RawSvi::new(0.04, 0.4, -0.3, 0.0, 0.15).expect("valid test or documentation fixture");
        let jw = raw_to_jw(&raw, 1.0).expect("valid test or documentation fixture");
        assert!(
            jw.psi_t.abs() > 1e-9,
            "m = 0 with rho != 0 has non-zero skew"
        );
        let back = jw_to_raw(&jw).expect("valid test or documentation fixture");
        assert!(
            back.m.abs() < 1e-9,
            "recovered m should be 0, got {}",
            back.m
        );
        for &k in &[-0.3, 0.0, 0.3] {
            assert!(
                (back.total_variance(k) - raw.total_variance(k)).abs() < 1e-8,
                "k = {k}"
            );
        }
    }

    #[test]
    fn jw_to_raw_rejects_ambiguous_vertex_at_atm() {
        // A raw slice whose vertex sits at k = 0 with m != 0 produces a
        // Jump-Wings tuple that maps a one-parameter family of raw slices,
        // so the inverse map must report it as ambiguous.
        let rho = -0.3_f64;
        let sigma = 0.15_f64;
        let m = rho * sigma / (1.0 - rho * rho).sqrt();
        let raw =
            RawSvi::new(0.04, 0.4, rho, m, sigma).expect("valid test or documentation fixture");
        let jw = raw_to_jw(&raw, 1.0).expect("valid test or documentation fixture");
        assert!((jw.v_t - jw.v_tilde_t).abs() < 1e-12);
        assert!(matches!(jw_to_raw(&jw), Err(ConvertError::DegenerateJw)));
    }

    #[test]
    fn jw_to_raw_rejects_no_preimage() {
        // psi_t too large -> |beta| > 1.
        let jw = SviJw::new_unchecked(0.04, 5.0, 0.3, 0.25, 0.035, 1.0);
        assert!(matches!(
            jw_to_raw(&jw),
            Err(ConvertError::JwHasNoRawPreimage { .. })
        ));
    }

    #[test]
    fn jw_to_raw_rejects_zero_b() {
        // c_t = p_t = 0 -> b = 0.
        let jw = SviJw::new_unchecked(0.04, 0.0, 0.0, 0.0, 0.04, 1.0);
        assert!(matches!(jw_to_raw(&jw), Err(ConvertError::DegenerateJw)));
    }

    #[test]
    fn ssvi_to_raw_matches_slice_at() {
        let ssvi = Ssvi::new(
            -0.3,
            Phi::modified_power_law(0.5, 0.5).expect("valid test or documentation fixture"),
        )
        .expect("valid test or documentation fixture");
        let raw = ssvi_to_raw(&ssvi, 0.04).expect("valid test or documentation fixture");
        let direct = ssvi
            .slice_at(0.04)
            .expect("valid test or documentation fixture");
        assert_raw_close(&raw, &direct, 1e-15);
    }

    #[test]
    fn ssvi_to_raw_rejects_bad_theta() {
        let ssvi = Ssvi::new(
            -0.3,
            Phi::heston(1.0).expect("valid test or documentation fixture"),
        )
        .expect("valid test or documentation fixture");
        assert!(ssvi_to_raw(&ssvi, 0.0).is_err());
    }

    #[test]
    fn jw_roundtrip_total_variance_preserved() {
        // The strongest invariant: w(k) is unchanged by Raw -> JW -> Raw.
        let raw = RawSvi::new(0.045, 0.38, -0.25, 0.03, 0.14)
            .expect("valid test or documentation fixture");
        let back = jw_to_raw(&raw_to_jw(&raw, 1.5).expect("valid test or documentation fixture"))
            .expect("valid test or documentation fixture");
        for &k in &[-1.0, -0.4, 0.0, 0.4, 1.0] {
            assert!(
                (back.total_variance(k) - raw.total_variance(k)).abs() < 1e-9,
                "k = {k}"
            );
        }
    }
}