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

//! Bounded calendar-spread arbitrage diagnostics for pairs of Raw SVI slices.

use crate::no_arb::evidence::{
    DiagnosticError, RootEvidence, RootTermination, ScanConfig, ScanEvidence,
};
use crate::numerics::{brent_root_with_evidence, index_to_f64};
use crate::smile::raw::RawSvi;

/// Number of points in the deterministic diagnostic grid.
const SCAN_POINTS: usize = 401;
/// Extra log-moneyness added beyond each caller-supplied bound.
const SCAN_MARGIN: f64 = 1.0;
/// Absolute tolerance used to refine a bracketed crossing.
const REFINE_TOL: f64 = 1e-10;
/// Iteration budget for crossing refinement.
const REFINE_MAX_ITER: usize = 200;

/// The result of a bounded calendar-spread-arbitrage scan.
///
/// A clean diagnostic says only that no crossing was observed on its reported
/// grid. It is not evidence of ordering over all log-moneyness.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CalendarDiagnostic {
    violation_observed: bool,
    min_difference: f64,
    worst_k: f64,
    refined_crossing: Option<f64>,
    evidence: ScanEvidence,
}

impl CalendarDiagnostic {
    /// Returns whether a later-minus-earlier variance below the recorded
    /// negative tolerance was sampled.
    #[must_use]
    pub const fn violation_observed(self) -> bool {
        self.violation_observed
    }

    /// Returns the minimum sampled later-minus-earlier variance.
    #[must_use]
    pub const fn min_difference(self) -> f64 {
        self.min_difference
    }

    /// Returns the worst sampled log-moneyness.
    #[must_use]
    pub const fn worst_k(self) -> f64 {
        self.worst_k
    }

    /// Returns a nearby refined zero crossing when one was bracketed.
    #[must_use]
    pub const fn refined_crossing(self) -> Option<f64> {
        self.refined_crossing
    }

    /// Returns the bounded numerical evidence.
    #[must_use]
    pub const fn evidence(self) -> ScanEvidence {
        self.evidence
    }
}

