Files
foxhunt/crates/ml/src/validation/regime_analysis.rs
jgrusewski 09c515e3e9 fix(clippy): ZERO errors across entire workspace — CI ready
Final 31 ml crate fixes: unsafe_code allows, unused vars prefixed,
boolean simplification, dead code removal, integer suffix, drop cleanup.

cargo fix auto-removed ~30 unused imports from ml crate.

Total clippy cleanup: 278 errors → 0 across all ML crates.
Full workspace: `cargo clippy --workspace --lib -- -D warnings` = 0 errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 01:04:09 +01:00

282 lines
10 KiB
Rust

//! Per-regime performance analysis.
//!
//! Groups daily returns by market regime and computes performance metrics
//! (Sharpe ratio, win rate, average return) for each regime bucket.
//!
//! Provides both a CPU fallback ([`per_regime_breakdown`]) and a GPU-accelerated
//! path ([`per_regime_breakdown_gpu`]) that classifies regimes via tensor ops on
//! device, reading back only 3 mask vectors for the final grouping.
use std::collections::HashMap;
use ml_core::cuda_autograd::GpuTensor;
use ml_core::device::MlDevice;
use ml_core::native_types::NativeDevice;
use serde::{Deserialize, Serialize};
use crate::dqn::{RegimeType, RegimeClassConfig};
use ml_validation::statistical::sharpe_ratio;
/// Per-regime performance metrics.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegimeMetrics {
/// The market regime this bucket corresponds to.
pub regime: RegimeType,
/// Sharpe ratio of returns in this regime.
pub sharpe: f64,
/// Number of bars observed in this regime.
pub num_bars: usize,
/// Fraction of bars with positive returns (0.0-1.0).
pub win_rate: f64,
/// Arithmetic mean of returns in this regime.
pub avg_return: f64,
}
/// Break down daily returns by market regime and compute per-regime metrics.
///
/// Iterates over `min(daily_returns.len(), features.len())` bars. Each bar is
/// classified into a regime via [`RegimeType::classify_from_features`], and
/// returns are grouped accordingly. For each regime bucket the function
/// computes the Sharpe ratio, win rate, average return, and bar count.
///
/// Returns an empty [`HashMap`] when both inputs are empty.
pub fn per_regime_breakdown(
daily_returns: &[f64],
features: &[Vec<f32>],
) -> HashMap<RegimeType, RegimeMetrics> {
let n = daily_returns.len().min(features.len());
// Group returns by regime using safe indexing via iterators.
let mut regime_returns: HashMap<RegimeType, Vec<f64>> = HashMap::new();
let regime_cfg = RegimeClassConfig::default();
for (ret, feat) in daily_returns.iter().zip(features.iter()).take(n) {
let regime = RegimeType::classify_from_features(feat, &regime_cfg);
regime_returns.entry(regime).or_default().push(*ret);
}
// Compute metrics for each regime bucket.
compute_regime_metrics(regime_returns)
}
/// Minimum number of features required for GPU regime classification.
///
/// The GPU path needs ADX at index 40 and CUSUM direction at index 41 (via
/// [`RegimeClassConfig`] defaults), so each feature vector must have at least
/// 42 elements.
const MIN_FEATURES_FOR_GPU: usize = 42;
/// GPU-accelerated per-regime breakdown using tensor-based classification.
///
/// Builds a `[n, feature_dim]` tensor on `device`, calls
/// [`RegimeType::classify_regime_masks_gpu`] to produce binary masks entirely
/// on device, then reads back the 3 mask vectors (single GPU-to-CPU transfer)
/// and groups returns on the CPU side.
///
/// Falls back to the CPU path ([`per_regime_breakdown`]) when:
/// - Any feature vector has fewer than [`MIN_FEATURES_FOR_GPU`] elements
/// - Tensor construction or GPU classification fails
///
/// # Errors
///
/// Returns [`MLError`] only on truly unrecoverable failures. Soft errors
/// (short features, GPU OOM) are caught and redirected to the CPU fallback.
pub fn per_regime_breakdown_gpu(
daily_returns: &[f64],
features: &[Vec<f32>],
device: &NativeDevice,
) -> Result<HashMap<RegimeType, RegimeMetrics>, crate::MLError> {
let n = daily_returns.len().min(features.len());
if n == 0 {
return Ok(HashMap::new());
}
// All feature vectors must be long enough for the GPU regime classifier.
let feature_dim = features.iter().take(n).map(Vec::len).min().unwrap_or(0);
if feature_dim < MIN_FEATURES_FOR_GPU {
return Ok(per_regime_breakdown(daily_returns, features));
}
// Get CUDA ordinal from device; fall back to CPU if not CUDA.
let ordinal = match device.cuda_ordinal() {
Some(id) => id,
None => return Ok(per_regime_breakdown(daily_returns, features)),
};
// Create MlDevice to obtain a CUDA stream.
let ml_device = match MlDevice::cuda(ordinal) {
Ok(d) => d,
Err(_) => return Ok(per_regime_breakdown(daily_returns, features)),
};
let stream = match ml_device.cuda_stream() {
Ok(s) => s,
Err(_) => return Ok(per_regime_breakdown(daily_returns, features)),
};
// Build a flat f32 buffer, truncating each row to `feature_dim`.
let mut flat: Vec<f32> = Vec::with_capacity(n * feature_dim);
for feat in features.iter().take(n) {
flat.extend_from_slice(feat.get(..feature_dim).unwrap_or(feat));
}
// Construct the [n, feature_dim] GpuTensor on device.
let states = match GpuTensor::from_host(&flat, vec![n, feature_dim], stream) {
Ok(t) => t,
Err(_) => return Ok(per_regime_breakdown(daily_returns, features)),
};
// Run GPU regime classification.
let regime_cfg = RegimeClassConfig::default();
let (trending_mask, ranging_mask, volatile_mask) =
match RegimeType::classify_regime_masks_gpu(&states, &regime_cfg, stream) {
Ok(masks) => masks,
Err(_) => return Ok(per_regime_breakdown(daily_returns, features)),
};
// Read masks back to CPU — single transfer of 3 x n f32 values.
let trending: Vec<f32> = trending_mask.to_host(stream).map_err(|e| {
crate::MLError::ModelError(format!("trending mask readback: {e}"))
})?;
let ranging: Vec<f32> = ranging_mask.to_host(stream).map_err(|e| {
crate::MLError::ModelError(format!("ranging mask readback: {e}"))
})?;
let volatile: Vec<f32> = volatile_mask.to_host(stream).map_err(|e| {
crate::MLError::ModelError(format!("volatile mask readback: {e}"))
})?;
// Group returns by regime using the binary masks.
let mut regime_returns: HashMap<RegimeType, Vec<f64>> = HashMap::new();
for i in 0..n {
let ret = daily_returns.get(i).copied().unwrap_or(0.0);
let regime = if trending.get(i).copied().unwrap_or(0.0) > 0.5 {
RegimeType::Trending
} else if volatile.get(i).copied().unwrap_or(0.0) > 0.5 {
RegimeType::Volatile
} else if ranging.get(i).copied().unwrap_or(0.0) > 0.5 {
RegimeType::Ranging
} else {
// Fallback — should not happen with well-formed masks.
RegimeType::Ranging
};
regime_returns.entry(regime).or_default().push(ret);
}
// Compute per-regime metrics (same logic as the CPU path).
Ok(compute_regime_metrics(regime_returns))
}
/// Compute [`RegimeMetrics`] for each regime bucket from grouped returns.
///
/// Shared helper used by both the CPU and GPU paths.
fn compute_regime_metrics(
regime_returns: HashMap<RegimeType, Vec<f64>>,
) -> HashMap<RegimeType, RegimeMetrics> {
regime_returns
.into_iter()
.map(|(regime, returns)| {
let num_bars = returns.len();
let sharpe_val = sharpe_ratio(&returns);
let avg_return = if num_bars > 0 {
returns.iter().sum::<f64>() / num_bars as f64
} else {
0.0
};
let win_rate = if num_bars > 0 {
let wins = returns.iter().filter(|r| **r > 0.0).count();
wins as f64 / num_bars as f64
} else {
0.0
};
let metrics = RegimeMetrics {
regime,
sharpe: sharpe_val,
num_bars,
win_rate,
avg_return,
};
(regime, metrics)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_per_regime_breakdown_groups_correctly() {
// With short feature vectors (< 211 elements), all bars classify as Ranging.
let daily_returns = vec![0.01, -0.005, 0.02, -0.01, 0.015];
let features: Vec<Vec<f32>> = (0..5).map(|_| vec![0.0_f32; 50]).collect();
let result = per_regime_breakdown(&daily_returns, &features);
// All should fall into the Ranging bucket.
assert_eq!(result.len(), 1, "Expected exactly one regime bucket");
assert!(
result.contains_key(&RegimeType::Ranging),
"Expected Ranging regime bucket"
);
let ranging = result.get(&RegimeType::Ranging);
assert!(ranging.is_some(), "Ranging bucket should exist");
if let Some(m) = ranging {
assert_eq!(m.num_bars, 5, "Expected 5 bars in Ranging bucket");
assert_eq!(m.regime, RegimeType::Ranging);
}
}
#[test]
fn test_per_regime_metrics_computation() {
// All returns land in Ranging (short features), so we test metrics directly.
// Returns: [0.10, -0.05, 0.20, -0.10, 0.15]
// positive: 3 out of 5 -> win_rate = 0.6
// sum = 0.30, avg_return = 0.06
let daily_returns = vec![0.10, -0.05, 0.20, -0.10, 0.15];
let features: Vec<Vec<f32>> = (0..5).map(|_| vec![0.0_f32; 10]).collect();
let result = per_regime_breakdown(&daily_returns, &features);
let metrics = result
.get(&RegimeType::Ranging)
.expect("Ranging bucket must exist");
// win_rate: 3 positive out of 5
assert!(
(metrics.win_rate - 0.6).abs() < 1e-10,
"Expected win_rate=0.6, got {}",
metrics.win_rate
);
// avg_return: (0.10 + (-0.05) + 0.20 + (-0.10) + 0.15) / 5 = 0.06
assert!(
(metrics.avg_return - 0.06).abs() < 1e-10,
"Expected avg_return=0.06, got {}",
metrics.avg_return
);
// num_bars
assert_eq!(metrics.num_bars, 5);
// sharpe should be positive (mean > 0 with reasonable variance)
assert!(
metrics.sharpe > 0.0,
"Expected positive Sharpe, got {}",
metrics.sharpe
);
}
#[test]
fn test_empty_returns() {
let result = per_regime_breakdown(&[], &[]);
assert!(
result.is_empty(),
"Expected empty HashMap for empty inputs, got {} entries",
result.len()
);
}
}