feat(validation): add TimeSeriesData struct and ValidatableStrategy trait

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-02-20 16:16:53 +01:00
parent d956add21c
commit 1d545aabfb
2 changed files with 342 additions and 0 deletions

View File

@@ -5,9 +5,13 @@
//! - Statistical validation (walk-forward, DSR, PBO, permutation tests)
pub mod financial;
pub mod types;
// Re-export financial validation for backward compatibility
pub use financial::{
validate_model_basic, validate_model_comprehensive, validate_type_conversions,
FinancialValidationResult, ValidationResult,
};
// Re-export statistical validation types
pub use types::{TimeSeriesData, ValidatableStrategy};

338
ml/src/validation/types.rs Normal file
View File

@@ -0,0 +1,338 @@
//! Time series data types and validatable strategy trait for statistical validation.
//!
//! Provides [`TimeSeriesData`] for representing financial time series with prices,
//! features, and computed log returns, plus [`ValidatableStrategy`] as the interface
//! for strategies that can be evaluated through walk-forward validation, DSR, and PBO.
use chrono::{DateTime, Utc};
use crate::MLError;
/// Financial time series data for validation pipelines.
///
/// Holds aligned timestamps, feature matrices, close prices, and log returns
/// (computed automatically from prices). All vectors must have consistent
/// lengths: `timestamps.len() == features.len() == prices.len()`, and
/// `returns.len() == prices.len() - 1`.
#[derive(Debug, Clone)]
pub struct TimeSeriesData {
/// Bar timestamps (one per bar).
pub timestamps: Vec<DateTime<Utc>>,
/// Feature matrix `[num_bars][feature_dim]`.
pub features: Vec<Vec<f32>>,
/// Close prices per bar (must have >= 2 elements).
pub prices: Vec<f64>,
/// Log returns `ln(p[i+1] / p[i])`, length = `prices.len() - 1`.
pub returns: Vec<f64>,
}
impl TimeSeriesData {
/// Create a new `TimeSeriesData`, validating lengths and computing log returns.
///
/// # Errors
///
/// Returns [`MLError::InvalidInput`] if:
/// - `prices` has fewer than 2 elements
/// - `timestamps`, `features`, and `prices` have different lengths
/// - Any price is non-positive (cannot compute `ln`)
pub fn new(
timestamps: Vec<DateTime<Utc>>,
features: Vec<Vec<f32>>,
prices: Vec<f64>,
) -> Result<Self, MLError> {
if prices.len() < 2 {
return Err(MLError::InvalidInput(format!(
"TimeSeriesData requires at least 2 prices, got {}",
prices.len()
)));
}
if timestamps.len() != prices.len() || features.len() != prices.len() {
return Err(MLError::InvalidInput(format!(
"Length mismatch: timestamps={}, features={}, prices={}",
timestamps.len(),
features.len(),
prices.len()
)));
}
// Validate all prices are positive before computing log returns
for (i, &p) in prices.iter().enumerate() {
if p <= 0.0 || !p.is_finite() {
return Err(MLError::InvalidInput(format!(
"Price at index {} must be positive and finite, got {}",
i, p
)));
}
}
// Compute log returns using windows(2) with safe pattern matching
let returns: Vec<f64> = prices
.windows(2)
.filter_map(|w| {
if let [a, b] = *w {
Some((b / a).ln())
} else {
None
}
})
.collect();
Ok(Self {
timestamps,
features,
prices,
returns,
})
}
/// Number of bars in the time series.
pub fn len(&self) -> usize {
self.prices.len()
}
/// Returns `true` if the time series contains no data.
pub fn is_empty(&self) -> bool {
self.prices.is_empty()
}
/// Extract a sub-range `[start, end)` as a new `TimeSeriesData`.
///
/// The slice must contain at least 2 bars so that log returns can
/// be computed for the sub-range.
///
/// # Errors
///
/// Returns [`MLError::InvalidInput`] if:
/// - `start >= end`
/// - `end > self.len()`
/// - The resulting slice has fewer than 2 elements
pub fn slice(&self, start: usize, end: usize) -> Result<Self, MLError> {
if start >= end {
return Err(MLError::InvalidInput(format!(
"Invalid slice range: start ({}) must be less than end ({})",
start, end
)));
}
if end > self.len() {
return Err(MLError::InvalidInput(format!(
"Slice end ({}) exceeds data length ({})",
end,
self.len()
)));
}
let sub_len = end - start;
if sub_len < 2 {
return Err(MLError::InvalidInput(format!(
"Slice must contain at least 2 elements, got {}",
sub_len
)));
}
let sub_timestamps = self
.timestamps
.get(start..end)
.unwrap_or_default()
.to_vec();
let sub_features = self
.features
.get(start..end)
.unwrap_or_default()
.to_vec();
let sub_prices = self
.prices
.get(start..end)
.unwrap_or_default()
.to_vec();
// Recompute returns from the sub-range prices
Self::new(sub_timestamps, sub_features, sub_prices)
}
}
/// Trait for strategies that can be validated through walk-forward,
/// deflated Sharpe ratio, and probability of backtest overfitting tests.
pub trait ValidatableStrategy {
/// Train the strategy on the provided time series data.
///
/// # Errors
///
/// Returns [`MLError`] if training fails.
fn train(&mut self, data: &TimeSeriesData) -> Result<(), MLError>;
/// Evaluate the strategy on out-of-sample data.
///
/// Returns a vector of daily PnL values (one per bar in `data`).
///
/// # Errors
///
/// Returns [`MLError`] if evaluation fails.
fn evaluate(&self, data: &TimeSeriesData) -> Result<Vec<f64>, MLError>;
/// Human-readable strategy name.
fn name(&self) -> &str;
/// Reset internal state so the strategy can be retrained from scratch.
///
/// # Errors
///
/// Returns [`MLError`] if reset fails.
fn reset(&mut self) -> Result<(), MLError>;
}
#[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 dummy feature rows of given dimension.
fn make_features(n: usize, dim: usize) -> Vec<Vec<f32>> {
(0..n).map(|_| vec![0.0f32; dim]).collect()
}
#[test]
fn test_time_series_data_new_computes_returns() {
let prices = vec![100.0, 110.0, 105.0, 120.0];
let n = prices.len();
let ts = TimeSeriesData::new(make_timestamps(n), make_features(n, 3), prices.clone());
assert!(ts.is_ok(), "Expected Ok, got {:?}", ts.err());
let ts = ts.unwrap_or_else(|_| unreachable!());
assert_eq!(ts.returns.len(), n - 1);
// Verify each return is ln(p[i+1] / p[i])
for (i, &r) in ts.returns.iter().enumerate() {
let p_curr = prices.get(i).copied().unwrap_or(1.0);
let p_next = prices.get(i + 1).copied().unwrap_or(1.0);
let expected = (p_next / p_curr).ln();
assert!(
(r - expected).abs() < 1e-12,
"Return at index {} mismatch: got {}, expected {}",
i,
r,
expected
);
}
}
#[test]
fn test_time_series_data_length_mismatch_errors() {
// timestamps has 3 elements, prices has 4
let result = TimeSeriesData::new(
make_timestamps(3),
make_features(4, 2),
vec![100.0, 110.0, 105.0, 120.0],
);
assert!(result.is_err());
let err_msg = format!("{}", result.unwrap_err());
assert!(
err_msg.contains("Length mismatch"),
"Expected 'Length mismatch' in error, got: {}",
err_msg
);
}
#[test]
fn test_time_series_data_too_short_errors() {
// Only 1 price
let result = TimeSeriesData::new(
make_timestamps(1),
make_features(1, 2),
vec![100.0],
);
assert!(result.is_err());
let err_msg = format!("{}", result.unwrap_err());
assert!(
err_msg.contains("at least 2 prices"),
"Expected 'at least 2 prices' in error, got: {}",
err_msg
);
}
#[test]
fn test_slice_valid_range() {
let prices = vec![100.0, 110.0, 105.0, 120.0, 115.0];
let n = prices.len();
let ts = TimeSeriesData::new(make_timestamps(n), make_features(n, 2), prices.clone());
let ts = ts.unwrap_or_else(|_| unreachable!());
// Slice [1, 4) => prices [110, 105, 120]
let sliced = ts.slice(1, 4);
assert!(sliced.is_ok(), "Expected Ok, got {:?}", sliced.err());
let sliced = sliced.unwrap_or_else(|_| unreachable!());
assert_eq!(sliced.len(), 3);
assert_eq!(sliced.returns.len(), 2);
// Verify sliced prices
let expected_prices = vec![110.0, 105.0, 120.0];
for (i, (&got, &want)) in sliced.prices.iter().zip(expected_prices.iter()).enumerate() {
assert!(
(got - want).abs() < 1e-12,
"Price mismatch at index {}: got {}, expected {}",
i,
got,
want
);
}
// Verify sliced returns are recomputed correctly
let expected_r0 = (105.0_f64 / 110.0).ln();
let expected_r1 = (120.0_f64 / 105.0).ln();
let r0 = sliced.returns.first().copied().unwrap_or(f64::NAN);
let r1 = sliced.returns.get(1).copied().unwrap_or(f64::NAN);
assert!((r0 - expected_r0).abs() < 1e-12);
assert!((r1 - expected_r1).abs() < 1e-12);
}
#[test]
fn test_slice_out_of_bounds_errors() {
let prices = vec![100.0, 110.0, 105.0];
let n = prices.len();
let ts = TimeSeriesData::new(make_timestamps(n), make_features(n, 2), prices);
let ts = ts.unwrap_or_else(|_| unreachable!());
// end > len
let result = ts.slice(0, 10);
assert!(result.is_err());
let err_msg = format!("{}", result.unwrap_err());
assert!(
err_msg.contains("exceeds data length"),
"Expected 'exceeds data length' in error, got: {}",
err_msg
);
// start >= end
let result = ts.slice(2, 1);
assert!(result.is_err());
let err_msg = format!("{}", result.unwrap_err());
assert!(
err_msg.contains("must be less than end"),
"Expected 'must be less than end' in error, got: {}",
err_msg
);
// Slice too small (only 1 element)
let result = ts.slice(1, 2);
assert!(result.is_err());
let err_msg = format!("{}", result.unwrap_err());
assert!(
err_msg.contains("at least 2 elements"),
"Expected 'at least 2 elements' in error, got: {}",
err_msg
);
}
}