fix(optimal_control,risk_metrics): stabilize solvers + statistically sound tests

Five deterministic test failures rooted out (all pre-existing on main):

- mrsjd + regime_switching + hjb_solver: pointwise Jacobi iteration on the
  stationary HJB diverges (sigma^2/dx^2 >> rho, residual -> NaN). Replaced
  with implicit-in-space Kushner-Dupuis upwind discretisation solved by the
  Thomas algorithm (unconditionally stable); removed now-unneeded
  under-relaxation; NaN-safe convergence checks (!(r < tol)).
- hjb_solver: equation had no source term, so V = 0 and the V' = +-1
  boundaries were grid artifacts. Added quadratic tracking payoff and
  singular-control obstacle projection -> symmetric boundaries.
- regime_switching two_regime_model: zero running term made cross-regime
  value comparison meaningless; running payoff f(x) = x makes the
  higher-drift regime strictly more valuable.
- ou_estimator test: stderr(kappa) ~ sqrt(2*kappa/T) was 140% of kappa with
  n=500 and an unseeded rng; now seeded StdRng + T ~ 80y (stderr ~ 22%).
- hurst test: fixture passed alternating +-1 LEVELS; R/S input convention
  is the increment series -> seeded iid +-1 increments.
- hmm doctest: placeholder example executed empty data -> rust,no_run.

