diff --git a/ml/src/validation/mod.rs b/ml/src/validation/mod.rs index f09dde14c..0040fb6e2 100644 --- a/ml/src/validation/mod.rs +++ b/ml/src/validation/mod.rs @@ -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}; diff --git a/ml/src/validation/types.rs b/ml/src/validation/types.rs new file mode 100644 index 000000000..6d66d24a7 --- /dev/null +++ b/ml/src/validation/types.rs @@ -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>, + /// Feature matrix `[num_bars][feature_dim]`. + pub features: Vec>, + /// Close prices per bar (must have >= 2 elements). + pub prices: Vec, + /// Log returns `ln(p[i+1] / p[i])`, length = `prices.len() - 1`. + pub returns: Vec, +} + +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>, + features: Vec>, + prices: Vec, + ) -> Result { + 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 = 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 { + 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, 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> { + (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> { + (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 + ); + } +}