374 lines
12 KiB
Rust
374 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 super::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)]
|
|
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(())
|
|
}
|
|
}
|