use crate::error::ParamError;
use crate::no_arb::calendar::calendar_scan;
use crate::no_arb::evidence::{
ArbitrageAssessment, ArbitrageEvidence, ArbitrageStatus, ScanEvidence,
};
use crate::smile::raw::RawSvi;
use crate::surface::ssvi::Ssvi;
use crate::surface::term_structure::TermStructure;
#[derive(Debug, Clone, PartialEq)]
enum Backing {
Slices(Vec<(f64, RawSvi)>),
Ssvi {
ssvi: Ssvi,
term: TermStructure,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct Surface {
backing: Backing,
}
impl Surface {
pub fn from_slices(slices: Vec<(f64, RawSvi)>) -> Result<Self, ParamError> {
if slices.is_empty() {
return Err(ParamError::NonPositiveMaturity { t: 0.0 });
}
for &(t, slice) in &slices {
if !t.is_finite() {
return Err(ParamError::NonFinite { name: "maturity" });
}
if t <= 0.0 {
return Err(ParamError::NonPositiveMaturity { t });
}
if !slice.atm_total_variance().is_finite() {
return Err(ParamError::NonFinite {
name: "slice ATM total variance",
});
}
}
for (index, pair) in slices.windows(2).enumerate() {
if pair[1].0 <= pair[0].0 {
return Err(ParamError::NotStrictlyIncreasing {
name: "maturity",
index: index + 1,
previous: pair[0].0,
value: pair[1].0,
});
}
let previous = pair[0].1.atm_total_variance();
let value = pair[1].1.atm_total_variance();
if value < previous {
return Err(ParamError::DecreasingAtmVariance {
index: index + 1,
previous,
value,
});
}
}
Ok(Self {
backing: Backing::Slices(slices),
})
}
pub fn from_ssvi<T>(ssvi: Ssvi, term: T) -> Result<Self, ParamError>
where
T: TryInto<TermStructure>,
ParamError: From<T::Error>,
{
let term = term.try_into().map_err(ParamError::from)?;
for &(_, theta) in term.knots() {
ssvi.slice_at(theta.get())?;
}
Ok(Self {
backing: Backing::Ssvi { ssvi, term },
})
}
#[must_use]
pub fn len(&self) -> usize {
match &self.backing {
Backing::Slices(s) => s.len(),
Backing::Ssvi { term, .. } => term.len(),
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn total_variance(&self, k: f64, t: f64) -> Result<f64, ParamError> {
if !k.is_finite() {
return Err(ParamError::NonFinite {
name: "log_moneyness",
});
}
if t <= 0.0 || !t.is_finite() {
return Err(ParamError::NonPositiveMaturity { t });
}
let value = match &self.backing {
Backing::Slices(slices) => Self::total_variance_slices(slices, k, t),
Backing::Ssvi { ssvi, term } => {
let theta = interpolate_theta(term.knots(), t);
ssvi.total_variance(k, theta)
}
};
if value.is_finite() {
Ok(value)
} else {
Err(ParamError::NonFinite {
name: "total_variance",
})
}
}
pub fn implied_vol(&self, k: f64, t: f64) -> Result<f64, ParamError> {
if t <= 0.0 || !t.is_finite() {
return Err(ParamError::NonPositiveMaturity { t });
}
let volatility = (self.total_variance(k, t)? / t).sqrt();
if volatility.is_finite() {
Ok(volatility)
} else {
Err(ParamError::NonFinite {
name: "implied_volatility",
})
}
}
pub fn calendar_assessment(
&self,
k_lo: f64,
k_hi: f64,
) -> Result<ArbitrageAssessment, crate::DiagnosticError> {
if !k_lo.is_finite() || !k_hi.is_finite() {
return Err(crate::DiagnosticError::NonFiniteBound);
}
if k_lo >= k_hi {
return Err(crate::DiagnosticError::InvalidOrder);
}
match &self.backing {
Backing::Slices(slices) => {
if slices.len() == 1 {
return Ok(ArbitrageAssessment::new(
ArbitrageStatus::NoViolationDetected,
ArbitrageEvidence::AnalyticNecessaryAndSufficient {
theorem: "single-maturity calendar ordering is vacuous",
boundary_tolerance: 0.0,
},
f64::INFINITY,
None,
));
}
let mut selected_minimum = f64::INFINITY;
let mut witness = None;
let mut scan_evidence = None;
let mut total_samples = 0_usize;
let mut selected_scan = 0_usize;
let mut selected_priority = 0_u8;
let mut selected_score = f64::INFINITY;
let mut violation_observed = false;
let mut boundary_unresolved = false;
for (pair_index, pair) in slices.windows(2).enumerate() {
let diagnostic = calendar_scan(&pair[0].1, &pair[1].1, k_lo, k_hi)?;
total_samples =
total_samples.saturating_add(diagnostic.evidence().samples_evaluated());
violation_observed |= diagnostic.violation_observed();
boundary_unresolved |=
diagnostic.min_difference() <= diagnostic.evidence().config().tolerance();
let tolerance = diagnostic.evidence().config().tolerance();
let (priority, score) = calendar_scan_rank(
diagnostic.violation_observed(),
diagnostic.min_difference(),
tolerance,
);
if scan_evidence.is_none()
|| priority > selected_priority
|| (priority == selected_priority && score < selected_score)
{
selected_priority = priority;
selected_score = score;
selected_minimum = diagnostic.min_difference();
witness = Some(diagnostic.worst_k());
scan_evidence = Some(diagnostic.evidence());
selected_scan = pair_index;
}
}
let evidence = scan_evidence.map_or_else(
|| ArbitrageEvidence::AnalyticNecessaryAndSufficient {
theorem: "single-maturity calendar ordering is vacuous",
boundary_tolerance: 0.0,
},
|scan| {
ArbitrageEvidence::NumericalScan(ScanEvidence::aggregate_selected(
scan,
slices.len().saturating_sub(1),
total_samples,
selected_scan,
))
},
);
let status = if violation_observed {
ArbitrageStatus::ViolationDetected
} else if boundary_unresolved {
ArbitrageStatus::Indeterminate
} else {
ArbitrageStatus::NoViolationDetected
};
Ok(ArbitrageAssessment::new(
status,
evidence,
selected_minimum,
witness,
))
}
Backing::Ssvi { ssvi, term } => {
let thetas: Vec<f64> = term.knots().iter().map(|&(_, theta)| theta.get()).collect();
Ok(ssvi.calendar_assessment(&thetas))
}
}
}
fn total_variance_slices(slices: &[(f64, RawSvi)], k: f64, t: f64) -> f64 {
let n = slices.len();
let (t0, first) = slices[0];
let (tn, last) = slices[n - 1];
if t <= t0 {
return (first.total_variance(k) / t0) * t.max(0.0);
}
if t >= tn {
return (last.total_variance(k) / tn) * t;
}
for pair in slices.windows(2) {
let (tj, sj) = pair[0];
let (tj1, sj1) = pair[1];
if t >= tj && t <= tj1 {
let wj = sj.total_variance(k);
let wj1 = sj1.total_variance(k);
let frac = (t - tj) / (tj1 - tj);
return wj + frac * (wj1 - wj);
}
}
last.total_variance(k)
}
}
fn calendar_scan_rank(violation: bool, minimum: f64, tolerance: f64) -> (u8, f64) {
let priority = if violation {
2
} else {
u8::from(minimum <= tolerance)
};
(priority, minimum / tolerance)
}
fn interpolate_theta(
term: &[(
crate::market::units::Maturity,
crate::market::units::TotalVariance,
)],
t: f64,
) -> f64 {
let n = term.len();
let (t_first, theta_first) = (term[0].0.get(), term[0].1.get());
let (t_last, theta_last) = (term[n - 1].0.get(), term[n - 1].1.get());
if t <= t_first {
return (theta_first / t_first) * t.max(0.0);
}
if t >= t_last {
return (theta_last / t_last) * t;
}
for pair in term.windows(2) {
let (t_lo, theta_lo) = (pair[0].0.get(), pair[0].1.get());
let (t_hi, theta_hi) = (pair[1].0.get(), pair[1].1.get());
if t >= t_lo && t <= t_hi {
let frac = (t - t_lo) / (t_hi - t_lo);
return theta_lo + frac * (theta_hi - theta_lo);
}
}
theta_last
}
#[cfg(test)]
#[allow(clippy::expect_used)] mod tests {
use super::*;
use crate::surface::ssvi::Phi;
#[test]
fn from_slices_requires_explicit_order() {
let s =
RawSvi::new(0.04, 0.3, -0.2, 0.0, 0.1).expect("valid test or documentation fixture");
assert!(Surface::from_slices(vec![(2.0, s), (0.5, s), (1.0, s)]).is_err());
let surface = Surface::from_slices(vec![(0.5, s), (1.0, s), (2.0, s)])
.expect("valid test or documentation fixture");
assert_eq!(surface.len(), 3);
}
#[test]
fn from_slices_rejects_bad_maturity() {
let s =
RawSvi::new(0.04, 0.3, -0.2, 0.0, 0.1).expect("valid test or documentation fixture");
assert!(Surface::from_slices(vec![(0.0, s)]).is_err());
assert!(Surface::from_slices(vec![(1.0, s), (1.0, s)]).is_err());
assert!(Surface::from_slices(vec![]).is_err());
}
#[test]
fn from_slices_rejects_non_finite_atm_evaluation_defensively() {
let overflow = RawSvi::new_unchecked(0.0, f64::MAX, 0.0, -f64::MAX, f64::MIN_POSITIVE);
assert!(matches!(
Surface::from_slices(vec![(1.0, overflow)]),
Err(ParamError::NonFinite {
name: "slice ATM total variance"
})
));
}
#[test]
fn interpolation_is_linear_in_w() {
let s1 =
RawSvi::new(0.02, 0.3, -0.2, 0.0, 0.1).expect("valid test or documentation fixture");
let s2 =
RawSvi::new(0.06, 0.3, -0.2, 0.0, 0.1).expect("valid test or documentation fixture");
let surface = Surface::from_slices(vec![(1.0, s1), (3.0, s2)])
.expect("valid test or documentation fixture");
for &k in &[-0.3, 0.0, 0.3] {
let mid = surface
.total_variance(k, 2.0)
.expect("positive finite maturity");
let expect = 0.5 * (s1.total_variance(k) + s2.total_variance(k));
assert!((mid - expect).abs() < 1e-12, "k = {k}");
}
}
#[test]
fn interpolation_recovers_knot_values() {
let s1 =
RawSvi::new(0.02, 0.3, -0.2, 0.0, 0.1).expect("valid test or documentation fixture");
let s2 =
RawSvi::new(0.06, 0.3, -0.2, 0.0, 0.1).expect("valid test or documentation fixture");
let surface = Surface::from_slices(vec![(1.0, s1), (3.0, s2)])
.expect("valid test or documentation fixture");
assert!(
(surface
.total_variance(0.1, 1.0)
.expect("positive finite maturity")
- s1.total_variance(0.1))
.abs()
< 1e-12
);
assert!(
(surface
.total_variance(0.1, 3.0)
.expect("positive finite maturity")
- s2.total_variance(0.1))
.abs()
< 1e-12
);
}
#[test]
fn extrapolation_is_flat_in_vol() {
let s =
RawSvi::new(0.04, 0.3, -0.2, 0.0, 0.1).expect("valid test or documentation fixture");
let surface =
Surface::from_slices(vec![(1.0, s)]).expect("valid test or documentation fixture");
let w_half = surface
.total_variance(0.0, 0.5)
.expect("positive finite maturity");
assert!((w_half - s.total_variance(0.0) * 0.5).abs() < 1e-12);
let w_double = surface
.total_variance(0.0, 2.0)
.expect("positive finite maturity");
assert!((w_double - s.total_variance(0.0) * 2.0).abs() < 1e-12);
let v05 = surface
.implied_vol(0.0, 0.5)
.expect("valid test or documentation fixture");
let v20 = surface
.implied_vol(0.0, 2.0)
.expect("valid test or documentation fixture");
assert!((v05 - v20).abs() < 1e-12);
}
#[test]
fn implied_vol_rejects_bad_maturity() {
let s =
RawSvi::new(0.04, 0.3, -0.2, 0.0, 0.1).expect("valid test or documentation fixture");
let surface =
Surface::from_slices(vec![(1.0, s)]).expect("valid test or documentation fixture");
assert!(surface.implied_vol(0.0, 0.0).is_err());
assert!(surface.total_variance(0.0, -1.0).is_err());
assert!(surface.total_variance(0.0, f64::NAN).is_err());
assert!(surface.total_variance(f64::NAN, 1.0).is_err());
assert!(match surface.implied_vol(0.0, f64::MIN_POSITIVE) {
Ok(value) => value.is_finite(),
Err(_) => true,
});
}
#[test]
fn slice_surface_calendar_check() {
let early =
RawSvi::new(0.02, 0.3, -0.2, 0.0, 0.1).expect("valid test or documentation fixture");
let late =
RawSvi::new(0.06, 0.3, -0.2, 0.0, 0.1).expect("valid test or documentation fixture");
let ok = Surface::from_slices(vec![(0.5, early), (1.5, late)])
.expect("valid test or documentation fixture");
assert!(
ok.calendar_assessment(-0.5, 0.5)
.expect("finite diagnostic bounds")
.status()
== ArbitrageStatus::NoViolationDetected
);
assert!(Surface::from_slices(vec![(0.5, late), (1.5, early)]).is_err());
}
#[test]
fn single_slice_calendar_assessment_validates_declared_bounds() {
let slice = RawSvi::new(0.04, 0.1, 0.0, 0.0, 0.2).expect("valid fixture");
let surface = Surface::from_slices(vec![(1.0, slice)]).expect("valid surface");
assert_eq!(
surface.calendar_assessment(f64::NAN, 1.0),
Err(crate::DiagnosticError::NonFiniteBound)
);
assert_eq!(
surface.calendar_assessment(1.0, -1.0),
Err(crate::DiagnosticError::InvalidOrder)
);
}
#[test]
fn roundoff_scale_calendar_crossing_is_indeterminate() {
let slope = f64::EPSILON;
let early = RawSvi::new(0.04 - slope * 0.1, slope, 0.0, 0.0, 0.1)
.expect("valid almost-flat early slice");
let late = RawSvi::new(0.04, 0.0, 0.0, 0.0, 0.1).expect("valid flat late slice");
let surface = Surface::from_slices(vec![(1.0, early), (2.0, late)])
.expect("equal finite ATM anchors");
assert_eq!(
surface
.calendar_assessment(-1.0, 1.0)
.expect("valid bounded diagnostic")
.status(),
ArbitrageStatus::Indeterminate
);
}
#[test]
fn raw_surface_calendar_scan_is_explicitly_bounded() {
let early =
RawSvi::new(0.02, 0.5, 0.0, 0.0, 0.1).expect("valid hidden-wing-crossing fixture");
let late =
RawSvi::new(1.0, 0.1, 0.0, 0.0, 0.1).expect("valid hidden-wing-crossing fixture");
let surface = Surface::from_slices(vec![(0.5, early), (1.5, late)])
.expect("ATM-ordered maturity knots");
assert!(
surface
.calendar_assessment(-1.0, 1.0)
.expect("finite diagnostic bounds")
.status()
== ArbitrageStatus::NoViolationDetected
);
assert!(
surface
.calendar_assessment(-3.0, 3.0)
.expect("finite diagnostic bounds")
.status()
== ArbitrageStatus::ViolationDetected
);
}
#[test]
fn aggregated_calendar_evidence_separates_selected_and_total_scope() {
let first = RawSvi::new(0.02, 0.1, 0.0, 0.0, 0.2).expect("valid fixture");
let second = RawSvi::new(0.05, 0.1, 0.0, 0.0, 0.2).expect("valid fixture");
let third = RawSvi::new(0.09, 0.1, 0.0, 0.0, 0.2).expect("valid fixture");
let surface = Surface::from_slices(vec![(0.5, first), (1.0, second), (2.0, third)])
.expect("ordered surface");
let assessment = surface
.calendar_assessment(-1.0, 1.0)
.expect("finite diagnostic");
let evidence = assessment.evidence();
assert!(matches!(evidence, ArbitrageEvidence::NumericalScan(_)));
let ArbitrageEvidence::NumericalScan(evidence) = evidence else {
return;
};
assert_eq!(evidence.scan_count(), 2);
assert_eq!(evidence.samples_evaluated(), 401);
assert_eq!(evidence.total_samples_evaluated(), 802);
assert!(evidence.selected_scan() < evidence.scan_count());
}
#[test]
fn aggregate_selects_material_violation_over_more_negative_unresolved_pair() {
let sigma = 0.1_f64;
let small_slope = 1e-8_f64;
let first = RawSvi::new(
small_slope.mul_add(-sigma, 1.0),
small_slope,
0.0,
0.0,
sigma,
)
.expect("small-scale curved fixture");
let second = RawSvi::new(1.0, 0.0, 0.0, 0.0, sigma).expect("small-scale flat fixture");
let large_level = 1e8_f64;
let large_slope = 5e-8_f64;
let third = RawSvi::new(
large_slope.mul_add(-sigma, large_level),
large_slope,
0.0,
0.0,
sigma,
)
.expect("large-scale curved fixture");
let fourth =
RawSvi::new(large_level, 0.0, 0.0, 0.0, sigma).expect("large-scale flat fixture");
let surface = Surface::from_slices(vec![
(0.5, first),
(1.0, second),
(1.5, third),
(2.0, fourth),
])
.expect("ATM-ordered scale-separated surface");
let assessment = surface
.calendar_assessment(-1.0, 1.0)
.expect("finite diagnostic");
assert_eq!(assessment.status(), ArbitrageStatus::ViolationDetected);
let evidence = assessment.evidence();
assert!(matches!(evidence, ArbitrageEvidence::NumericalScan(_)));
let ArbitrageEvidence::NumericalScan(evidence) = evidence else {
return;
};
assert_eq!(evidence.scan_count(), 3);
assert_eq!(evidence.selected_scan(), 0);
assert!(assessment.margin() < -evidence.config().tolerance());
assert!(evidence.refinement_attempted());
}
#[test]
fn ssvi_surface_evaluates_and_interpolates() {
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 surface = Surface::from_ssvi(ssvi, vec![(0.5, 0.02), (1.0, 0.04), (2.0, 0.08)])
.expect("valid test or documentation fixture");
let w_knot = surface
.total_variance(0.1, 1.0)
.expect("positive finite maturity");
assert!((w_knot - ssvi.total_variance(0.1, 0.04)).abs() < 1e-12);
let w_mid = surface
.total_variance(0.0, 1.5)
.expect("positive finite maturity");
assert!(w_mid > ssvi.total_variance(0.0, 0.04));
assert!(w_mid < ssvi.total_variance(0.0, 0.08));
}
#[test]
fn from_ssvi_rejects_non_finite_slice_mapping() {
let ssvi = Ssvi::new(
0.0,
Phi::heston(f64::MAX).expect("finite positive phi parameter"),
)
.expect("valid SSVI correlation");
assert!(matches!(
Surface::from_ssvi(ssvi, vec![(1.0, f64::MAX)]),
Err(ParamError::InvalidPhiParameter {
name: "phi(theta)",
..
})
));
}
#[test]
fn ssvi_surface_calendar_check() {
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 surface = Surface::from_ssvi(ssvi, vec![(0.5, 0.02), (1.0, 0.04), (2.0, 0.08)])
.expect("valid test or documentation fixture");
assert!(
surface
.calendar_assessment(-0.5, 0.5)
.expect("finite diagnostic bounds")
.status()
== ArbitrageStatus::NoViolationDetected
);
assert_eq!(
surface.calendar_assessment(f64::NAN, 1.0),
Err(crate::DiagnosticError::NonFiniteBound)
);
assert_eq!(
surface.calendar_assessment(1.0, 1.0),
Err(crate::DiagnosticError::InvalidOrder)
);
}
#[test]
fn ssvi_surface_rejects_bad_term() {
let ssvi = Ssvi::new(
-0.3,
Phi::heston(1.0).expect("valid test or documentation fixture"),
)
.expect("valid test or documentation fixture");
assert!(Surface::from_ssvi(ssvi, vec![]).is_err());
assert!(Surface::from_ssvi(ssvi, vec![(1.0, 0.0)]).is_err());
assert!(Surface::from_ssvi(ssvi, vec![(0.0, 0.04)]).is_err());
}
#[test]
fn is_empty_is_false_for_built_surface() {
let s =
RawSvi::new(0.04, 0.3, -0.2, 0.0, 0.1).expect("valid test or documentation fixture");
let surface =
Surface::from_slices(vec![(1.0, s)]).expect("valid test or documentation fixture");
assert!(!surface.is_empty());
}
}