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 //! # Example
//! //!
//! ```rust //! ```rust,no_run
//! use optimizr::hmm::{HMMConfig, HMM, GaussianEmission}; //! use optimizr::hmm::{HMMConfig, HMM, GaussianEmission};
//! //!
//! let config = HMMConfig::<GaussianEmission>::builder(3) //! let config = HMMConfig::<GaussianEmission>::builder(3)
+63 -38
View File
@@ -5,7 +5,6 @@
use crate::optimal_control::{OptimalControlError, Result}; use crate::optimal_control::{OptimalControlError, Result};
use ndarray::Array1; use ndarray::Array1;
use rayon::prelude::*;
/// Configuration for HJB solver /// Configuration for HJB solver
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -119,52 +118,77 @@ impl HJBSolver {
let mut v = Array1::<f64>::zeros(cfg.n_points); let mut v = Array1::<f64>::zeros(cfg.n_points);
let mut v_old = Array1::<f64>::zeros(cfg.n_points); let mut v_old = Array1::<f64>::zeros(cfg.n_points);
// Coefficients for finite differences // Running reward: quadratic tracking penalty around θ (maximisation of
let drift_coeff = cfg.kappa / (2.0 * dx); // -(x-θ)²). Without a source term the stationary equation ρV = LV has
let diffusion_coeff = 0.5 * cfg.sigma.powi(2) / dx.powi(2); // 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 iterations = 0;
let mut residual = f64::INFINITY; 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 { for iter in 0..cfg.max_iter {
v_old.assign(&v); v_old.assign(&v);
// Interior points (parallel computation) // Assemble tridiagonal system (upwind, unconditionally stable)
let _v_slice = v.as_slice().unwrap(); for i in 1..n - 1 {
let x_slice = x.as_slice().unwrap(); let mu = cfg.kappa * (cfg.theta - x[i]);
let v_old_slice = v_old.as_slice().unwrap(); 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) // Thomas algorithm
.into_par_iter() let mut d = diag.clone();
.map(|i| { let mut r = rhs.clone();
let xi = x_slice[i]; for i in 1..n {
let w = sub[i] / d[i - 1];
// Drift term: κ(θ - x) * dV/dx d[i] -= w * sup[i - 1];
let drift = cfg.kappa r[i] -= w * r[i - 1];
* (cfg.theta - xi) }
* (v_old_slice[i + 1] - v_old_slice[i - 1]) v[n - 1] = r[n - 1] / d[n - 1];
* drift_coeff for i in (0..n - 1).rev() {
/ cfg.kappa; v[i] = (r[i] - sup[i] * v[i + 1]) / d[i];
// 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;
} }
// Boundary conditions (Neumann: dV/dx = 0 at boundaries) // Obstacle projection: acting costs 1 per unit of displacement
v[0] = v[1]; for i in 1..n {
v[cfg.n_points - 1] = v[cfg.n_points - 2]; 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 // Check convergence
residual = (&v - &v_old) 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!( return Err(OptimalControlError::ConvergenceError(format!(
"Failed to converge after {} iterations (residual: {:.2e})", "Failed to converge after {} iterations (residual: {:.2e})",
iterations, residual iterations, residual
+81 -71
View File
@@ -32,7 +32,6 @@ use crate::optimal_control::{
OptimalControlError, Result, OptimalControlError, Result,
}; };
use ndarray::{Array1, Array2}; use ndarray::{Array1, Array2};
use rayon::prelude::*;
/// Regime-specific jump parameters /// Regime-specific jump parameters
pub struct RegimeJumpParameters { pub struct RegimeJumpParameters {
@@ -207,13 +206,14 @@ impl MRSJDSolver {
if residual < cfg.tolerance { if residual < cfg.tolerance {
break; break;
} }
// No under-relaxation: the implicit-in-space solve is
// Relaxation // unconditionally stable, damping only slows convergence.
let omega = 0.5; // More conservative for stability
v = &v * omega + &v_old * (1.0 - omega);
} }
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!( return Err(OptimalControlError::ConvergenceError(format!(
"Failed to converge after {} iterations, residual = {:.2e}", "Failed to converge after {} iterations, residual = {:.2e}",
iterations, residual iterations, residual
@@ -253,80 +253,90 @@ impl MRSJDSolver {
) -> Result<()> { ) -> Result<()> {
let cfg = &self.config; let cfg = &self.config;
let params = &self.regime_params[regime]; 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; let lambda = params.jump_intensity;
for i in 0..cfg.n_points { let mut jump_mass = vec![0.0_f64; n];
let mut integral = 0.0; for i in 0..n {
for j in 0..cfg.n_points { let mut inflow = 0.0;
integral += jump_kernel[[i, j]] * (v_old[[regime, j]] - v_old[[regime, i]]); 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) // Implicit-in-space solve (KushnerDupuis upwind discretisation).
let updates: Vec<(usize, f64, f64)> = (1..cfg.n_points - 1) // The stationary HJB ρv = μ v' + ½σ² v'' + jump + switching + cost
.into_par_iter() // is rearranged into a diagonally dominant tridiagonal system per
.map(|i| { // regime (jumps and regime coupling explicit via v_old), which is
let xi = x[i]; // 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 for i in 1..n - 1 {
let v_c = v_old[[regime, i]]; let xi = x[i];
let v_f = v_old[[regime, i + 1]]; let mu = (params.drift)(xi);
let v_b = v_old[[regime, i - 1]]; let sigma = (params.diffusion)(xi);
let sig2 = sigma * sigma;
let mu_p = mu.max(0.0);
let mu_m = mu.min(0.0);
// Derivatives // Control from the current value gradient (policy-iteration style)
let dv_forward = (v_f - v_c) / dx; let dv_forward = (v_old[[regime, i + 1]] - v_old[[regime, i]]) / dx;
let dv_backward = (v_c - v_b) / dx; let dv_backward = (v_old[[regime, i]] - v_old[[regime, i - 1]]) / dx;
let d2v = (v_f - 2.0 * v_c + v_b) / (dx * dx); let optimal_control = self.optimize_control_mrsjd(xi, dv_forward, dv_backward, params);
// 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;
u[[regime, i]] = optimal_control; 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 // Neumann boundaries: v_0 = v_1, v_{n-1} = v_{n-2}
v[[regime, 0]] = v[[regime, 1]]; diag[0] = 1.0;
v[[regime, cfg.n_points - 1]] = v[[regime, cfg.n_points - 2]]; 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(()) Ok(())
} }
@@ -465,7 +475,7 @@ mod tests {
n_regimes: 2, n_regimes: 2,
transition_rates: q, transition_rates: q,
state_bounds: (-1.0, 3.0), state_bounds: (-1.0, 3.0),
n_points: 100, n_points: 200,
rho: 0.05, rho: 0.05,
transaction_cost: 0.0, transaction_cost: 0.0,
max_iter: 200, 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use rand::{thread_rng, Rng}; use rand::Rng;
use rand_distr::Normal; use rand_distr::Normal;
#[test] #[test]
fn test_ou_estimation_simulated_data() { 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_kappa = 0.5;
let true_theta = 0.0; let true_theta = 0.0;
let true_sigma = 0.2; let true_sigma = 0.2;
let dt: f64 = 1.0 / 252.0; 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 normal = Normal::new(0.0, 1.0).unwrap();
let mut spread = vec![0.0; n]; let mut spread = vec![0.0; n];
@@ -212,7 +218,7 @@ mod tests {
// Estimate parameters // Estimate parameters
let params = estimate_ou_params(&spread, dt).unwrap(); 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.kappa - true_kappa).abs() / true_kappa < 0.3);
assert!((params.theta - true_theta).abs() < 0.1); assert!((params.theta - true_theta).abs() < 0.1);
assert!((params.sigma - true_sigma).abs() / true_sigma < 0.3); assert!((params.sigma - true_sigma).abs() / true_sigma < 0.3);
+69 -66
View File
@@ -24,7 +24,6 @@
use crate::optimal_control::{OptimalControlError, Result}; use crate::optimal_control::{OptimalControlError, Result};
use ndarray::{Array1, Array2}; use ndarray::{Array1, Array2};
use rayon::prelude::*;
// use statrs::distribution::{ContinuousCDF, Normal}; // use statrs::distribution::{ContinuousCDF, Normal};
/// Regime-specific parameters /// Regime-specific parameters
@@ -191,13 +190,12 @@ impl RegimeSwitchingSolver {
if residual < cfg.tolerance { if residual < cfg.tolerance {
break; break;
} }
// No under-relaxation: the implicit-in-space solve is
// Relaxation for stability // unconditionally stable, damping only slows convergence.
let omega = 0.7;
v = &v * omega + &v_old * (1.0 - omega);
} }
if residual >= cfg.tolerance { // `!(a < b)` also catches NaN residuals
if !(residual < cfg.tolerance) {
return Err(OptimalControlError::ConvergenceError(format!( return Err(OptimalControlError::ConvergenceError(format!(
"Failed to converge after {} iterations, residual = {}", "Failed to converge after {} iterations, residual = {}",
iterations, residual iterations, residual
@@ -234,68 +232,69 @@ impl RegimeSwitchingSolver {
) -> Result<()> { ) -> Result<()> {
let cfg = &self.config; let cfg = &self.config;
let params = &self.regime_params[regime]; let params = &self.regime_params[regime];
let n = cfg.n_points;
// Interior points (parallel) // Implicit-in-space solve (KushnerDupuis upwind discretisation).
let updates: Vec<(usize, f64, f64)> = (1..cfg.n_points - 1) // The stationary HJB ρv = μ v' + ½σ² v'' + running + switching is
.into_par_iter() // assembled into a diagonally dominant tridiagonal system per regime
.map(|i| { // (regime coupling explicit via v_old) and solved with the Thomas
let xi = x[i]; // 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 for i in 1..n - 1 {
let v_center = v_old[[regime, i]]; let xi = x[i];
let v_forward = v_old[[regime, i + 1]]; let mu = (params.drift)(xi);
let v_backward = v_old[[regime, i - 1]]; let sigma = (params.diffusion)(xi);
let sig2 = sigma * sigma;
// Gradients (finite differences) // Control from the current value gradient (policy-iteration style)
let dv_forward = (v_forward - v_center) / dx; let dv_forward = (v_old[[regime, i + 1]] - v_old[[regime, i]]) / dx;
let dv_backward = (v_center - v_backward) / dx; let dv_backward = (v_old[[regime, i]] - v_old[[regime, i - 1]]) / dx;
let d2v = (v_forward - 2.0 * v_center + v_backward) / (dx * dx); let optimal_control = self.optimize_control(xi, dv_forward, dv_backward, params);
// 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)
.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;
u[[regime, i]] = optimal_control; 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) // Neumann boundaries: v_0 = v_1, v_{n-1} = v_{n-2}
v[[regime, 0]] = v[[regime, 1]]; diag[0] = 1.0;
v[[regime, cfg.n_points - 1]] = v[[regime, cfg.n_points - 2]]; 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(()) Ok(())
} }
@@ -398,18 +397,22 @@ impl RegimeSwitchingSolver {
..Default::default() ..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 { let params_bull = RegimeParameters {
drift: Box::new(move |_x| mu_bull), drift: Box::new(move |_x| mu_bull),
diffusion: Box::new(move |_x| sigma_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 { let params_bear = RegimeParameters {
drift: Box::new(move |_x| mu_bear), drift: Box::new(move |_x| mu_bear),
diffusion: Box::new(move |_x| sigma_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]) Self::new(config, vec![params_bull, params_bear])
+17 -9
View File
@@ -548,18 +548,26 @@ mod tests {
#[test] #[test]
fn test_hurst_random_walk() { fn test_hurst_random_walk() {
// Random walk should have H ≈ 0.5 // R/S analysis takes the INCREMENT series as input (it cumulates
let n = 1000; // internally); iid increments of a random walk give H ≈ 0.5.
let mut series = vec![0.0]; // The previous fixture passed deterministic alternating ±1 LEVELS —
for i in 1..n { // wrong convention and wrong process, failing by construction.
series.push(series[i - 1] + if i % 2 == 0 { 1.0 } else { -1.0 }); 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 series = Array1::from_vec(increments);
let result = hurst_exponent(&series, &[8, 16, 32, 64]).unwrap(); let result = hurst_exponent(&series, &[8, 16, 32, 64, 128]).unwrap();
// Should be close to 0.5 // 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] #[test]