regit_svi/density.rs
1// Copyright 2026 Regit.io — Nicolas Koenig
2// SPDX-License-Identifier: Apache-2.0
3
4//! Risk-neutral density implied by a raw SVI slice.
5//!
6//! The butterfly function `g(k)` (see [`crate::no_arb::butterfly`]) is the
7//! sign-controlling factor in the risk-neutral density, not the density
8//! itself. With
9//!
10//! ```text
11//! d_minus(k) = -k/sqrt(w(k)) - sqrt(w(k))/2
12//! d_plus(k) = -k/sqrt(w(k)) + sqrt(w(k))/2
13//! ```
14//!
15//! the risk-neutral density of the log-strike is
16//!
17//! ```text
18//! p(k) = g(k) / sqrt(2*pi*w(k)) * exp(-d_minus(k)^2 / 2)
19//! ```
20//!
21//! On a regular positive slice, `p(k) >= 0` for all `k` is equivalent to
22//! `g(k) >= 0`. Unit continuous mass additionally requires the left-tail
23//! boundary that excludes an atom at zero. [`integral`] is a bounded numerical
24//! diagnostic for that continuous mass.
25//!
26//! # References
27//!
28//! - Breeden, D. & Litzenberger, R., "Prices of state-contingent claims
29//! implicit in option prices", *Journal of Business* 51(4):621-651 (1978).
30//! - Gatheral, J. & Jacquier, A., "Arbitrage-free SVI volatility surfaces",
31//! *Quantitative Finance* 14(1):59-71 (2014), eq. (2.2).
32
33use crate::no_arb::butterfly::{g, g_with_scale};
34use crate::numerics::index_to_f64;
35use crate::smile::raw::RawSvi;
36use core::fmt;
37
38/// `2*pi` — normalising constant for the density.
39const TWO_PI: f64 = std::f64::consts::TAU;
40
41/// The Black `d_minus` quantity: `d_minus(k) = -k/sqrt(w) - sqrt(w)/2`.
42///
43/// This infallible kernel assumes finite `k` and positive finite `w(k)` and
44/// propagates IEEE non-finite results when those preconditions fail.
45///
46/// # Examples
47///
48/// ```
49/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
50/// use regit_svi::smile::raw::RawSvi;
51/// use regit_svi::density::d_minus;
52///
53/// // At k = 0 with total variance w, d_minus = -sqrt(w)/2.
54/// let svi = RawSvi::new(0.04, 0.0, 0.0, 0.0, 0.1)?;
55/// assert!((d_minus(&svi, 0.0) + 0.1).abs() < 1e-12);
56/// # Ok(())
57/// # }
58/// ```
59#[must_use]
60#[inline]
61pub fn d_minus(svi: &RawSvi, k: f64) -> f64 {
62 let w = svi.total_variance(k);
63 let sqrt_w = w.sqrt();
64 -k / sqrt_w - sqrt_w / 2.0
65}
66
67/// The Black `d_plus` quantity: `d_plus(k) = -k/sqrt(w) + sqrt(w)/2`.
68///
69/// This infallible kernel assumes finite `k` and positive finite `w(k)` and
70/// propagates IEEE non-finite results when those preconditions fail.
71///
72/// # Examples
73///
74/// ```
75/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
76/// use regit_svi::smile::raw::RawSvi;
77/// use regit_svi::density::d_plus;
78///
79/// // At k = 0 with total variance w, d_plus = +sqrt(w)/2.
80/// let svi = RawSvi::new(0.04, 0.0, 0.0, 0.0, 0.1)?;
81/// assert!((d_plus(&svi, 0.0) - 0.1).abs() < 1e-12);
82/// # Ok(())
83/// # }
84/// ```
85#[must_use]
86#[inline]
87pub fn d_plus(svi: &RawSvi, k: f64) -> f64 {
88 let w = svi.total_variance(k);
89 let sqrt_w = w.sqrt();
90 -k / sqrt_w + sqrt_w / 2.0
91}
92
93/// The risk-neutral density `p(k)` implied by a raw SVI slice (MATH.md §9).
94///
95/// ```text
96/// p(k) = g(k) / sqrt(2*pi*w(k)) * exp(-d_minus(k)^2 / 2)
97/// ```
98///
99/// On a regular positive slice, a negative `p(k)` is a butterfly-arbitrage
100/// witness. Non-negativity of the factor must be combined with the applicable
101/// call-price tail condition for a full slice conclusion.
102///
103/// # Examples
104///
105/// ```
106/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
107/// use regit_svi::smile::raw::RawSvi;
108/// use regit_svi::density::risk_neutral_density;
109///
110/// // A benign slice has a positive density at the money.
111/// let svi = RawSvi::new(0.04, 0.1, -0.2, 0.0, 0.3)?;
112/// assert!(risk_neutral_density(&svi, 0.0)? > 0.0);
113/// # Ok(())
114/// # }
115/// ```
116///
117/// # Errors
118///
119/// Returns [`DensityError::InvalidDomain`] for non-finite `k`,
120/// [`DensityError::NonPositiveVariance`] at a zero/singular variance point,
121/// [`DensityError::IllConditionedVariance`] when a positive variance is inside
122/// the scale-aware cancellation band,
123/// or [`DensityError::NonFiniteEvaluation`] when binary64 evaluation overflows.
124pub fn risk_neutral_density(svi: &RawSvi, k: f64) -> Result<f64, DensityError> {
125 if !k.is_finite() {
126 return Err(DensityError::InvalidDomain);
127 }
128 let w = svi.total_variance(k);
129 if !w.is_finite() {
130 return Err(DensityError::NonFiniteEvaluation { k });
131 }
132 if w <= 0.0 {
133 return Err(DensityError::NonPositiveVariance { k, w });
134 }
135 let variance_scale = 1.0 + svi.a().abs() + svi.b().abs() * svi.sigma().abs();
136 let near_minimum = (k - svi.k_min()).abs() <= 16.0 * f64::EPSILON * (1.0 + k.abs());
137 let uncertainty = 16.0 * f64::EPSILON * variance_scale;
138 if svi.b() > 0.0 && near_minimum && w <= uncertainty {
139 return Err(DensityError::IllConditionedVariance { k, w, uncertainty });
140 }
141 let dm = d_minus(svi, k);
142 let value = g(svi, k) / (TWO_PI * w).sqrt() * (-0.5 * dm * dm).exp();
143 if value.is_finite() {
144 Ok(value)
145 } else {
146 Err(DensityError::NonFiniteEvaluation { k })
147 }
148}
149
150/// Error returned when density evaluation or integration has no finite domain.
151#[derive(Debug, Clone, Copy, PartialEq)]
152pub enum DensityError {
153 /// The requested range, panel count, or point is invalid.
154 InvalidDomain,
155 /// Total variance is non-positive at the requested log-moneyness.
156 NonPositiveVariance {
157 /// Log-moneyness of the singularity.
158 k: f64,
159 /// Evaluated total variance.
160 w: f64,
161 },
162 /// Positive variance is too close to floating-point cancellation for a
163 /// reliable regular-density evaluation.
164 IllConditionedVariance {
165 /// Log-moneyness of the ill-conditioned evaluation.
166 k: f64,
167 /// Positive total variance obtained in binary64.
168 w: f64,
169 /// Scale-aware uncertainty threshold used for classification.
170 uncertainty: f64,
171 },
172 /// Floating-point evaluation overflowed or produced `NaN`.
173 NonFiniteEvaluation {
174 /// Log-moneyness of the failed evaluation.
175 k: f64,
176 },
177}
178
179impl fmt::Display for DensityError {
180 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
181 match self {
182 Self::InvalidDomain => {
183 write!(f, "density domain must be finite, ordered, and non-empty")
184 }
185 Self::NonPositiveVariance { k, w } => {
186 write!(f, "density is singular at k={k}: total variance is {w}")
187 }
188 Self::IllConditionedVariance { k, w, uncertainty } => write!(
189 f,
190 "density is ill-conditioned at k={k}: total variance {w} is within uncertainty {uncertainty}"
191 ),
192 Self::NonFiniteEvaluation { k } => {
193 write!(f, "density evaluation is non-finite at k={k}")
194 }
195 }
196 }
197}
198
199impl std::error::Error for DensityError {}
200
201/// A diagnostic report on the risk-neutral density of a raw SVI slice.
202///
203/// When the continuous positive-strike mass is one, [`integral`] over a wide
204/// window should approach `1`. A value far from `1` can reflect truncation, a
205/// zero-strike atom, arbitrage, or numerical ill-conditioning.
206#[derive(Debug, Clone, Copy, PartialEq)]
207pub struct DensityReport {
208 /// Requested lower log-moneyness bound.
209 lower: f64,
210 /// Requested upper log-moneyness bound.
211 upper: f64,
212 /// Number of panels in the finer composite-Simpson estimate.
213 panels: usize,
214 /// Numerical integral of `p` over the diagnostic window.
215 integral: f64,
216 /// Richardson error estimate from nested composite-Simpson grids.
217 integration_error: f64,
218 /// Smallest density value observed on the integration grid.
219 min_density: f64,
220 /// Whether a negative density was observed on the bounded grid.
221 violation_observed: bool,
222}
223
224impl DensityReport {
225 /// Returns the requested finite integration bounds.
226 #[must_use]
227 pub const fn domain(self) -> (f64, f64) {
228 (self.lower, self.upper)
229 }
230 /// Returns the panel count used by the reported fine-grid integral.
231 #[must_use]
232 pub const fn panels(self) -> usize {
233 self.panels
234 }
235 /// Returns the bounded numerical integral.
236 #[must_use]
237 pub const fn integral(self) -> f64 {
238 self.integral
239 }
240 /// Returns the nested-grid Simpson error estimate.
241 ///
242 /// This estimates quadrature discretization error on the declared finite
243 /// window; it does not include probability mass outside that window.
244 #[must_use]
245 pub const fn integration_error(self) -> f64 {
246 self.integration_error
247 }
248 /// Returns the minimum sampled density.
249 #[must_use]
250 pub const fn min_density(self) -> f64 {
251 self.min_density
252 }
253 /// Returns whether the bounded grid contained a negative density.
254 #[must_use]
255 pub const fn violation_observed(self) -> bool {
256 self.violation_observed
257 }
258}
259
260/// Numerically integrates the risk-neutral density over `[k_lo, k_hi]` by the
261/// composite Simpson rule with `2n` panels.
262///
263/// When the continuous positive-strike mass is one, a sufficiently wide
264/// window approaches `1`. The caller controls the window and panel count;
265/// this routine makes no truncation-error guarantee.
266///
267/// # Examples
268///
269/// ```
270/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
271/// use regit_svi::smile::raw::RawSvi;
272/// use regit_svi::density::integral;
273///
274/// // A benign, low-variance slice integrates to near 1 over a wide window.
275/// let svi = RawSvi::new(0.04, 0.05, -0.1, 0.0, 0.4)?;
276/// let mass = integral(&svi, -6.0, 6.0, 2000)?;
277/// assert!((mass - 1.0).abs() < 1e-2, "mass = {mass}");
278/// # Ok(())
279/// # }
280/// ```
281///
282/// # Errors
283///
284/// Returns [`DensityError::InvalidDomain`] for invalid bounds/counts and
285/// propagates pointwise density errors from [`risk_neutral_density`].
286pub fn integral(svi: &RawSvi, k_lo: f64, k_hi: f64, n: usize) -> Result<f64, DensityError> {
287 if !k_lo.is_finite() || !k_hi.is_finite() || k_lo >= k_hi || n == 0 || n > usize::MAX / 2 {
288 return Err(DensityError::InvalidDomain);
289 }
290 let panels = 2 * n;
291 let h = (k_hi - k_lo) / index_to_f64(panels);
292 let mut sum = risk_neutral_density(svi, k_lo)? + risk_neutral_density(svi, k_hi)?;
293 if !sum.is_finite() {
294 return Err(DensityError::NonFiniteEvaluation { k: k_lo });
295 }
296 for i in 1..panels {
297 let k = h.mul_add(index_to_f64(i), k_lo);
298 let weight = if i % 2 == 1 { 4.0 } else { 2.0 };
299 sum += weight * risk_neutral_density(svi, k)?;
300 if !sum.is_finite() {
301 return Err(DensityError::NonFiniteEvaluation { k });
302 }
303 }
304 let value = sum * h / 3.0;
305 if value.is_finite() {
306 Ok(value)
307 } else {
308 Err(DensityError::NonFiniteEvaluation { k: k_hi })
309 }
310}
311
312/// Builds a [`DensityReport`] for a slice over `[k_lo, k_hi]`.
313///
314/// Integrates the density and samples the same bounded grid for its minimum.
315/// A clean report is numerical diagnostic evidence, not a global conclusion.
316///
317/// # Examples
318///
319/// ```
320/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
321/// use regit_svi::smile::raw::RawSvi;
322/// use regit_svi::density::density_report;
323///
324/// let svi = RawSvi::new(0.04, 0.05, -0.1, 0.0, 0.4)?;
325/// let report = density_report(&svi, -6.0, 6.0, 2000)?;
326/// assert!(!report.violation_observed());
327/// assert!((report.integral() - 1.0).abs() < 1e-2);
328/// # Ok(())
329/// # }
330/// ```
331///
332/// # Errors
333///
334/// Returns [`DensityError::InvalidDomain`] for invalid bounds/counts and
335/// propagates pointwise density errors from [`risk_neutral_density`].
336pub fn density_report(
337 svi: &RawSvi,
338 k_lo: f64,
339 k_hi: f64,
340 n: usize,
341) -> Result<DensityReport, DensityError> {
342 if !k_lo.is_finite() || !k_hi.is_finite() || k_lo >= k_hi || n == 0 || n > usize::MAX / 4 {
343 return Err(DensityError::InvalidDomain);
344 }
345 let panels = 4 * n;
346 let h = (k_hi - k_lo) / index_to_f64(panels);
347
348 let mut min_density = f64::INFINITY;
349 let mut violation_observed = false;
350 for i in 0..=panels {
351 let k = h.mul_add(index_to_f64(i), k_lo);
352 let p = risk_neutral_density(svi, k)?;
353 if p < min_density {
354 min_density = p;
355 }
356 let (density_factor, evaluation_scale) = g_with_scale(svi, k);
357 violation_observed |= density_factor < -128.0 * f64::EPSILON * evaluation_scale;
358 }
359
360 let coarse_integral = integral(svi, k_lo, k_hi, n)?;
361 let fine_integral = integral(svi, k_lo, k_hi, 2 * n)?;
362 let integration_error = (fine_integral - coarse_integral).abs() / 15.0;
363 if !integration_error.is_finite() {
364 return Err(DensityError::NonFiniteEvaluation { k: k_hi });
365 }
366 Ok(DensityReport {
367 lower: k_lo,
368 upper: k_hi,
369 panels,
370 integral: fine_integral,
371 integration_error,
372 min_density,
373 violation_observed,
374 })
375}
376
377#[cfg(test)]
378#[allow(clippy::expect_used)] // Validated fixtures use contextual expectations.
379mod tests {
380 use super::*;
381
382 #[test]
383 fn d_plus_d_minus_differ_by_sqrt_w() {
384 let svi =
385 RawSvi::new(0.04, 0.2, -0.3, 0.05, 0.12).expect("valid test or documentation fixture");
386 for &k in &[-0.5, 0.0, 0.3] {
387 let w = svi.total_variance(k);
388 assert!((d_plus(&svi, k) - d_minus(&svi, k) - w.sqrt()).abs() < 1e-12);
389 }
390 }
391
392 #[test]
393 fn density_positive_for_benign_slice() {
394 let svi =
395 RawSvi::new(0.04, 0.1, -0.2, 0.0, 0.3).expect("valid test or documentation fixture");
396 for &k in &[-1.0, -0.3, 0.0, 0.3, 1.0] {
397 assert!(
398 risk_neutral_density(&svi, k).expect("valid test or documentation fixture") > 0.0,
399 "p({k})"
400 );
401 }
402 }
403
404 #[test]
405 fn density_integrates_to_one() {
406 let svi =
407 RawSvi::new(0.04, 0.05, -0.1, 0.0, 0.4).expect("valid test or documentation fixture");
408 let mass = integral(&svi, -8.0, 8.0, 4000).expect("valid test or documentation fixture");
409 assert!((mass - 1.0).abs() < 1e-3, "mass = {mass}");
410 }
411
412 #[test]
413 fn density_integrates_to_one_low_vol() {
414 let svi =
415 RawSvi::new(0.02, 0.04, -0.15, 0.0, 0.3).expect("valid test or documentation fixture");
416 let mass = integral(&svi, -6.0, 6.0, 4000).expect("valid test or documentation fixture");
417 assert!((mass - 1.0).abs() < 1e-3, "mass = {mass}");
418 }
419
420 #[test]
421 fn density_report_benign_slice() {
422 let svi =
423 RawSvi::new(0.04, 0.05, -0.1, 0.0, 0.4).expect("valid test or documentation fixture");
424 let report =
425 density_report(&svi, -8.0, 8.0, 4000).expect("valid test or documentation fixture");
426 assert!(!report.violation_observed());
427 assert!((report.integral() - 1.0).abs() < 1e-3);
428 assert!(report.integration_error().is_finite());
429 assert!(report.integration_error() >= 0.0);
430 let (lower, upper) = report.domain();
431 assert!((lower + 8.0).abs() < f64::EPSILON);
432 assert!((upper - 8.0).abs() < f64::EPSILON);
433 assert_eq!(report.panels(), 16_000);
434 assert!(report.min_density() >= 0.0);
435 }
436
437 #[test]
438 fn density_report_flags_vogt_slice() {
439 // The Vogt slice has butterfly arbitrage -> negative density region.
440 let vogt = RawSvi::new(-0.0410, 0.1331, 0.3060, 0.3586, 0.4153)
441 .expect("valid test or documentation fixture");
442 let report =
443 density_report(&vogt, -2.0, 2.0, 2000).expect("valid test or documentation fixture");
444 assert!(report.violation_observed());
445 assert!(report.min_density() < 0.0);
446 }
447
448 #[test]
449 fn density_handles_zero_variance_gracefully() {
450 // A slice whose w_min is exactly 0 should not produce NaN.
451 let svi = RawSvi::new(-0.125, 0.5, 0.0, 0.0, 0.25).expect("valid exact-zero fixture");
452 assert!(matches!(
453 risk_neutral_density(&svi, svi.k_min()),
454 Err(DensityError::NonPositiveVariance { .. })
455 ));
456 }
457
458 #[test]
459 fn tiny_positive_variance_is_not_classified_as_non_positive() {
460 let slice =
461 RawSvi::new(-0.02 + 1e-16, 0.1, 0.0, 0.0, 0.2).expect("valid tiny-positive fixture");
462 assert!(slice.w_min() > 0.0);
463 assert!(matches!(
464 risk_neutral_density(&slice, slice.k_min()),
465 Err(DensityError::IllConditionedVariance { w, .. }) if w > 0.0
466 ));
467 }
468
469 #[test]
470 fn non_finite_variance_is_not_classified_as_non_positive() {
471 let slice = RawSvi::new_unchecked(0.0, f64::MAX, 0.0, -f64::MAX, f64::MIN_POSITIVE);
472 assert!(matches!(
473 risk_neutral_density(&slice, 0.0),
474 Err(DensityError::NonFiniteEvaluation { k: 0.0 })
475 ));
476 }
477}