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>
282 lines
10 KiB
Rust
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, ®ime_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, ®ime_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()
|
|
);
|
|
}
|
|
}
|