Skip to content

Commit c3d1680

Browse files
committed
fix: feller bounds
1 parent 8f3d141 commit c3d1680

File tree

2 files changed

+45
-11
lines changed

2 files changed

+45
-11
lines changed

src/ai/volatility/heston.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,14 +134,14 @@ mod tests {
134134

135135
let data: Array2<f64> = read_npy(temp_file.path()).unwrap();
136136

137-
// Corrected data loading: match the Python code
137+
// Corrected data loading to match the reference dataset
138138
let parameters = data.slice(s![.., 0..5]).to_owned(); // Parameters (12000, 5)
139139
let implied_vols = data.slice(s![.., 5..]).to_owned(); // Implied volatilities (12000, 88)
140140

141141
let strikes = Array1::from(vec![0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.1, 1.2, 1.3, 1.4, 1.5]);
142142
let maturities = Array1::from(vec![0.1, 0.3, 0.6, 0.9, 1.2, 1.5, 1.8, 2.0]);
143143

144-
// Data splitting: match the Python code
144+
// Data splitting consistent with the reference workflow
145145
let split_data = train_test_split_for_array2(&[&implied_vols, &parameters], 0.15, Some(42));
146146
let (x_train, x_test) = &split_data[0]; // Implied volatilities
147147
let (y_train, y_test) = &split_data[1]; // Parameters

src/quant/calibration/heston.rs

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ const KAPPA_MIN: f64 = 1e-3;
2121
const THETA_MIN: f64 = 1e-8;
2222
const SIGMA_MIN: f64 = 1e-8;
2323

24+
// Use periodic linear extension mapping into these ranges
25+
const P_KAPPA: (f64, f64) = (0.1, 20.0);
26+
const P_THETA: (f64, f64) = (0.001, 0.4);
27+
const P_SIGMA: (f64, f64) = (0.01, 0.6);
28+
const P_RHO: (f64, f64) = (-1.0, 1.0);
29+
const P_V0: (f64, f64) = (0.005, 0.25);
30+
2431
#[derive(Clone, Debug)]
2532
pub struct HestonParams {
2633
/// Initial variance v0 (not volatility) in Heston model
@@ -36,25 +43,52 @@ pub struct HestonParams {
3643
}
3744

3845
impl HestonParams {
39-
/// Project parameters to satisfy Heston admissibility constraints:
40-
/// v0 ≥ 0, kappa > 0, theta > 0, sigma ≥ 0, −1 < rho < 1, and Feller 2*kappa*theta ≥ sigma^2.
46+
fn periodic_map(x: f64, c: f64, d: f64) -> f64 {
47+
if c <= x && x <= d {
48+
x
49+
} else {
50+
let range = d - c;
51+
if range <= 0.0 {
52+
return c;
53+
}
54+
let n = ((x - c) / range).floor();
55+
let n_int = n as i64;
56+
if n_int % 2 == 0 {
57+
x - n * range
58+
} else {
59+
d + n * range - (x - c)
60+
}
61+
}
62+
}
63+
64+
/// Project parameters to satisfy Heston admissibility constraints and periodic-range mapping.
65+
/// Steps:
66+
/// 1) Periodic mapping into fixed parameter ranges
67+
/// 2) Enforce basic positivity/box constraints
68+
/// 3) Enforce Feller by lowering sigma when needed (otherwise minimally bump theta)
4169
pub fn project_in_place(&mut self) {
42-
// Basic bounds
70+
self.kappa = Self::periodic_map(self.kappa, P_KAPPA.0, P_KAPPA.1);
71+
self.theta = Self::periodic_map(self.theta, P_THETA.0, P_THETA.1);
72+
self.sigma = Self::periodic_map(self.sigma, P_SIGMA.0, P_SIGMA.1).abs();
73+
self.rho = Self::periodic_map(self.rho, P_RHO.0, P_RHO.1);
74+
self.v0 = Self::periodic_map(self.v0, P_V0.0, P_V0.1);
75+
4376
self.v0 = self.v0.max(0.0);
4477
self.kappa = self.kappa.max(KAPPA_MIN);
4578
self.theta = self.theta.max(THETA_MIN);
4679
self.sigma = self.sigma.abs().max(SIGMA_MIN);
4780
self.rho = self.rho.max(-RHO_BOUND).min(RHO_BOUND);
4881

49-
// Feller condition: 2*kappa*theta ≥ sigma^2.
82+
// 3) Feller condition: 2*kappa*theta ≥ sigma^2.
5083
if 2.0 * self.kappa * self.theta < self.sigma * self.sigma {
5184
let sigma_star = (2.0 * self.kappa * self.theta).sqrt();
52-
if sigma_star >= SIGMA_MIN {
53-
// Prefer reducing sigma to satisfy Feller to avoid blowing up theta.
54-
self.sigma = sigma_star;
85+
if sigma_star >= P_SIGMA.0 {
86+
// Prefer reducing sigma, but keep within the range lower bound as well.
87+
self.sigma = sigma_star.min(P_SIGMA.1);
5588
} else {
56-
// As a fallback (when sigma would go below minimum), bump theta minimally.
57-
self.theta = ((self.sigma * self.sigma) / (2.0 * self.kappa)).max(THETA_MIN) + EPS;
89+
// As a fallback (when sigma would go below minimum), bump theta minimally, respecting the range upper bound.
90+
let theta_star = ((self.sigma * self.sigma) / (2.0 * self.kappa)).max(THETA_MIN) + EPS;
91+
self.theta = theta_star.min(P_THETA.1);
5892
}
5993
}
6094
}

0 commit comments

Comments
 (0)