Skip to content

Commit a880cf6

Browse files
committed
feat: add local vols, dupire, breeden-litzenberger
1 parent d8e6ba4 commit a880cf6

File tree

4 files changed

+406
-0
lines changed

4 files changed

+406
-0
lines changed

src/quant/pricing.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,6 @@ pub mod bsm;
33
pub mod finitie_difference;
44
pub mod heston;
55
pub mod merton_jump;
6+
pub mod breeden_litzenberger;
7+
pub mod dupire;
8+
pub mod pnl;
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
//! Breeden–Litzenberger formula utilities
2+
//! f_{RN}(K, T) = e^{r T} * ∂²C(K,T)/∂K² (same for P)
3+
4+
use impl_new_derive::ImplNew;
5+
6+
/// Breeden–Litzenberger risk-neutral density extractor
7+
#[derive(ImplNew, Clone, Debug)]
8+
pub struct BreedenLitzenberger {
9+
/// Strikes (strictly increasing)
10+
pub strikes: Vec<f64>,
11+
/// Option prices C(K[i], T) or P(K[i], T) at the same maturity T (present values)
12+
pub prices: Vec<f64>,
13+
/// Risk-free rate
14+
pub r: f64,
15+
/// Time to maturity in years
16+
pub tau: f64,
17+
/// Optional pre-calculated second derivative ∂²C/∂K² at each strike (overrides finite-difference computation)
18+
pub d2c_dk2: Option<Vec<f64>>,
19+
}
20+
21+
impl BreedenLitzenberger {
22+
/// Compute risk–neutral density across strikes using finite differences on a (possibly non-uniform) grid.
23+
///
24+
/// Returns a Vec of length strikes.len() with the estimated density at each strike.
25+
/// Endpoints (i = 0 and i = n-1) are set to NaN as second derivatives are ill-defined there with 3-point stencils.
26+
#[must_use]
27+
pub fn density(&self) -> Vec<f64> {
28+
assert!(
29+
self.strikes.len() == self.prices.len(),
30+
"strikes and prices must have same length"
31+
);
32+
let n = self.strikes.len();
33+
let mut dens = vec![f64::NAN; n];
34+
if n < 3 {
35+
return dens;
36+
}
37+
38+
for i in 1..n - 1 {
39+
let k_im1 = self.strikes[i - 1];
40+
let k_i = self.strikes[i];
41+
let k_ip1 = self.strikes[i + 1];
42+
43+
let c_im1 = self.prices[i - 1];
44+
let c_i = self.prices[i];
45+
let c_ip1 = self.prices[i + 1];
46+
47+
let h_i = k_i - k_im1;
48+
let h_ip1 = k_ip1 - k_i;
49+
50+
// Second derivative on a non-uniform grid (three-point formula)
51+
// C''(K_i) ≈ 2 [ C_{i-1}/(h_i (h_i + h_ip1)) - C_i/(h_i h_ip1) + C_{i+1}/(h_ip1 (h_i + h_ip1)) ]
52+
let denom_left = h_i * (h_i + h_ip1);
53+
let denom_mid = h_i * h_ip1;
54+
let denom_right = h_ip1 * (h_i + h_ip1);
55+
56+
let cdd = 2.0 * (c_im1 / denom_left - c_i / denom_mid + c_ip1 / denom_right);
57+
dens[i] = (self.r * self.tau).exp() * cdd;
58+
}
59+
60+
dens
61+
}
62+
63+
/// Compute risk–neutral density using pre-calculated second derivatives ∂²C/∂K².
64+
/// Requires `d2c_dk2` field to be populated.
65+
///
66+
/// Returns a Vec of length strikes.len() with the estimated density at each strike.
67+
#[must_use]
68+
pub fn density_from_custom_derivatives(&self) -> Vec<f64> {
69+
let d2c = self.d2c_dk2.as_ref().expect("d2c_dk2 must be provided for custom derivatives");
70+
let n = self.strikes.len();
71+
assert_eq!(d2c.len(), n, "d2c_dk2 must have same length as strikes");
72+
73+
let mut dens = Vec::with_capacity(n);
74+
for i in 0..n {
75+
dens.push((self.r * self.tau).exp() * d2c[i]);
76+
}
77+
dens
78+
}
79+
}

