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::arbitrage`]) is exactly the
7//! normalised risk-neutral probability density in log-strike space. With
8//!
9//! ```text
10//! d_minus(k) = -k/sqrt(w(k)) - sqrt(w(k))/2
11//! d_plus(k) = -k/sqrt(w(k)) + sqrt(w(k))/2
12//! ```
13//!
14//! the risk-neutral density of the log-strike is
15//!
16//! ```text
17//! p(k) = g(k) / sqrt(2*pi*w(k)) * exp(-d_minus(k)^2 / 2)
18//! ```
19//!
20//! `p(k) >= 0` for all `k` is equivalent to `g(k) >= 0`, which is why the
21//! butterfly check is a density-positivity check. For an arbitrage-free slice
22//! `p` integrates to 1 over `k in R`; the [`integral`] function provides a
23//! numerical check.
24//!
25//! # References
26//!
27//! - Breeden, D. & Litzenberger, R., "Prices of state-contingent claims
28//! implicit in option prices", *Journal of Business* 51(4):621-651 (1978).
29//! - Gatheral, J. & Jacquier, A., "Arbitrage-free SVI volatility surfaces",
30//! *Quantitative Finance* 14(1):59-71 (2014), eq. (2.2).
31
32use crate::arbitrage::g;
33use crate::math::index_to_f64;
34use crate::raw::RawSvi;
35
36/// `2*pi` — normalising constant for the density.
37const TWO_PI: f64 = std::f64::consts::TAU;
38
39/// The Black `d_minus` quantity: `d_minus(k) = -k/sqrt(w) - sqrt(w)/2`.
40///
41/// # Examples
42///
43/// ```
44/// use regit_svi::raw::RawSvi;
45/// use regit_svi::density::d_minus;
46///
47/// // At k = 0 with total variance w, d_minus = -sqrt(w)/2.
48/// let svi = RawSvi::new(0.04, 0.0, 0.0, 0.0, 0.1).unwrap();
49/// assert!((d_minus(&svi, 0.0) + 0.1).abs() < 1e-12);
50/// ```
51#[must_use]
52#[inline]
53pub fn d_minus(svi: &RawSvi, k: f64) -> f64 {
54 let w = svi.total_variance(k);
55 let sqrt_w = w.sqrt();
56 -k / sqrt_w - sqrt_w / 2.0
57}
58
59/// The Black `d_plus` quantity: `d_plus(k) = -k/sqrt(w) + sqrt(w)/2`.
60///
61/// # Examples
62///
63/// ```
64/// use regit_svi::raw::RawSvi;
65/// use regit_svi::density::d_plus;
66///
67/// // At k = 0 with total variance w, d_plus = +sqrt(w)/2.
68/// let svi = RawSvi::new(0.04, 0.0, 0.0, 0.0, 0.1).unwrap();
69/// assert!((d_plus(&svi, 0.0) - 0.1).abs() < 1e-12);
70/// ```
71#[must_use]
72#[inline]
73pub fn d_plus(svi: &RawSvi, k: f64) -> f64 {
74 let w = svi.total_variance(k);
75 let sqrt_w = w.sqrt();
76 -k / sqrt_w + sqrt_w / 2.0
77}
78
79/// The risk-neutral density `p(k)` implied by a raw SVI slice (MATH.md §9).
80///
81/// ```text
82/// p(k) = g(k) / sqrt(2*pi*w(k)) * exp(-d_minus(k)^2 / 2)
83/// ```
84///
85/// `p(k)` is non-negative everywhere iff the slice is free of butterfly
86/// arbitrage. For a slice with butterfly arbitrage `p` is negative on the
87/// arbitrage interval.
88///
89/// # Examples
90///
91/// ```
92/// use regit_svi::raw::RawSvi;
93/// use regit_svi::density::risk_neutral_density;
94///
95/// // A benign slice has a positive density at the money.
96/// let svi = RawSvi::new(0.04, 0.1, -0.2, 0.0, 0.3).unwrap();
97/// assert!(risk_neutral_density(&svi, 0.0) > 0.0);
98/// ```
99#[must_use]
100pub fn risk_neutral_density(svi: &RawSvi, k: f64) -> f64 {
101 let w = svi.total_variance(k);
102 if w <= 0.0 || !w.is_finite() {
103 return 0.0;
104 }
105 let dm = d_minus(svi, k);
106 g(svi, k) / (TWO_PI * w).sqrt() * (-0.5 * dm * dm).exp()
107}
108
109/// A diagnostic report on the risk-neutral density of a raw SVI slice.
110///
111/// The [`integral`] over a wide log-moneyness window should be close to `1`
112/// for an arbitrage-free slice; a value far from `1`, or `min_density < 0`,
113/// signals butterfly arbitrage or a numerically extreme slice.
114#[derive(Debug, Clone, Copy, PartialEq)]
115pub struct DensityReport {
116 /// Numerical integral of `p` over the diagnostic window.
117 pub integral: f64,
118 /// Smallest density value observed on the integration grid.
119 pub min_density: f64,
120 /// `true` if `min_density >= 0` over the diagnostic window.
121 pub is_non_negative: bool,
122}
123
124/// Numerically integrates the risk-neutral density over `[k_lo, k_hi]` by the
125/// composite Simpson rule with `2n` panels.
126///
127/// For an arbitrage-free slice and a sufficiently wide window the result is
128/// close to `1` (the density is a probability measure). `n` controls
129/// accuracy; `n = 1000` is ample for diagnostic use.
130///
131/// # Examples
132///
133/// ```
134/// use regit_svi::raw::RawSvi;
135/// use regit_svi::density::integral;
136///
137/// // A benign, low-variance slice integrates to near 1 over a wide window.
138/// let svi = RawSvi::new(0.04, 0.05, -0.1, 0.0, 0.4).unwrap();
139/// let mass = integral(&svi, -6.0, 6.0, 2000);
140/// assert!((mass - 1.0).abs() < 1e-2, "mass = {mass}");
141/// ```
142#[must_use]
143pub fn integral(svi: &RawSvi, k_lo: f64, k_hi: f64, n: usize) -> f64 {
144 let n = n.max(1);
145 let panels = 2 * n;
146 let h = (k_hi - k_lo) / index_to_f64(panels);
147 let mut sum = risk_neutral_density(svi, k_lo) + risk_neutral_density(svi, k_hi);
148 for i in 1..panels {
149 let k = h.mul_add(index_to_f64(i), k_lo);
150 let weight = if i % 2 == 1 { 4.0 } else { 2.0 };
151 sum += weight * risk_neutral_density(svi, k);
152 }
153 sum * h / 3.0
154}
155
156/// Builds a [`DensityReport`] for a slice over `[k_lo, k_hi]`.
157///
158/// Integrates the density and scans the same grid for the minimum density,
159/// so a single call answers both "does it integrate to one?" and "is it
160/// non-negative?".
161///
162/// # Examples
163///
164/// ```
165/// use regit_svi::raw::RawSvi;
166/// use regit_svi::density::density_report;
167///
168/// let svi = RawSvi::new(0.04, 0.05, -0.1, 0.0, 0.4).unwrap();
169/// let report = density_report(&svi, -6.0, 6.0, 2000);
170/// assert!(report.is_non_negative);
171/// assert!((report.integral - 1.0).abs() < 1e-2);
172/// ```
173#[must_use]
174pub fn density_report(svi: &RawSvi, k_lo: f64, k_hi: f64, n: usize) -> DensityReport {
175 let n = n.max(1);
176 let panels = 2 * n;
177 let h = (k_hi - k_lo) / index_to_f64(panels);
178
179 let mut min_density = f64::INFINITY;
180 for i in 0..=panels {
181 let k = h.mul_add(index_to_f64(i), k_lo);
182 let p = risk_neutral_density(svi, k);
183 if p < min_density {
184 min_density = p;
185 }
186 }
187
188 DensityReport {
189 integral: integral(svi, k_lo, k_hi, n),
190 min_density,
191 is_non_negative: min_density >= 0.0,
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 #[test]
200 fn d_plus_d_minus_differ_by_sqrt_w() {
201 let svi = RawSvi::new(0.04, 0.2, -0.3, 0.05, 0.12).unwrap();
202 for &k in &[-0.5, 0.0, 0.3] {
203 let w = svi.total_variance(k);
204 assert!((d_plus(&svi, k) - d_minus(&svi, k) - w.sqrt()).abs() < 1e-12);
205 }
206 }
207
208 #[test]
209 fn density_positive_for_benign_slice() {
210 let svi = RawSvi::new(0.04, 0.1, -0.2, 0.0, 0.3).unwrap();
211 for &k in &[-1.0, -0.3, 0.0, 0.3, 1.0] {
212 assert!(risk_neutral_density(&svi, k) > 0.0, "p({k})");
213 }
214 }
215
216 #[test]
217 fn density_integrates_to_one() {
218 let svi = RawSvi::new(0.04, 0.05, -0.1, 0.0, 0.4).unwrap();
219 let mass = integral(&svi, -8.0, 8.0, 4000);
220 assert!((mass - 1.0).abs() < 1e-3, "mass = {mass}");
221 }
222
223 #[test]
224 fn density_integrates_to_one_low_vol() {
225 let svi = RawSvi::new(0.02, 0.04, -0.15, 0.0, 0.3).unwrap();
226 let mass = integral(&svi, -6.0, 6.0, 4000);
227 assert!((mass - 1.0).abs() < 1e-3, "mass = {mass}");
228 }
229
230 #[test]
231 fn density_report_benign_slice() {
232 let svi = RawSvi::new(0.04, 0.05, -0.1, 0.0, 0.4).unwrap();
233 let report = density_report(&svi, -8.0, 8.0, 4000);
234 assert!(report.is_non_negative);
235 assert!((report.integral - 1.0).abs() < 1e-3);
236 assert!(report.min_density >= 0.0);
237 }
238
239 #[test]
240 fn density_report_flags_vogt_slice() {
241 // The Vogt slice has butterfly arbitrage -> negative density region.
242 let vogt = RawSvi::new(-0.0410, 0.1331, 0.3060, 0.3586, 0.4153).unwrap();
243 let report = density_report(&vogt, -2.0, 2.0, 2000);
244 assert!(!report.is_non_negative);
245 assert!(report.min_density < 0.0);
246 }
247
248 #[test]
249 fn density_handles_zero_variance_gracefully() {
250 // A slice whose w_min is exactly 0 should not produce NaN.
251 let svi = RawSvi::new(0.0, 0.1, 0.0, 0.0, 0.2).unwrap();
252 let p = risk_neutral_density(&svi, svi.k_min());
253 assert!(p.is_finite());
254 }
255}