Files
foxhunt/crates/ml-validation/src/temporal_guard.rs
jgrusewski db6462ba7a fix(clippy): resolve all clippy warnings across entire workspace (--all-targets)
Systematic fix of 360+ clippy errors across 37+ crates covering lib,
test, bench, and example targets. Key changes:

- Add targeted #[allow(...)] on #[cfg(test)] modules for test-only lints
  (assertions_on_result_states, float_cmp, str_to_string, indexing, etc.)
- Feature-gate broken integration tests behind __<crate>_integration flags
  where public APIs changed (trading-service, backtesting-service, etc.)
- Remove dead [[test]] entries from Cargo.toml files pointing to deleted files
- Fix production code: field_reassign_with_default, manual_range_contains,
  assert!(false) → panic!(), format!("{}") simplification, len() > 0 → !is_empty()
- Delete truly unused code (Order struct, unused methods/fields/variants)
- Convert sqlx::query!() to sqlx::query() for SQLX_OFFLINE compatibility

Result: cargo clippy --workspace --all-targets -- -D warnings = 0 errors, 0 warnings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:18:35 +01:00

375 lines
12 KiB
Rust

//! Temporal guard for preventing forward-looking data leakage.
//!
//! Wraps a [`TimeSeriesData`] reference with a cutoff index that partitions
//! the series into a training region `[0, cutoff)` and a test region
//! `[cutoff, len)`. Any attempt to slice across the boundary is rejected,
//! ensuring that training code never accidentally sees future data.
use chrono::{DateTime, Utc};
use crate::types::TimeSeriesData;
use crate::MLError;
// ---------------------------------------------------------------------------
// LeakageAuditReport
// ---------------------------------------------------------------------------
/// Result of a forward-leakage audit on a [`TemporalGuard`].
#[derive(Debug, Clone)]
pub struct LeakageAuditReport {
/// `true` if any training timestamp is >= any test timestamp.
pub has_future_timestamps: bool,
/// Number of bars in the training partition.
pub training_bars: usize,
/// The timestamp at the cutoff index, if available.
pub cutoff_timestamp: Option<DateTime<Utc>>,
}
// ---------------------------------------------------------------------------
// NormalizationStats
// ---------------------------------------------------------------------------
/// Feature-wise normalization statistics computed from training data only.
#[derive(Debug, Clone)]
pub struct NormalizationStats {
/// Per-feature means.
pub means: Vec<f64>,
/// Per-feature standard deviations.
pub stds: Vec<f64>,
/// Number of samples (bars) used.
pub sample_count: usize,
}
// ---------------------------------------------------------------------------
// TemporalGuard
// ---------------------------------------------------------------------------
/// Prevents forward-looking data leakage by enforcing a temporal cutoff.
///
/// The guard partitions the underlying [`TimeSeriesData`] into:
/// - **Training region**: `[0, cutoff_idx)`
/// - **Test region**: `[cutoff_idx, data.len())`
///
/// Slicing across the boundary is an error.
#[derive(Debug)]
pub struct TemporalGuard<'a> {
data: &'a TimeSeriesData,
cutoff_idx: usize,
}
impl<'a> TemporalGuard<'a> {
/// Create a new `TemporalGuard`.
///
/// # Errors
///
/// Returns [`MLError::InvalidInput`] if `cutoff_idx` is 0, less than 2
/// (training needs at least 2 bars), or exceeds `data.len()`.
pub fn new(data: &'a TimeSeriesData, cutoff_idx: usize) -> Result<Self, MLError> {
if cutoff_idx < 2 {
return Err(MLError::InvalidInput(format!(
"TemporalGuard cutoff must be >= 2 for valid training slice, got {}",
cutoff_idx,
)));
}
if cutoff_idx > data.len() {
return Err(MLError::InvalidInput(format!(
"TemporalGuard cutoff ({}) exceeds data length ({})",
cutoff_idx,
data.len(),
)));
}
Ok(Self { data, cutoff_idx })
}
/// Return the training partition `[0, cutoff)` as a new `TimeSeriesData`.
///
/// # Errors
///
/// Propagates errors from [`TimeSeriesData::slice`].
pub fn training_slice(&self) -> Result<TimeSeriesData, MLError> {
self.data.slice(0, self.cutoff_idx)
}
/// Return a test sub-range `[start, end)`.
///
/// Both `start` and `end` must be at or after the cutoff index.
///
/// # Errors
///
/// Returns [`MLError::InvalidInput`] if `start < cutoff_idx`, or
/// propagates errors from [`TimeSeriesData::slice`].
pub fn test_slice(&self, start: usize, end: usize) -> Result<TimeSeriesData, MLError> {
if start < self.cutoff_idx {
return Err(MLError::InvalidInput(format!(
"test_slice start ({}) is before cutoff ({}); this would leak training data",
start, self.cutoff_idx,
)));
}
self.data.slice(start, end)
}
/// Slice the data within a single partition.
///
/// The range `[start, end)` must lie entirely within `[0, cutoff)` or
/// entirely within `[cutoff, len)`. Crossing the boundary is an error.
///
/// # Errors
///
/// Returns [`MLError::InvalidInput`] if the range crosses the cutoff
/// boundary, or propagates errors from [`TimeSeriesData::slice`].
pub fn slice(&self, start: usize, end: usize) -> Result<TimeSeriesData, MLError> {
if start >= end {
return Err(MLError::InvalidInput(format!(
"Invalid slice range: start ({}) must be less than end ({})",
start, end,
)));
}
// Entirely in training region
let in_train = end <= self.cutoff_idx;
// Entirely in test region
let in_test = start >= self.cutoff_idx;
if !in_train && !in_test {
return Err(MLError::InvalidInput(format!(
"Slice [{}, {}) crosses the temporal cutoff at index {}; \
this would leak future data into the training partition",
start, end, self.cutoff_idx,
)));
}
self.data.slice(start, end)
}
/// Audit for timestamp-based forward leakage.
///
/// Checks whether any training timestamp is >= the earliest test
/// timestamp, which would indicate temporal ordering issues.
pub fn audit_leakage(&self) -> LeakageAuditReport {
let cutoff_timestamp = self.data.timestamps.get(self.cutoff_idx).copied();
let has_future_timestamps = if self.cutoff_idx < self.data.len() {
// Find the minimum test timestamp
let min_test_ts = self
.data
.timestamps
.get(self.cutoff_idx..)
.unwrap_or_default()
.iter()
.min()
.copied();
// Find the maximum training timestamp
let max_train_ts = self
.data
.timestamps
.get(..self.cutoff_idx)
.unwrap_or_default()
.iter()
.max()
.copied();
match (max_train_ts, min_test_ts) {
(Some(train_max), Some(test_min)) => train_max >= test_min,
_ => false,
}
} else {
false
};
LeakageAuditReport {
has_future_timestamps,
training_bars: self.cutoff_idx,
cutoff_timestamp,
}
}
/// Compute per-feature normalization statistics from training data only.
///
/// Returns means and standard deviations for each feature dimension,
/// computed exclusively from `[0, cutoff)`. These can then be applied
/// to the test partition without leaking future information.
pub fn compute_normalization_stats(&self) -> NormalizationStats {
let train_features = self
.data
.features
.get(..self.cutoff_idx)
.unwrap_or_default();
let sample_count = train_features.len();
if sample_count == 0 {
return NormalizationStats {
means: Vec::new(),
stds: Vec::new(),
sample_count: 0,
};
}
// Determine feature dimension from first row
let dim = train_features
.first()
.map(|r| r.len())
.unwrap_or_default();
let mut means = vec![0.0_f64; dim];
let mut sq_sums = vec![0.0_f64; dim];
for row in train_features {
for (j, val) in row.iter().enumerate() {
if let Some(m) = means.get_mut(j) {
*m += *val as f64;
}
if let Some(s) = sq_sums.get_mut(j) {
*s += (*val as f64) * (*val as f64);
}
}
}
let n = sample_count as f64;
for m in &mut means {
*m /= n;
}
let stds: Vec<f64> = means
.iter()
.zip(sq_sums.iter())
.map(|(&mean, &sq_sum)| {
let variance = (sq_sum / n) - mean * mean;
if variance > 0.0 {
variance.sqrt()
} else {
0.0
}
})
.collect();
NormalizationStats {
means,
stds,
sample_count,
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
#[allow(clippy::assertions_on_result_states)]
mod tests {
use super::*;
use chrono::TimeZone;
/// Helper: create N UTC timestamps starting from 2024-01-01.
fn make_timestamps(n: usize) -> Vec<DateTime<Utc>> {
(0..n)
.map(|i| {
Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0)
.single()
.unwrap_or_else(Utc::now)
+ chrono::Duration::days(i as i64)
})
.collect()
}
/// Helper: create N feature rows with a deterministic pattern.
fn make_features(n: usize, dim: usize) -> Vec<Vec<f32>> {
(0..n)
.map(|i| (0..dim).map(|j| (i * dim + j) as f32).collect())
.collect()
}
/// Helper: create a simple ascending-price time series.
fn make_test_data(n: usize) -> Result<TimeSeriesData, Box<dyn std::error::Error>> {
let prices: Vec<f64> = (0..n).map(|i| 100.0 + i as f64).collect();
Ok(TimeSeriesData::new(make_timestamps(n), make_features(n, 3), prices)?)
}
#[test]
fn test_training_slice_returns_correct_range() -> Result<(), Box<dyn std::error::Error>> {
let data = make_test_data(10)?;
let guard = TemporalGuard::new(&data, 5)?;
let train = guard.training_slice()?;
assert_eq!(train.len(), 5);
// First price should be 100.0, last should be 104.0
let first = train.prices.first().copied().unwrap_or(0.0);
let last = train.prices.last().copied().unwrap_or(0.0);
assert!((first - 100.0).abs() < 1e-12);
assert!((last - 104.0).abs() < 1e-12);
Ok(())
}
#[test]
fn test_test_slice_rejects_before_cutoff() -> Result<(), Box<dyn std::error::Error>> {
let data = make_test_data(10)?;
let guard = TemporalGuard::new(&data, 5)?;
// start=3 is before cutoff=5 → should fail
let result = guard.test_slice(3, 8);
assert!(result.is_err());
let err_msg = format!("{}", result.unwrap_err());
assert!(
err_msg.contains("leak"),
"Expected 'leak' in error, got: {err_msg}",
);
Ok(())
}
#[test]
fn test_slice_rejects_cross_boundary() -> Result<(), Box<dyn std::error::Error>> {
let data = make_test_data(10)?;
let guard = TemporalGuard::new(&data, 5)?;
// [3, 7) crosses cutoff=5
let result = guard.slice(3, 7);
assert!(result.is_err());
let err_msg = format!("{}", result.unwrap_err());
assert!(
err_msg.contains("crosses"),
"Expected 'crosses' in error, got: {err_msg}",
);
// [0, 4) is entirely in training → should succeed
let train_ok = guard.slice(0, 4);
assert!(train_ok.is_ok());
// [5, 8) is entirely in test → should succeed
let test_ok = guard.slice(5, 8);
assert!(test_ok.is_ok());
Ok(())
}
#[test]
fn test_audit_detects_no_leakage_in_sorted_data() -> Result<(), Box<dyn std::error::Error>> {
let data = make_test_data(10)?;
let guard = TemporalGuard::new(&data, 5)?;
let report = guard.audit_leakage();
assert!(!report.has_future_timestamps);
assert_eq!(report.training_bars, 5);
assert!(report.cutoff_timestamp.is_some());
Ok(())
}
#[test]
fn test_normalization_stats_from_training_only() -> Result<(), Box<dyn std::error::Error>> {
let data = make_test_data(10)?;
let guard = TemporalGuard::new(&data, 5)?;
let stats = guard.compute_normalization_stats();
assert_eq!(stats.sample_count, 5);
assert_eq!(stats.means.len(), 3);
assert_eq!(stats.stds.len(), 3);
// Verify means are computed from training features only (rows 0..5)
// Feature column 0: values 0, 3, 6, 9, 12 → mean = 6.0
let expected_mean_0 = (0.0 + 3.0 + 6.0 + 9.0 + 12.0) / 5.0;
let got_mean_0 = stats.means.first().copied().unwrap_or(f64::NAN);
assert!(
(got_mean_0 - expected_mean_0).abs() < 1e-10,
"Expected mean_0={expected_mean_0}, got {got_mean_0}",
);
Ok(())
}
}