feat(validation): add TimeSeriesData struct and ValidatableStrategy trait
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
338
ml/src/validation/types.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user