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:
+1
-1
@@ -11,7 +11,7 @@
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```rust
|
||||
//! ```rust,no_run
|
||||
//! use optimizr::hmm::{HMMConfig, HMM, GaussianEmission};
|
||||
//!
|
||||
//! let config = HMMConfig::<GaussianEmission>::builder(3)
|
||||
|
||||
@@ -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 (Kushner–Dupuis 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
|
||||
|
||||
@@ -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| {
|
||||
let xi = x[i];
|
||||
// Implicit-in-space solve (Kushner–Dupuis 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];
|
||||
|
||||
// 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]];
|
||||
for i in 1..n - 1 {
|
||||
let xi = x[i];
|
||||
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);
|
||||
|
||||
// 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);
|
||||
|
||||
// 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
|
||||
let cost = (params.cost)(xi, optimal_control);
|
||||
|
||||
// HJB update
|
||||
let new_value =
|
||||
(drift_term + diffusion_term + jump_term + switching_term + cost) / cfg.rho;
|
||||
|
||||
(i, new_value, optimal_control)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Apply updates
|
||||
for (i, new_value, optimal_control) in updates {
|
||||
v[[regime, i]] = new_value;
|
||||
// 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);
|
||||
|
||||
// Total outflow rate to other regimes
|
||||
let q_out: f64 = (0..cfg.n_regimes)
|
||||
.filter(|&j| j != regime)
|
||||
.map(|j| q[[regime, j]])
|
||||
.sum();
|
||||
|
||||
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;
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -185,34 +185,40 @@ 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 mut rng = thread_rng();
|
||||
let n = 20_000;
|
||||
|
||||
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];
|
||||
spread[0] = true_theta;
|
||||
|
||||
|
||||
for i in 1..n {
|
||||
let dw = rng.sample(normal) * dt.sqrt();
|
||||
spread[i] = spread[i - 1] + true_kappa * (true_theta - spread[i - 1]) * dt
|
||||
+ true_sigma * dw;
|
||||
}
|
||||
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -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| {
|
||||
let xi = x[i];
|
||||
// Implicit-in-space solve (Kushner–Dupuis 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];
|
||||
|
||||
// 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]];
|
||||
for i in 1..n - 1 {
|
||||
let xi = x[i];
|
||||
let mu = (params.drift)(xi);
|
||||
let sigma = (params.diffusion)(xi);
|
||||
let sig2 = sigma * sigma;
|
||||
|
||||
// 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, ¶ms);
|
||||
|
||||
// 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)
|
||||
.filter(|&j| j != regime)
|
||||
.map(|j| q[[regime, j]] * (v_old[[j, i]] - v_center))
|
||||
.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;
|
||||
// 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);
|
||||
|
||||
let q_out: f64 = (0..cfg.n_regimes)
|
||||
.filter(|&j| j != regime)
|
||||
.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();
|
||||
|
||||
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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user