/// Scans two Raw SVI slices for calendar-spread arbitrage.
///
/// `early` is the shorter-dated slice and `late` the longer-dated one. The
/// difference `D(k) = w_late(k) - w_early(k)` is sampled on the caller's range
/// plus a fixed margin. A sample below the scale-aware negative tolerance in
/// the evidence is a conclusive violation witness; values in the tolerance
/// band are unresolved. A clean bounded scan is not a global ordering proof.
///
/// # Examples
///
/// ```
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// use regit_svi::no_arb::calendar::calendar_scan;
/// use regit_svi::smile::raw::RawSvi;
///
/// let early = RawSvi::new(0.04, 0.3, -0.2, 0.0, 0.1)
///     ?;
/// let late = RawSvi::new(0.08, 0.3, -0.2, 0.0, 0.1)
///     ?;
/// let report = calendar_scan(&early, &late, -0.5, 0.5)
///     ?;
/// assert!(!report.violation_observed());
/// # Ok(())
/// # }
/// ```
///
/// # Errors
///
/// Returns [`DiagnosticError`] if either bound is non-finite, `k_lo >= k_hi`,
/// expanding the interval overflows, or a variance difference is non-finite.
pub fn calendar_scan(
    early: &RawSvi,
    late: &RawSvi,
    k_lo: f64,
    k_hi: f64,
) -> Result<CalendarDiagnostic, DiagnosticError> {
    if !k_lo.is_finite() || !k_hi.is_finite() {
        return Err(DiagnosticError::NonFiniteBound);
    }
    if k_lo >= k_hi {
        return Err(DiagnosticError::InvalidOrder);
    }
    let lo = k_lo - SCAN_MARGIN;
    let hi = k_hi + SCAN_MARGIN;
    if ScanConfig::new(lo, hi, SCAN_POINTS, 0.0).is_none() {
        return Err(DiagnosticError::DomainOverflow);
    }
    let step = (hi - lo) / index_to_f64(SCAN_POINTS - 1);
    let difference = |k: f64| late.total_variance(k) - early.total_variance(k);

    let mut min_difference = f64::INFINITY;
    let mut max_variance_scale = 1.0_f64;
    let mut worst_k = lo;
    let mut samples = Vec::with_capacity(SCAN_POINTS);
    for index in 0..SCAN_POINTS {
        let k = step.mul_add(index_to_f64(index), lo);
        let early_variance = early.total_variance(k);
        let late_variance = late.total_variance(k);
        let value = late_variance - early_variance;
        if !value.is_finite() {
            return Err(DiagnosticError::NonFiniteEvaluation);
        }
        max_variance_scale = max_variance_scale
            .max(early_variance.abs())
            .max(late_variance.abs());
        if value < min_difference {
            min_difference = value;
            worst_k = k;
        }
        samples.push((k, value));
    }

    let evaluation_tolerance = 128.0 * f64::EPSILON * max_variance_scale;
    let Some(config) = ScanConfig::new(lo, hi, SCAN_POINTS, evaluation_tolerance) else {
        return Err(DiagnosticError::DomainOverflow);
    };
    let violation_observed = min_difference < -evaluation_tolerance;
    let refinement_bracket = violation_observed
        .then(|| {
            samples
                .windows(2)
                .filter(|pair| {
                    pair[0].1 == 0.0
                        || pair[1].1 == 0.0
                        || pair[0].1.is_sign_negative() != pair[1].1.is_sign_negative()
                })
                .min_by(|left, right| {
                    let left_distance = (0.5 * (left[0].0 + left[1].0) - worst_k).abs();
                    let right_distance = (0.5 * (right[0].0 + right[1].0) - worst_k).abs();
                    left_distance.total_cmp(&right_distance)
                })
                .map(|pair| (pair[0].0, pair[1].0))
        })
        .flatten();
    let refinement = refinement_bracket.and_then(|(lower, upper)| {
        brent_root_with_evidence(difference, lower, upper, REFINE_TOL, REFINE_MAX_ITER)
    });
    let refined_crossing = refinement.map(crate::numerics::BrentRoot::root);
    let root_evidence = refinement.map(|root| {
        RootEvidence::new(
            root.root(),
            root.lower(),
            root.upper(),
            root.residual(),
            root.evaluations(),
            if root.exact() {
                RootTermination::ExactRoot
            } else {
                RootTermination::BracketTolerance
            },
        )
    });

    Ok(CalendarDiagnostic {
        violation_observed,
        min_difference,
        worst_k,
        refined_crossing,
        evidence: ScanEvidence::new(
            config,
            (k_lo, k_hi),
            SCAN_POINTS,
            SCAN_POINTS,
            refinement_bracket.is_some(),
            root_evidence,
        ),
    })
}

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

    #[test]
    fn ordered_slices_have_no_bounded_witness() {
        let early =
            RawSvi::new(0.04, 0.3, -0.2, 0.0, 0.1).expect("valid test or documentation fixture");
        let late =
            RawSvi::new(0.08, 0.3, -0.2, 0.0, 0.1).expect("valid test or documentation fixture");
        let report = calendar_scan(&early, &late, -0.5, 0.5).expect("finite diagnostic bounds");
        assert!(!report.violation_observed());
        assert!(report.min_difference() > 0.0);
    }

    #[test]
    fn rejects_unordered_bounds() {
        let early = RawSvi::new(0.04, 0.3, -0.2, 0.0, 0.1).expect("valid test fixture");
        let late = RawSvi::new(0.08, 0.3, -0.2, 0.0, 0.1).expect("valid test fixture");
        assert_eq!(
            calendar_scan(&early, &late, 1.0, -1.0),
            Err(DiagnosticError::InvalidOrder)
        );
        assert_eq!(
            calendar_scan(&early, &late, 0.0, 0.0),
            Err(DiagnosticError::InvalidOrder)
        );
    }

    #[test]
    fn crossing_slices_produce_a_witness() {
        let early =
            RawSvi::new(0.08, 0.3, -0.2, 0.0, 0.1).expect("valid test or documentation fixture");
        let late =
            RawSvi::new(0.04, 0.3, -0.2, 0.0, 0.1).expect("valid test or documentation fixture");
        let report = calendar_scan(&early, &late, -0.5, 0.5).expect("finite diagnostic bounds");
        assert!(report.violation_observed());
        assert!(report.min_difference() < 0.0);
    }

    #[test]
    fn conclusion_is_limited_to_the_observed_domain() {
        let early =
            RawSvi::new(0.02, 0.5, 0.0, 0.0, 0.1).expect("valid test or documentation fixture");
        let late =
            RawSvi::new(1.0, 0.1, 0.0, 0.0, 0.1).expect("valid test or documentation fixture");
        assert!(
            !calendar_scan(&early, &late, -1.0, 1.0)
                .expect("finite diagnostic bounds")
                .violation_observed()
        );
        assert!(
            calendar_scan(&early, &late, -3.0, 3.0)
                .expect("finite diagnostic bounds")
                .violation_observed()
        );
        assert!(
            calendar_scan(&early, &late, -3.0, 3.0)
                .expect("finite diagnostic bounds")
                .refined_crossing()
                .is_some()
        );
        let evidence = calendar_scan(&early, &late, -3.0, 3.0)
            .expect("finite diagnostic bounds")
            .evidence();
        assert_eq!(evidence.requested_domain(), (-3.0, 3.0));
        assert!(evidence.config().lower() < -3.0);
        assert!(evidence.refinement_attempted());
        assert!(evidence.refinement().is_some());
    }
}