Tests: 139/139 (129 lib + 10 doc).
This commit is contained in:
ThotDjehuty
2026-07-07 18:13:53 +02:00
parent 0463382fcb
commit dda201f0d3
6 changed files with 247 additions and 195 deletions
+1 -1
View File
@@ -11,7 +11,7 @@
//!
//! # Example
//!
//! ```rust
//! ```rust,no_run
//! use optimizr::hmm::{HMMConfig, HMM, GaussianEmission};
//!
//! let config = HMMConfig::<GaussianEmission>::builder(3)
+63 -38
View File
@@ -5,7 +5,6 @@
use crate::optimal_control::{OptimalControlError, Result};
use ndarray::Array1;
use rayon::prelude::*;
/// Configuration for HJB solver
#[derive(Debug, Clone)]
@@ -119,52 +118,77 @@ impl HJBSolver {
let mut v = Array1::<f64>::zeros(cfg.n_points);
let mut v_old = Array1::<f64>::zeros(cfg.n_points);
// Coefficients for finite differences
let drift_coeff = cfg.kappa / (2.0 * dx);
let diffusion_coeff = 0.5 * cfg.sigma.powi(2) / dx.powi(2);
// Running reward: quadratic tracking penalty around θ (maximisation of
// -(x-θ)²). Without a source term the stationary equation ρV = LV has
// only the trivial solution V ≡ 0, which made gradients — and hence
// the V' = ±1 switching boundaries — meaningless.
let f: Vec<f64> = x.iter().map(|&xi| -(xi - cfg.theta).powi(2)).collect();
// Iterative solver
let sig2 = cfg.sigma * cfg.sigma;
let dx2 = dx * dx;
// Iterative solver: implicit (Thomas) solve of the linear part
// ρV = κ(θ-x)V' + ½σ²V'' + f (KushnerDupuis upwind rates), followed
// by projection on the singular-control obstacles
// V(x) ≥ V(x±dx) - dx (unit proportional control cost),
// repeated until the fixed point. A pointwise Jacobi update diverges
// here (σ²/dx² ≫ ρ) and plain value iteration contracts too slowly.
let n = cfg.n_points;
let mut iterations = 0;
let mut residual = f64::INFINITY;
let mut sub = vec![0.0_f64; n];
let mut diag = vec![0.0_f64; n];
let mut sup = vec![0.0_f64; n];
let mut rhs = vec![0.0_f64; n];
for iter in 0..cfg.max_iter {
v_old.assign(&v);
// Interior points (parallel computation)
let _v_slice = v.as_slice().unwrap();
let x_slice = x.as_slice().unwrap();
let v_old_slice = v_old.as_slice().unwrap();
// Assemble tridiagonal system (upwind, unconditionally stable)
for i in 1..n - 1 {
let mu = cfg.kappa * (cfg.theta - x[i]);
let p_up = 0.5 * sig2 / dx2 + mu.max(0.0) / dx;
let p_dn = 0.5 * sig2 / dx2 + (-mu).max(0.0) / dx;
sub[i] = -p_dn;
diag[i] = cfg.rho + p_up + p_dn;
sup[i] = -p_up;
rhs[i] = f[i];
}
// Neumann boundaries: V'(x_min) = V'(x_max) = 0
diag[0] = 1.0;
sup[0] = -1.0;
rhs[0] = 0.0;
sub[n - 1] = -1.0;
diag[n - 1] = 1.0;
rhs[n - 1] = 0.0;
let interior_values: Vec<f64> = (1..cfg.n_points - 1)
.into_par_iter()
.map(|i| {
let xi = x_slice[i];
// Drift term: κ(θ - x) * dV/dx
let drift = cfg.kappa
* (cfg.theta - xi)
* (v_old_slice[i + 1] - v_old_slice[i - 1])
* drift_coeff
/ cfg.kappa;
// Diffusion term: (σ²/2) * d²V/dx²
let diffusion = (v_old_slice[i + 1] - 2.0 * v_old_slice[i]
+ v_old_slice[i - 1])
* diffusion_coeff;
// Update: ρV = drift + diffusion
(drift + diffusion) / cfg.rho
})
.collect();
// Update interior points
for (i, &val) in interior_values.iter().enumerate() {
v[i + 1] = val;
// Thomas algorithm
let mut d = diag.clone();
let mut r = rhs.clone();
for i in 1..n {
let w = sub[i] / d[i - 1];
d[i] -= w * sup[i - 1];
r[i] -= w * r[i - 1];
}
v[n - 1] = r[n - 1] / d[n - 1];
for i in (0..n - 1).rev() {
v[i] = (r[i] - sup[i] * v[i + 1]) / d[i];
}
// Boundary conditions (Neumann: dV/dx = 0 at boundaries)
v[0] = v[1];
v[cfg.n_points - 1] = v[cfg.n_points - 2];
// Obstacle projection: acting costs 1 per unit of displacement
for i in 1..n {
let candidate = v[i - 1] - dx;
if candidate > v[i] {
v[i] = candidate;
}
}
for i in (0..n - 1).rev() {
let candidate = v[i + 1] - dx;
if candidate > v[i] {
v[i] = candidate;
}
}
// Check convergence
residual = (&v - &v_old)
@@ -179,7 +203,8 @@ impl HJBSolver {
}
}
if residual >= cfg.tolerance {
// `!(a < b)` also catches NaN residuals
if !(residual < cfg.tolerance) {
return Err(OptimalControlError::ConvergenceError(format!(
"Failed to converge after {} iterations (residual: {:.2e})",
iterations, residual
+76 -66
View File
@@ -32,7 +32,6 @@ use crate::optimal_control::{
OptimalControlError, Result,
};
use ndarray::{Array1, Array2};
use rayon::prelude::*;
/// Regime-specific jump parameters
pub struct RegimeJumpParameters {
@@ -207,13 +206,14 @@ impl MRSJDSolver {
if residual < cfg.tolerance {
break;
}
// Relaxation
let omega = 0.5; // More conservative for stability
v = &v * omega + &v_old * (1.0 - omega);
// No under-relaxation: the implicit-in-space solve is
// unconditionally stable, damping only slows convergence.
}
if residual >= cfg.tolerance {
// NOTE: `!(residual < tolerance)` (rather than `residual >= tolerance`)
// also catches NaN residuals, which otherwise slip through both
// comparisons and produce a silently-invalid Ok result.
if !(residual < cfg.tolerance) {
return Err(OptimalControlError::ConvergenceError(format!(
"Failed to converge after {} iterations, residual = {:.2e}",
iterations, residual
@@ -253,80 +253,90 @@ impl MRSJDSolver {
) -> Result<()> {
let cfg = &self.config;
let params = &self.regime_params[regime];
let n = cfg.n_points;
// Compute jump integral for this regime
// Jump inflow λ·Σ_j k_ij v_old_j and outflow mass λ·Σ_j k_ij per node.
// The kernel row sum can be < 1 (jumps leaving the grid are dropped),
// so track it explicitly to keep the scheme conservative.
let lambda = params.jump_intensity;
for i in 0..cfg.n_points {
let mut integral = 0.0;
for j in 0..cfg.n_points {
integral += jump_kernel[[i, j]] * (v_old[[regime, j]] - v_old[[regime, i]]);
let mut jump_mass = vec![0.0_f64; n];
for i in 0..n {
let mut inflow = 0.0;
let mut mass = 0.0;
for j in 0..n {
inflow += jump_kernel[[i, j]] * v_old[[regime, j]];
mass += jump_kernel[[i, j]];
}
jump_int[[regime, i]] = lambda * integral;
jump_int[[regime, i]] = lambda * (inflow - mass * v_old[[regime, i]]);
jump_mass[i] = lambda * mass;
}
// Solve at interior points (parallel)
let updates: Vec<(usize, f64, f64)> = (1..cfg.n_points - 1)
.into_par_iter()
.map(|i| {
// Implicit-in-space solve (KushnerDupuis upwind discretisation).
// The stationary HJB ρv = μ v' + ½σ² v'' + jump + switching + cost
// is rearranged into a diagonally dominant tridiagonal system per
// regime (jumps and regime coupling explicit via v_old), which is
// unconditionally stable — a pointwise Jacobi update diverges here
// because σ²/dx² ≫ ρ.
let mut sub = vec![0.0_f64; n]; // a_i · v_{i-1}
let mut diag = vec![0.0_f64; n]; // b_i · v_i
let mut sup = vec![0.0_f64; n]; // c_i · v_{i+1}
let mut rhs = vec![0.0_f64; n];
for i in 1..n - 1 {
let xi = x[i];
// Get values
let v_c = v_old[[regime, i]];
let v_f = v_old[[regime, i + 1]];
let v_b = v_old[[regime, i - 1]];
// Derivatives
let dv_forward = (v_f - v_c) / dx;
let dv_backward = (v_c - v_b) / dx;
let d2v = (v_f - 2.0 * v_c + v_b) / (dx * dx);
// Regime-specific parameters
let mu = (params.drift)(xi);
let sigma = (params.diffusion)(xi);
let sig2 = sigma * sigma;
let mu_p = mu.max(0.0);
let mu_m = mu.min(0.0);
// Upwind scheme
let drift_term = if mu >= 0.0 {
mu * dv_backward
} else {
mu * dv_forward
};
// Diffusion
let diffusion_term = 0.5 * sigma * sigma * d2v;
// Jump integral
let jump_term = jump_int[[regime, i]];
// Regime switching term
let switching_term: f64 = (0..cfg.n_regimes)
.filter(|&j| j != regime)
.map(|j| q[[regime, j]] * (v_old[[j, i]] - v_c))
.sum();
// Optimal control (placeholder - can be optimized)
let optimal_control =
self.optimize_control_mrsjd(xi, dv_forward, dv_backward, params);
// Running cost
// Control from the current value gradient (policy-iteration style)
let dv_forward = (v_old[[regime, i + 1]] - v_old[[regime, i]]) / dx;
let dv_backward = (v_old[[regime, i]] - v_old[[regime, i - 1]]) / dx;
let optimal_control = self.optimize_control_mrsjd(xi, dv_forward, dv_backward, params);
u[[regime, i]] = optimal_control;
let cost = (params.cost)(xi, optimal_control);
// HJB update
let new_value =
(drift_term + diffusion_term + jump_term + switching_term + cost) / cfg.rho;
// Total outflow rate to other regimes
let q_out: f64 = (0..cfg.n_regimes)
.filter(|&j| j != regime)
.map(|j| q[[regime, j]])
.sum();
(i, new_value, optimal_control)
})
.collect();
sub[i] = -(mu_p / dx + 0.5 * sig2 / (dx * dx));
sup[i] = mu_m / dx - 0.5 * sig2 / (dx * dx);
diag[i] = cfg.rho + mu_p / dx - mu_m / dx + sig2 / (dx * dx) + jump_mass[i] + q_out;
// Apply updates
for (i, new_value, optimal_control) in updates {
v[[regime, i]] = new_value;
u[[regime, i]] = optimal_control;
// Explicit couplings: jump inflow + other-regime values
let switching_in: f64 = (0..cfg.n_regimes)
.filter(|&j| j != regime)
.map(|j| q[[regime, j]] * v_old[[j, i]])
.sum();
let jump_inflow = jump_int[[regime, i]] + jump_mass[i] * v_old[[regime, i]];
rhs[i] = cost + jump_inflow + switching_in;
}
// Boundaries
v[[regime, 0]] = v[[regime, 1]];
v[[regime, cfg.n_points - 1]] = v[[regime, cfg.n_points - 2]];
// Neumann boundaries: v_0 = v_1, v_{n-1} = v_{n-2}
diag[0] = 1.0;
sup[0] = -1.0;
rhs[0] = 0.0;
sub[n - 1] = -1.0;
diag[n - 1] = 1.0;
rhs[n - 1] = 0.0;
// Thomas algorithm (forward sweep + back substitution)
for i in 1..n {
let w = sub[i] / diag[i - 1];
diag[i] -= w * sup[i - 1];
rhs[i] -= w * rhs[i - 1];
}
v[[regime, n - 1]] = rhs[n - 1] / diag[n - 1];
for i in (0..n - 1).rev() {
v[[regime, i]] = (rhs[i] - sup[i] * v[[regime, i + 1]]) / diag[i];
}
u[[regime, 0]] = u[[regime, 1]];
u[[regime, n - 1]] = u[[regime, n - 2]];
Ok(())
}
@@ -465,7 +475,7 @@ mod tests {
n_regimes: 2,
transition_rates: q,
state_bounds: (-1.0, 3.0),
n_points: 100,
n_points: 200,
rho: 0.05,
transaction_cost: 0.0,
max_iter: 200,
+11 -5
View File
@@ -185,19 +185,25 @@ pub fn estimate_ou_params_mle(spread: &[f64], dt: f64) -> Result<OUParams> {
#[cfg(test)]
mod tests {
use super::*;
use rand::{thread_rng, Rng};
use rand::Rng;
use rand_distr::Normal;
#[test]
fn test_ou_estimation_simulated_data() {
// Simulate OU process
// Simulate OU process.
//
// Statistical design: stderr(κ̂) ≈ √(2κ/T). The previous version used
// n = 500 daily steps (T ≈ 2 y) → stderr ≈ 0.7 = 140 % of κ, so the
// 30 % tolerance failed for most draws of an UNSEEDED rng. Use a
// fixed seed (determinism) and T ≈ 80 y so stderr ≈ 0.11 (22 %).
let true_kappa = 0.5;
let true_theta = 0.0;
let true_sigma = 0.2;
let dt: f64 = 1.0 / 252.0;
let n = 500;
let n = 20_000;
let mut rng = thread_rng();
use rand::SeedableRng;
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
let normal = Normal::new(0.0, 1.0).unwrap();
let mut spread = vec![0.0; n];
@@ -212,7 +218,7 @@ mod tests {
// Estimate parameters
let params = estimate_ou_params(&spread, dt).unwrap();
// Check accuracy (within 30% for stochastic simulation)
// Check accuracy (2σ-level tolerances for the seeded draw)
assert!((params.kappa - true_kappa).abs() / true_kappa < 0.3);
assert!((params.theta - true_theta).abs() < 0.1);
assert!((params.sigma - true_sigma).abs() / true_sigma < 0.3);
+66 -63
View File
@@ -24,7 +24,6 @@
use crate::optimal_control::{OptimalControlError, Result};
use ndarray::{Array1, Array2};
use rayon::prelude::*;
// use statrs::distribution::{ContinuousCDF, Normal};
/// Regime-specific parameters
@@ -191,13 +190,12 @@ impl RegimeSwitchingSolver {
if residual < cfg.tolerance {
break;
}
// Relaxation for stability
let omega = 0.7;
v = &v * omega + &v_old * (1.0 - omega);
// No under-relaxation: the implicit-in-space solve is
// unconditionally stable, damping only slows convergence.
}
if residual >= cfg.tolerance {
// `!(a < b)` also catches NaN residuals
if !(residual < cfg.tolerance) {
return Err(OptimalControlError::ConvergenceError(format!(
"Failed to converge after {} iterations, residual = {}",
iterations, residual
@@ -234,68 +232,69 @@ impl RegimeSwitchingSolver {
) -> Result<()> {
let cfg = &self.config;
let params = &self.regime_params[regime];
let n = cfg.n_points;
// Interior points (parallel)
let updates: Vec<(usize, f64, f64)> = (1..cfg.n_points - 1)
.into_par_iter()
.map(|i| {
// Implicit-in-space solve (KushnerDupuis upwind discretisation).
// The stationary HJB ρv = μ v' + ½σ² v'' + running + switching is
// assembled into a diagonally dominant tridiagonal system per regime
// (regime coupling explicit via v_old) and solved with the Thomas
// algorithm — a pointwise Jacobi update diverges because σ²/dx² ≫ ρ.
let mut sub = vec![0.0_f64; n];
let mut diag = vec![0.0_f64; n];
let mut sup = vec![0.0_f64; n];
let mut rhs = vec![0.0_f64; n];
for i in 1..n - 1 {
let xi = x[i];
let mu = (params.drift)(xi);
let sigma = (params.diffusion)(xi);
let sig2 = sigma * sigma;
// Current value and neighbors
let v_center = v_old[[regime, i]];
let v_forward = v_old[[regime, i + 1]];
let v_backward = v_old[[regime, i - 1]];
// Control from the current value gradient (policy-iteration style)
let dv_forward = (v_old[[regime, i + 1]] - v_old[[regime, i]]) / dx;
let dv_backward = (v_old[[regime, i]] - v_old[[regime, i - 1]]) / dx;
let optimal_control = self.optimize_control(xi, dv_forward, dv_backward, params);
u[[regime, i]] = optimal_control;
let running = (params.cost)(xi, optimal_control);
// Gradients (finite differences)
let dv_forward = (v_forward - v_center) / dx;
let dv_backward = (v_center - v_backward) / dx;
let d2v = (v_forward - 2.0 * v_center + v_backward) / (dx * dx);
// Regime-specific drift and diffusion
let _mu_xi = (params.drift)(xi);
let _sigma_xi = (params.diffusion)(xi);
// Optimal control via pointwise optimization
// For portfolio: u* = argmax_u [μ(x,u)·dV/dx + L(x,u)]
let optimal_control = self.optimize_control(xi, dv_forward, dv_backward, &params);
// HJB operator with optimal control
let mu_optimal = (params.drift)(xi); // Could depend on control
let sigma_optimal = (params.diffusion)(xi);
let cost = (params.cost)(xi, optimal_control);
// Upwind scheme for drift
let drift_term = if mu_optimal >= 0.0 {
mu_optimal * dv_backward
} else {
mu_optimal * dv_forward
};
// Diffusion term
let diffusion_term = 0.5 * sigma_optimal * sigma_optimal * d2v;
// Regime switching term: Σ_{j≠i} q_ij(V^j(x) - V^i(x))
let switching_term: f64 = (0..cfg.n_regimes)
let q_out: f64 = (0..cfg.n_regimes)
.filter(|&j| j != regime)
.map(|j| q[[regime, j]] * (v_old[[j, i]] - v_center))
.map(|j| q[[regime, j]])
.sum();
let switching_in: f64 = (0..cfg.n_regimes)
.filter(|&j| j != regime)
.map(|j| q[[regime, j]] * v_old[[j, i]])
.sum();
// Update: ρV = drift + diffusion + cost + switching
let new_value = (drift_term + diffusion_term + cost + switching_term) / cfg.rho;
(i, new_value, optimal_control)
})
.collect();
// Apply updates
for (i, new_value, optimal_control) in updates {
v[[regime, i]] = new_value;
u[[regime, i]] = optimal_control;
let p_up = 0.5 * sig2 / (dx * dx) + mu.max(0.0) / dx;
let p_dn = 0.5 * sig2 / (dx * dx) + (-mu).max(0.0) / dx;
sub[i] = -p_dn;
sup[i] = -p_up;
diag[i] = cfg.rho + p_up + p_dn + q_out;
rhs[i] = running + switching_in;
}
// Boundary conditions (reflecting or absorbing)
v[[regime, 0]] = v[[regime, 1]];
v[[regime, cfg.n_points - 1]] = v[[regime, cfg.n_points - 2]];
// Neumann boundaries: v_0 = v_1, v_{n-1} = v_{n-2}
diag[0] = 1.0;
sup[0] = -1.0;
rhs[0] = 0.0;
sub[n - 1] = -1.0;
diag[n - 1] = 1.0;
rhs[n - 1] = 0.0;
// Thomas algorithm (forward sweep + back substitution)
for i in 1..n {
let w = sub[i] / diag[i - 1];
diag[i] -= w * sup[i - 1];
rhs[i] -= w * rhs[i - 1];
}
v[[regime, n - 1]] = rhs[n - 1] / diag[n - 1];
for i in (0..n - 1).rev() {
v[[regime, i]] = (rhs[i] - sup[i] * v[[regime, i + 1]]) / diag[i];
}
u[[regime, 0]] = u[[regime, 1]];
u[[regime, n - 1]] = u[[regime, n - 2]];
Ok(())
}
@@ -398,18 +397,22 @@ impl RegimeSwitchingSolver {
..Default::default()
};
// Bull regime parameters
// Regime 0 parameters (higher drift, lower volatility).
// Running payoff f(x) = x: the value of the state stream. With a
// zero running term the stationary equation only admits V ≡ 0 and
// regime comparisons are meaningless; with f(x) = x the higher-drift
// regime has a strictly larger value at every interior point.
let params_bull = RegimeParameters {
drift: Box::new(move |_x| mu_bull),
diffusion: Box::new(move |_x| sigma_bull),
cost: Box::new(|_x, _u| 0.0), // No running cost
cost: Box::new(|x, _u| x),
};
// Bear regime parameters
// Regime 1 parameters (lower drift, higher volatility)
let params_bear = RegimeParameters {
drift: Box::new(move |_x| mu_bear),
diffusion: Box::new(move |_x| sigma_bear),
cost: Box::new(|_x, _u| 0.0),
cost: Box::new(|x, _u| x),
};
Self::new(config, vec![params_bull, params_bear])
+17 -9
View File
@@ -548,18 +548,26 @@ mod tests {
#[test]
fn test_hurst_random_walk() {
// Random walk should have H ≈ 0.5
let n = 1000;
let mut series = vec![0.0];
for i in 1..n {
series.push(series[i - 1] + if i % 2 == 0 { 1.0 } else { -1.0 });
}
// R/S analysis takes the INCREMENT series as input (it cumulates
// internally); iid increments of a random walk give H ≈ 0.5.
// The previous fixture passed deterministic alternating ±1 LEVELS —
// wrong convention and wrong process, failing by construction.
use rand::{Rng, SeedableRng};
let mut rng = rand::rngs::StdRng::seed_from_u64(7);
let n = 4096;
let increments: Vec<f64> = (0..n)
.map(|_| if rng.gen::<bool>() { 1.0 } else { -1.0 })
.collect();
let series = Array1::from_vec(series);
let result = hurst_exponent(&series, &[8, 16, 32, 64]).unwrap();
let series = Array1::from_vec(increments);
let result = hurst_exponent(&series, &[8, 16, 32, 64, 128]).unwrap();
// Should be close to 0.5
assert!((result.hurst_exponent - 0.5).abs() < 0.2);
assert!(
(result.hurst_exponent - 0.5).abs() < 0.2,
"H = {}",
result.hurst_exponent
);
}
#[test]