src/quant/pricing/dupire.rs

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
//! Dupire local volatility from call price surface
2+
//! σ_loc^2(K,T) = [ ∂C/∂T + (r - q) K ∂C/∂K + q C ] / [ 0.5 K^2 ∂²C/∂K² ]
3+
4+
use impl_new_derive::ImplNew;
5+
use ndarray::{Array2, Axis};
6+
7+
/// Dupire local volatility surface calculator
8+
#[derive(ImplNew, Clone, Debug)]
9+
pub struct Dupire {
10+
/// Strikes (ascending), length N_K
11+
pub ks: Vec<f64>,
12+
/// Maturities in years (ascending), length N_T
13+
pub ts: Vec<f64>,
14+
/// Call price surface with shape (N_T, N_K), row = fixed T_j, col = K_i, values are present call prices C(K_i, T_j)
15+
pub calls: Array2<f64>,
16+
/// Risk-free rate
17+
pub r: f64,
18+
/// Dividend yield
19+
pub q: f64,
20+
/// Small number to stabilize division when ∂²C/∂K² is near zero
21+
pub eps: f64,
22+
/// Optional pre-calculated ∂C/∂K with shape (N_T, N_K) (overrides finite-difference computation)
23+
pub dc_dk: Option<Array2<f64>>,
24+
/// Optional pre-calculated ∂²C/∂K² with shape (N_T, N_K) (overrides finite-difference computation)
25+
pub d2c_dk2: Option<Array2<f64>>,
26+
/// Optional pre-calculated ∂C/∂T with shape (N_T, N_K) (overrides finite-difference computation)
27+
pub dc_dt: Option<Array2<f64>>,
28+
}
29+
30+
impl Dupire {
31+
/// Compute local volatility surface on the same (T, K) grid as the input call surface.
32+
///
33+
/// Returns Array2 (N_T, N_K) with σ_loc(K_i, T_j); boundaries in K use NaN where the second derivative is ill-defined.
34+
#[must_use]
35+
pub fn local_vol_surface(&self) -> Array2<f64> {
36+
assert_eq!(
37+
self.calls.dim().0,
38+
self.ts.len(),
39+
"calls rows must match ts length"
40+
);
41+
assert_eq!(
42+
self.calls.dim().1,
43+
self.ks.len(),
44+
"calls cols must match ks length"
45+
);
46+
47+
let nt = self.ts.len();
48+
let nk = self.ks.len();
49+
50+
let mut sigma = Array2::<f64>::from_elem((nt, nk), f64::NAN);
51+
52+
for j in 0..nt {
53+
for i in 1..nk - 1 {
54+
let k_im1 = self.ks[i - 1];
55+
let k_i = self.ks[i];
56+
let k_ip1 = self.ks[i + 1];
57+
58+
let c_im1 = self.calls[[j, i - 1]];
59+
let c_i = self.calls[[j, i]];
60+
let c_ip1 = self.calls[[j, i + 1]];
61+
62+
// ∂C/∂K at (j,i) using non-uniform 3-pt central stencil
63+
let h_i = k_i - k_im1;
64+
let h_ip1 = k_ip1 - k_i;
65+
let dcdk = (-h_ip1 / (h_i * (h_i + h_ip1))) * c_im1
66+
+ ((h_ip1 - h_i) / (h_i * h_ip1)) * c_i
67+
+ (h_i / (h_ip1 * (h_i + h_ip1))) * c_ip1;
68+
69+
// ∂²C/∂K² at (j,i) using non-uniform 3-pt stencil
70+
let denom_left = h_i * (h_i + h_ip1);
71+
let denom_mid = h_i * h_ip1;
72+
let denom_right = h_ip1 * (h_i + h_ip1);
73+
let d2cdk2 = 2.0 * (c_im1 / denom_left - c_i / denom_mid + c_ip1 / denom_right);
74+
75+
// ∂C/∂T at (j,i) using central difference in T (non-uniform aware)
76+
let dcdt = if j == 0 {
77+
let dt = self.ts[1] - self.ts[0];
78+
if dt.abs() < f64::EPSILON {
79+
f64::NAN
80+
} else {
81+
(self.calls[[1, i]] - self.calls[[0, i]]) / dt
82+
}
83+
} else if j == nt - 1 {
84+
let dt = self.ts[nt - 1] - self.ts[nt - 2];
85+
if dt.abs() < f64::EPSILON {
86+
f64::NAN
87+
} else {
88+
(self.calls[[nt - 1, i]] - self.calls[[nt - 2, i]]) / dt
89+
}
90+
} else {
91+
let dt = self.ts[j + 1] - self.ts[j - 1];
92+
if dt.abs() < f64::EPSILON {
93+
f64::NAN
94+
} else {
95+
(self.calls[[j + 1, i]] - self.calls[[j - 1, i]]) / dt
96+
}
97+
};
98+
99+
let denom = 0.5 * k_i * k_i * d2cdk2;
100+
let numer = dcdt + (self.r - self.q) * k_i * dcdk + self.q * c_i;
101+
102+
if denom.abs() > self.eps && denom.is_finite() && numer.is_finite() {
103+
let s2 = numer / denom;
104+
if s2.is_sign_positive() {
105+
sigma[[j, i]] = s2.max(0.0).sqrt();
106+
}
107+
}
108+
}
109+
}
110+
111+
sigma
112+
}
113+
114+
/// Compute local volatility surface using pre-calculated partial derivatives ∂C/∂K, ∂²C/∂K², ∂C/∂T.
115+
/// Requires `dc_dk`, `d2c_dk2`, and `dc_dt` fields to be populated.
116+
///
117+
/// Returns Array2 (N_T, N_K) with σ_loc(K_i, T_j).
118+
#[must_use]
119+
pub fn local_vol_surface_from_custom_derivatives(&self) -> Array2<f64> {
120+
let dc_dk = self.dc_dk.as_ref().expect("dc_dk must be provided for custom derivatives");
121+
let d2c_dk2 = self.d2c_dk2.as_ref().expect("d2c_dk2 must be provided for custom derivatives");
122+
let dc_dt = self.dc_dt.as_ref().expect("dc_dt must be provided for custom derivatives");
123+
124+
let nt = self.ts.len();
125+
let nk = self.ks.len();
126+
127+
assert_eq!(dc_dk.dim(), (nt, nk), "dc_dk must have shape (N_T, N_K)");
128+
assert_eq!(d2c_dk2.dim(), (nt, nk), "d2c_dk2 must have shape (N_T, N_K)");
129+
assert_eq!(dc_dt.dim(), (nt, nk), "dc_dt must have shape (N_T, N_K)");
130+
131+
let mut sigma = Array2::<f64>::from_elem((nt, nk), f64::NAN);
132+
133+
for j in 0..nt {
134+
for i in 0..nk {
135+
let k_i = self.ks[i];
136+
let c_i = self.calls[[j, i]];
137+
138+
let dcdk = dc_dk[[j, i]];
139+
let d2cdk2 = d2c_dk2[[j, i]];
140+
let dcdt = dc_dt[[j, i]];
141+
142+
let denom = 0.5 * k_i * k_i * d2cdk2;
143+
let numer = dcdt + (self.r - self.q) * k_i * dcdk + self.q * c_i;
144+
145+
if denom.abs() > self.eps && denom.is_finite() && numer.is_finite() {
146+
let s2 = numer / denom;
147+
if s2.is_sign_positive() {
148+
sigma[[j, i]] = s2.max(0.0).sqrt();
149+
}
150+
}
151+
}
152+
}
153+
154+
sigma
155+
}
156+
157+
/// Convenience: compute local volatility for a single maturity slice at time index j.
158+
#[must_use]
159+
pub fn local_vol_slice(&self, j: usize) -> Vec<f64> {
160+
assert!(j < self.ts.len());
161+
let row = self.calls.index_axis(Axis(0), j);
162+
let mut out = vec![f64::NAN; self.ks.len()];
163+
164+
if self.ks.len() < 3 {
165+
return out;
166+
}
167+
168+
for i in 1..self.ks.len() - 1 {
169+
let k_im1 = self.ks[i - 1];
170+
let k_i = self.ks[i];
171+
let k_ip1 = self.ks[i + 1];
172+
173+
let c_im1 = row[i - 1];
174+
let c_i = row[i];
175+
let c_ip1 = row[i + 1];
176+
177+
let h_i = k_i - k_im1;
178+
let h_ip1 = k_ip1 - k_i;
179+
180+
let dcdk = (-h_ip1 / (h_i * (h_i + h_ip1))) * c_im1
181+
+ ((h_ip1 - h_i) / (h_i * h_ip1)) * c_i
182+
+ (h_i / (h_ip1 * (h_i + h_ip1))) * c_ip1;
183+
184+
let denom_left = h_i * (h_i + h_ip1);
185+
let denom_mid = h_i * h_ip1;
186+
let denom_right = h_ip1 * (h_i + h_ip1);
187+
let d2cdk2 = 2.0 * (c_im1 / denom_left - c_i / denom_mid + c_ip1 / denom_right);
188+
189+
let dcdt = if j == 0 {
190+
let dt = self.ts[1] - self.ts[0];
191+
if dt.abs() < f64::EPSILON {
192+
f64::NAN
193+
} else {
194+
(self.calls[[1, i]] - self.calls[[0, i]]) / dt
195+
}
196+
} else if j == self.ts.len() - 1 {
197+
let dt = self.ts[j] - self.ts[j - 1];
198+
if dt.abs() < f64::EPSILON {
199+
f64::NAN
200+
} else {
201+
(self.calls[[j, i]] - self.calls[[j - 1, i]]) / dt
202+
}
203+
} else {
204+
let dt = self.ts[j + 1] - self.ts[j - 1];
205+
if dt.abs() < f64::EPSILON {
206+
f64::NAN
207+
} else {
208+
(self.calls[[j + 1, i]] - self.calls[[j - 1, i]]) / dt
209+
}
210+
};
211+
212+
let denom = 0.5 * k_i * k_i * d2cdk2;
213+
let numer = dcdt + (self.r - self.q) * k_i * dcdk + self.q * c_i;
214+
215+
if denom.abs() > self.eps && denom.is_finite() && numer.is_finite() {
216+
let s2 = numer / denom;
217+
if s2.is_sign_positive() {
218+
out[i] = s2.max(0.0).sqrt();
219+
}
220+
}
221+
}
222+
223+
out
224+
}
225+
226+
/// Compute local volatility for a single maturity slice using pre-calculated partial derivatives.
227+
/// Requires `dc_dk`, `d2c_dk2`, and `dc_dt` fields to be populated.
228+
#[must_use]
229+
pub fn local_vol_slice_from_custom_derivatives(&self, j: usize) -> Vec<f64> {
230+
assert!(j < self.ts.len());
231+
232+
let dc_dk = self.dc_dk.as_ref().expect("dc_dk must be provided for custom derivatives");
233+
let d2c_dk2 = self.d2c_dk2.as_ref().expect("d2c_dk2 must be provided for custom derivatives");
234+
let dc_dt = self.dc_dt.as_ref().expect("dc_dt must be provided for custom derivatives");
235+
236+
let row = self.calls.index_axis(Axis(0), j);
237+
let mut out = vec![f64::NAN; self.ks.len()];
238+
239+
for i in 0..self.ks.len() {
240+
let k_i = self.ks[i];
241+
let c_i = row[i];
242+
243+
let dcdk = dc_dk[[j, i]];
244+
let d2cdk2 = d2c_dk2[[j, i]];
245+
let dcdt = dc_dt[[j, i]];
246+
247+
let denom = 0.5 * k_i * k_i * d2cdk2;
248+
let numer = dcdt + (self.r - self.q) * k_i * dcdk + self.q * c_i;
249+
250+
if denom.abs() > self.eps && denom.is_finite() && numer.is_finite() {
251+
let s2 = numer / denom;
252+
if s2.is_sign_positive() {
253+
out[i] = s2.max(0.0).sqrt();
254+
}
255+
}
256+
}
257+
258+
out
259+
}
260+
}

0 commit comments

Comments
 (0)