Files
foxhunt/crates/ml/src/walk_forward.rs
jgrusewski 623ebfcf71 fix(precompute): in-place z-score + drop feature_vectors early
Previous workflow train-4qwtc hit memory-pressure thrash (~56Gi
cgroup.current sitting at the 56Gi pod limit, kernel reclaim
hammering page cache) right after OFI completed on the 9-quarter
17.8M-bar dataset. Two refactors reduce peak by ~12GB:

(1) walk_forward.rs: new `normalize_batch_in_place(&mut features)`
    that rewrites the slice in place. The previous `normalize_batch`
    `.collect()`s a new Vec — at this dataset size that's a
    transient ~6GB peak while both pre- and post-normalised arrays
    are alive.

(2) precompute_features.rs:
    - call `normalize_batch_in_place` instead of the rebinding form.
    - explicit `drop(feature_vectors)` after copying the slice into
      `features` — `feature_vectors` would otherwise stay alive
      until end-of-main shadowing the ~6GB allocation through
      every downstream step.

Combined with the prior `t.into_iter()` refactor (a27cb40a9), the
peak transient drops from ~56GB to ~44GB — well under the 56Gi pod
limit on the existing ci-compile-cpu pool (POP2-HC-32C-64G).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 10:32:11 +02:00

1184 lines
44 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Walk-forward evaluation framework for time-series ML models.
//!
//! Implements expanding-window walk-forward cross-validation with configurable
//! train/val/test splits and feature normalization. This prevents lookahead bias
//! by ensuring models are always evaluated on unseen future data.
//!
//! # Walk-Forward Split Diagram
//!
//! ```text
//! Fold 0: |------- Train (12mo) -------|-- Val (3mo) --|-- Test (3mo) --|
//! Fold 1: |---------- Train (15mo) ----------|-- Val --|-- Test --|
//! Fold 2: |------------- Train (18mo) --------------|-- Val --|-- Test --|
//! ```
//!
//! # Usage
//!
//! ```rust,no_run
//! use ml::walk_forward::{WalkForwardConfig, generate_walk_forward_windows, NormStats};
//!
//! let config = WalkForwardConfig::default();
//! let windows = generate_walk_forward_windows(&bars, &config);
//!
//! for window in &windows {
//! let features = extract_ml_features(&window.train)?;
//! let stats = NormStats::from_features(&features);
//! let normalized = stats.normalize_batch(&features);
//! // Train model on normalized features...
//! }
//! ```
use crate::types::OHLCVBar;
use chrono::{Months, NaiveDate};
/// Configuration for walk-forward evaluation window generation.
#[derive(Debug, Clone)]
pub struct WalkForwardConfig {
/// Number of months in the initial training window (default: 12).
pub initial_train_months: u32,
/// Number of months in the validation window (default: 3).
pub val_months: u32,
/// Number of months in the test window (default: 3).
pub test_months: u32,
/// Number of months to step forward between folds (default: 3).
pub step_months: u32,
}
impl Default for WalkForwardConfig {
fn default() -> Self {
Self {
initial_train_months: 12,
val_months: 3,
test_months: 3,
step_months: 3,
}
}
}
/// A single walk-forward evaluation window containing train/val/test splits.
#[derive(Debug, Clone)]
pub struct WalkForwardWindow {
/// Zero-based fold index.
pub fold: usize,
/// Training bars (expanding window).
pub train: Vec<OHLCVBar>,
/// Validation bars.
pub val: Vec<OHLCVBar>,
/// Test bars.
pub test: Vec<OHLCVBar>,
/// End date of the training period (exclusive boundary).
pub train_end: NaiveDate,
/// End date of the validation period (exclusive boundary).
pub val_end: NaiveDate,
/// End date of the test period (exclusive boundary).
pub test_end: NaiveDate,
/// Difficulty score for curriculum learning (mean ADX over training bars).
/// Low ADX (<20) = easy trending market, high ADX fluctuation = harder.
/// Computed via [`compute_difficulty`] after window generation.
pub difficulty_score: f64,
}
/// Curriculum phase for difficulty-based walk-forward window filtering.
///
/// Controls which windows are included in training based on their ADX-derived
/// difficulty score. Progression: Easy -> Mixed -> Full mirrors the standard
/// curriculum learning approach of starting with simple examples.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DifficultyPhase {
/// Only windows with mean ADX > 30 (clear trending markets, easier to learn).
Easy,
/// All windows included, but trending windows (ADX > 30) are weighted 2x
/// via duplication in the returned list.
Mixed,
/// All windows with equal weight (full training, no filtering).
Full,
}
impl DifficultyPhase {
/// Create a `DifficultyPhase` from a numeric index.
///
/// * `0` => `Easy`
/// * `1` => `Mixed`
/// * `2` (or any other value) => `Full`
pub fn from_index(idx: usize) -> Self {
match idx {
0 => Self::Easy,
1 => Self::Mixed,
_ => Self::Full,
}
}
}
/// ADX threshold used to classify a window as "trending" (easy) for curriculum learning.
const ADX_TRENDING_THRESHOLD: f64 = 30.0;
/// Compute a difficulty score for a slice of OHLCV bars based on mean ADX(14).
///
/// ADX (Average Directional Index) measures trend strength on a 0-100 scale.
/// High ADX (>30) indicates a clear trend which is easier for RL agents to exploit.
/// Low or fluctuating ADX indicates choppy, range-bound markets that are harder.
///
/// Returns the mean ADX value over the bar slice. If the slice has fewer than
/// 15 bars (minimum for ADX(14) calculation), returns 0.0.
///
/// # Algorithm
///
/// 1. Compute True Range (TR) for each bar
/// 2. Compute +DM / -DM directional movement
/// 3. Smooth TR, +DM, -DM with 14-period Wilder EMA
/// 4. Compute +DI / -DI and DX
/// 5. Smooth DX with 14-period Wilder EMA to get ADX
/// 6. Return mean ADX over valid bars
pub fn compute_difficulty(bars: &[OHLCVBar]) -> f64 {
let period: usize = 14;
// Need at least period + 1 bars for one ADX value
if bars.len() < period.saturating_add(1) {
return 0.0;
}
// Step 1-2: Compute True Range and Directional Movement
let mut tr_values = Vec::with_capacity(bars.len().saturating_sub(1));
let mut plus_dm_values = Vec::with_capacity(bars.len().saturating_sub(1));
let mut minus_dm_values = Vec::with_capacity(bars.len().saturating_sub(1));
for i in 1..bars.len() {
let prev = match bars.get(i.wrapping_sub(1)) {
Some(b) => b,
None => continue,
};
let curr = match bars.get(i) {
Some(b) => b,
None => continue,
};
// True Range = max(H-L, |H-prev_close|, |L-prev_close|)
let hl = curr.high - curr.low;
let hpc = (curr.high - prev.close).abs();
let lpc = (curr.low - prev.close).abs();
let tr = hl.max(hpc).max(lpc);
tr_values.push(tr);
// +DM = max(H - prev_H, 0) if > max(prev_L - L, 0), else 0
let up_move = curr.high - prev.high;
let down_move = prev.low - curr.low;
let plus_dm = if up_move > down_move && up_move > 0.0 {
up_move
} else {
0.0
};
let minus_dm = if down_move > up_move && down_move > 0.0 {
down_move
} else {
0.0
};
plus_dm_values.push(plus_dm);
minus_dm_values.push(minus_dm);
}
if tr_values.len() < period {
return 0.0;
}
// Step 3: Wilder smoothing (initial = sum of first `period` values, then EMA)
let mut smoothed_tr: f64 = tr_values.iter().take(period).sum();
let mut smoothed_plus_dm: f64 = plus_dm_values.iter().take(period).sum();
let mut smoothed_minus_dm: f64 = minus_dm_values.iter().take(period).sum();
let period_f64 = period as f64;
let mut dx_values = Vec::with_capacity(tr_values.len().saturating_sub(period));
// First DX from initial smoothed values
if smoothed_tr.abs() > 1e-12 {
let plus_di = 100.0 * smoothed_plus_dm / smoothed_tr;
let minus_di = 100.0 * smoothed_minus_dm / smoothed_tr;
let di_sum = plus_di + minus_di;
if di_sum.abs() > 1e-12 {
let dx = 100.0 * (plus_di - minus_di).abs() / di_sum;
dx_values.push(dx);
}
}
// Subsequent smoothed values using Wilder's method
for i in period..tr_values.len() {
let tr_val = match tr_values.get(i) {
Some(&v) => v,
None => continue,
};
let pdm_val = match plus_dm_values.get(i) {
Some(&v) => v,
None => continue,
};
let mdm_val = match minus_dm_values.get(i) {
Some(&v) => v,
None => continue,
};
smoothed_tr = smoothed_tr - (smoothed_tr / period_f64) + tr_val;
smoothed_plus_dm = smoothed_plus_dm - (smoothed_plus_dm / period_f64) + pdm_val;
smoothed_minus_dm = smoothed_minus_dm - (smoothed_minus_dm / period_f64) + mdm_val;
if smoothed_tr.abs() > 1e-12 {
let plus_di = 100.0 * smoothed_plus_dm / smoothed_tr;
let minus_di = 100.0 * smoothed_minus_dm / smoothed_tr;
let di_sum = plus_di + minus_di;
if di_sum.abs() > 1e-12 {
let dx = 100.0 * (plus_di - minus_di).abs() / di_sum;
dx_values.push(dx);
}
}
}
if dx_values.len() < period {
return 0.0;
}
// Step 5: Smooth DX with Wilder EMA to get ADX
let mut adx: f64 = dx_values.iter().take(period).sum::<f64>() / period_f64;
let mut adx_values = vec![adx];
for i in period..dx_values.len() {
let dx_val = match dx_values.get(i) {
Some(&v) => v,
None => continue,
};
adx = (adx * (period_f64 - 1.0) + dx_val) / period_f64;
adx_values.push(adx);
}
// Step 6: Return mean ADX
if adx_values.is_empty() {
return 0.0;
}
let sum: f64 = adx_values.iter().sum();
sum / adx_values.len() as f64
}
/// Filter walk-forward windows by curriculum difficulty phase.
///
/// This function applies difficulty-based filtering to a set of walk-forward
/// windows, enabling curriculum learning by controlling which market conditions
/// the agent trains on.
///
/// # Phases
///
/// * [`DifficultyPhase::Easy`] — Only windows with ADX > 30 (trending, easier markets).
/// * [`DifficultyPhase::Mixed`] — All windows, but trending windows are duplicated for 2x weight.
/// * [`DifficultyPhase::Full`] — All windows with equal weight (no filtering).
///
/// # Arguments
///
/// * `windows` — Walk-forward windows with pre-computed `difficulty_score`.
/// * `phase` — The curriculum phase to apply.
pub fn filter_by_difficulty(
windows: &[WalkForwardWindow],
phase: DifficultyPhase,
) -> Vec<WalkForwardWindow> {
match phase {
DifficultyPhase::Easy => {
// Only trending windows (ADX > 30)
windows
.iter()
.filter(|w| w.difficulty_score > ADX_TRENDING_THRESHOLD)
.cloned()
.collect()
}
DifficultyPhase::Mixed => {
// All windows, trending ones duplicated for 2x weight
let mut result = Vec::with_capacity(windows.len().saturating_mul(2));
for w in windows {
result.push(w.clone());
if w.difficulty_score > ADX_TRENDING_THRESHOLD {
result.push(w.clone());
}
}
result
}
DifficultyPhase::Full => {
// All windows, equal weight
windows.to_vec()
}
}
}
/// Minimum number of bars required in each split to form a valid window.
const MIN_BARS_PER_SPLIT: usize = 50;
/// Generate expanding walk-forward windows from sorted OHLCV bars.
///
/// Each successive fold extends the training window while keeping
/// val/test windows the same size, stepping forward by `step_months`.
///
/// Returns an empty `Vec` if the data is insufficient for even one fold.
///
/// # Arguments
///
/// * `bars` - Chronologically sorted OHLCV bars
/// * `config` - Walk-forward configuration
pub fn generate_walk_forward_windows(
bars: &[OHLCVBar],
config: &WalkForwardConfig,
) -> Vec<WalkForwardWindow> {
if bars.is_empty() {
return Vec::new();
}
// Determine date range from the data
let first_bar = match bars.first() {
Some(b) => b,
None => return Vec::new(),
};
let last_bar = match bars.last() {
Some(b) => b,
None => return Vec::new(),
};
let data_start = first_bar.timestamp.date_naive();
let data_end = last_bar.timestamp.date_naive();
let mut windows = Vec::new();
let mut fold = 0_usize;
loop {
// Train end: initial_train_months + fold * step_months from data_start
let total_train_months = config
.initial_train_months
.saturating_add(fold as u32 * config.step_months);
let train_end = match data_start.checked_add_months(Months::new(total_train_months)) {
Some(d) => d,
None => break,
};
// Val end: train_end + val_months
let val_end = match train_end.checked_add_months(Months::new(config.val_months)) {
Some(d) => d,
None => break,
};
// Test end: val_end + test_months
let test_end = match val_end.checked_add_months(Months::new(config.test_months)) {
Some(d) => d,
None => break,
};
// If test_end exceeds data range, we cannot form this fold.
// Use `>` with +1 day margin: bars with `date_naive() < test_end` captures
// all bars ON the last calendar day of the test window. Without the margin,
// quarterly data ending on e.g. Jun-30 fails when test_end = Jul-01.
if test_end > data_end + chrono::Duration::days(1) {
break;
}
// Partition bars into train/val/test using date boundaries
let train_bars: Vec<OHLCVBar> = bars
.iter()
.filter(|b| b.timestamp.date_naive() < train_end)
.copied()
.collect();
// Audit follow-up: insert PURGE_BARS gap between train and val.
// Date-based partition has no per-bar index, so drop the first
// `PURGE_BARS` bars whose date is in `[train_end, val_end)` —
// matches the bar-count purge applied by the index-based
// variants below. Test/val window endpoints stay date-anchored
// so total fold coverage is preserved (val_len shrinks by
// exactly `PURGE_BARS`).
let raw_val_bars: Vec<OHLCVBar> = bars
.iter()
.filter(|b| {
let d = b.timestamp.date_naive();
d >= train_end && d < val_end
})
.copied()
.collect();
let val_bars: Vec<OHLCVBar> = if raw_val_bars.len() > PURGE_BARS {
raw_val_bars.into_iter().skip(PURGE_BARS).collect()
} else {
// Degenerate fold — purge would consume the entire window;
// the `val_bars.len() < MIN_BARS_PER_SPLIT` gate below will
// skip this fold, so keep raw_val_bars empty-passthrough.
raw_val_bars
};
let test_bars: Vec<OHLCVBar> = bars
.iter()
.filter(|b| {
let d = b.timestamp.date_naive();
d >= val_end && d < test_end
})
.copied()
.collect();
// Skip folds where any split has fewer than MIN_BARS_PER_SPLIT bars
if train_bars.len() < MIN_BARS_PER_SPLIT
|| val_bars.len() < MIN_BARS_PER_SPLIT
|| test_bars.len() < MIN_BARS_PER_SPLIT
{
// If training set is too small, later folds might still work
// But if val/test are too small at the end, no point continuing
if val_bars.len() < MIN_BARS_PER_SPLIT || test_bars.len() < MIN_BARS_PER_SPLIT {
// Check if we have already generated at least one fold or if
// the data simply does not support this fold size
if fold > 0 {
break;
}
// For fold 0 with insufficient data, skip but try next fold
fold = fold.saturating_add(1);
continue;
}
fold = fold.saturating_add(1);
continue;
}
let difficulty_score = compute_difficulty(&train_bars);
windows.push(WalkForwardWindow {
fold,
train: train_bars,
val: val_bars,
test: test_bars,
train_end,
val_end,
test_end,
difficulty_score,
});
fold = fold.saturating_add(1);
}
windows
}
/// Walk-forward fold as index ranges into pre-loaded arrays.
/// Zero data copying — just start/end indices into bars/features arrays.
#[derive(Debug, Clone)]
pub struct FoldRange {
pub fold: usize,
pub train_start: usize,
pub train_end: usize,
pub val_start: usize,
pub val_end: usize,
pub difficulty_score: f64,
}
/// Generate walk-forward folds as index ranges (zero-copy).
///
/// Same date-boundary logic as `generate_walk_forward_windows` but returns
/// `(start, end)` indices instead of cloning bars. Uses `partition_point`
/// for O(log n) index lookup.
pub fn generate_walk_forward_indices(
bars: &[OHLCVBar],
config: &WalkForwardConfig,
) -> Vec<FoldRange> {
if bars.is_empty() {
return Vec::new();
}
let data_start = bars[0].timestamp.date_naive();
let data_end = bars[bars.len() - 1].timestamp.date_naive();
let mut ranges = Vec::new();
let mut fold = 0_usize;
loop {
let total_train_months = config
.initial_train_months
.saturating_add(fold as u32 * config.step_months);
let train_end = match data_start.checked_add_months(Months::new(total_train_months)) {
Some(d) => d,
None => break,
};
let val_end = match train_end.checked_add_months(Months::new(config.val_months)) {
Some(d) => d,
None => break,
};
let test_end = match val_end.checked_add_months(Months::new(config.test_months)) {
Some(d) => d,
None => break,
};
if test_end > data_end + chrono::Duration::days(1) {
break;
}
// O(log n) index lookups via partition_point (bars are sorted by timestamp)
let train_end_idx = bars.partition_point(|b| b.timestamp.date_naive() < train_end);
let val_end_idx = bars.partition_point(|b| b.timestamp.date_naive() < val_end);
let test_end_idx = bars.partition_point(|b| b.timestamp.date_naive() < test_end);
let train_start = 0; // expanding window: always starts at beginning
// Audit follow-up: insert PURGE_BARS gap between train and val
// to break single-bar carry-over from the last train label
// (`target = next_close`) into the first val state
// (`close`). Saturating-add against `bars.len()` so the purge
// can never push val past the end of the dataset; the
// `train_len >= MIN_BARS_PER_SPLIT` gate below catches degenerate
// folds where the purge consumes the entire validation window.
let val_start = train_end_idx
.saturating_add(PURGE_BARS)
.min(bars.len());
let val_end_bound = val_end_idx.max(val_start);
// Skip folds where any split is too small
let train_len = train_end_idx - train_start;
let val_len = val_end_bound - val_start;
let test_len = test_end_idx - val_end_bound;
if train_len < MIN_BARS_PER_SPLIT || val_len < MIN_BARS_PER_SPLIT || test_len < MIN_BARS_PER_SPLIT {
if val_len < MIN_BARS_PER_SPLIT || test_len < MIN_BARS_PER_SPLIT {
if fold > 0 { break; }
}
fold = fold.saturating_add(1);
continue;
}
let difficulty_score = compute_difficulty(&bars[train_start..train_end_idx]);
ranges.push(FoldRange {
fold,
train_start,
train_end: train_end_idx,
val_start,
val_end: val_end_bound,
difficulty_score,
});
fold = fold.saturating_add(1);
}
ranges
}
/// Generate walk-forward folds as index ranges from nanosecond timestamps.
///
/// Same date-boundary logic as [`generate_walk_forward_indices`] but operates
/// on raw `i64` timestamps (nanoseconds since Unix epoch) from fxcache data
/// instead of `OHLCVBar`. Difficulty score is always 0.0 because we don't
/// have OHLC data to compute ADX.
pub fn generate_walk_forward_indices_from_timestamps(
timestamps: &[i64],
config: &WalkForwardConfig,
) -> Vec<FoldRange> {
if timestamps.is_empty() {
return Vec::new();
}
let first_ts = timestamps[0];
let last_ts = timestamps[timestamps.len() - 1];
let data_start = chrono::DateTime::from_timestamp_nanos(first_ts).date_naive();
let data_end = chrono::DateTime::from_timestamp_nanos(last_ts).date_naive();
let mut ranges = Vec::new();
let mut fold = 0_usize;
loop {
let total_train_months = config
.initial_train_months
.saturating_add(fold as u32 * config.step_months);
let train_end = match data_start.checked_add_months(Months::new(total_train_months)) {
Some(d) => d,
None => break,
};
let val_end = match train_end.checked_add_months(Months::new(config.val_months)) {
Some(d) => d,
None => break,
};
let test_end = match val_end.checked_add_months(Months::new(config.test_months)) {
Some(d) => d,
None => break,
};
if test_end > data_end + chrono::Duration::days(1) {
break;
}
// O(log n) index lookups via partition_point (timestamps are sorted)
let train_end_idx = timestamps.partition_point(|&ts| {
chrono::DateTime::from_timestamp_nanos(ts).date_naive() < train_end
});
let val_end_idx = timestamps.partition_point(|&ts| {
chrono::DateTime::from_timestamp_nanos(ts).date_naive() < val_end
});
let test_end_idx = timestamps.partition_point(|&ts| {
chrono::DateTime::from_timestamp_nanos(ts).date_naive() < test_end
});
let train_start = 0; // expanding window: always starts at beginning
// Audit follow-up: insert PURGE_BARS gap between train and val
// to break single-bar carry-over (see PURGE_BARS docstring).
// Mirrors the bars-based variant above for the timestamps-only
// fxcache path; same saturating-add guard.
let val_start = train_end_idx
.saturating_add(PURGE_BARS)
.min(timestamps.len());
let val_end_bound = val_end_idx.max(val_start);
// Skip folds where any split is too small
let train_len = train_end_idx - train_start;
let val_len = val_end_bound - val_start;
let test_len = test_end_idx - val_end_bound;
if train_len < MIN_BARS_PER_SPLIT || val_len < MIN_BARS_PER_SPLIT || test_len < MIN_BARS_PER_SPLIT {
if val_len < MIN_BARS_PER_SPLIT || test_len < MIN_BARS_PER_SPLIT {
if fold > 0 { break; }
}
fold = fold.saturating_add(1);
continue;
}
// No ADX computation — fxcache doesn't have OHLC bars for difficulty
ranges.push(FoldRange {
fold,
train_start,
train_end: train_end_idx,
val_start,
val_end: val_end_bound,
difficulty_score: 0.0,
});
fold = fold.saturating_add(1);
}
ranges
}
/// Per-feature normalization statistics (mean and standard deviation).
///
/// Computed from training data and applied to val/test data to prevent
/// information leakage. Uses z-score normalization: `(x - mean) / std`.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct NormStats {
/// Per-feature mean values (length 51).
pub mean: Vec<f64>,
/// Per-feature standard deviation values (length 51, clamped >= 1e-8).
pub std: Vec<f64>,
}
/// Number of features in a standard feature vector (42 market features: 40 base + 2 regime).
const FEATURE_DIM: usize = 42;
/// Minimum standard deviation to prevent division by zero.
const MIN_STD: f64 = 1e-8;
/// Train→Val purge gap (in bars) inserted between every fold's train and
/// val windows. Audit follow-up
/// `docs/lookahead-bias-audit-2026-04-28.md` rec 2 — without a gap, the
/// last training bar's `target = next_close` overlaps the first val
/// bar's `close`, producing single-bar carry-over correlation between
/// the train label and the immediately-following val state. The 5-bar
/// width is the minimum that breaks single-bar carry-over for all
/// per-bar features (autocorr lags 1/5/10 at `extraction.rs:484-491`,
/// price-acceleration window 3 at `extraction.rs:797-810`); going
/// wider would reduce val sample size without measurable lookahead
/// reduction. Bar-count rather than time-based purge: at volume-bar
/// cadence bars vary in time, but 5 consecutive bars consume any
/// per-bar features regardless of wall-clock spacing — the lookahead
/// surface is per-bar, so per-bar purge is the well-matched primitive.
///
/// This is a structural correctness anchor rather than an adaptive
/// constant: the value is determined by the maximum lookback of the
/// feature pipeline (5 bars), not by training dynamics. Per
/// `feedback_isv_for_adaptive_bounds` it stays compile-time because
/// the feature-pipeline lookback is itself compile-time.
pub const PURGE_BARS: usize = 5;
/// Maximum legitimate magnitude of a z-score-normalized feature.
///
/// Z-scores in real-market 42-dim feature vectors live well within ±5 even on
/// extreme bars (10σ events are vanishingly rare); ±20 is a generous ceiling
/// that traps every observed corruption pattern (raw-price leak, corrupt bar
/// with log_return = -30 / std=1e-3 = -30000, etc.) without rejecting any
/// legitimate market signal. Used as the shared invariant in
/// [`validate_normalized_features`].
pub const NORMALIZED_FEATURE_BOUND: f64 = 20.0;
impl NormStats {
/// Compute normalization statistics from a batch of feature vectors (FEATURE_DIM dimensions).
///
/// If the input is empty, returns mean = 0.0 and std = 1.0 for all features.
pub fn from_features(features: &[[f64; FEATURE_DIM]]) -> Self {
if features.is_empty() {
return Self {
mean: vec![0.0; FEATURE_DIM],
std: vec![1.0; FEATURE_DIM],
};
}
let n = features.len() as f64;
let mut mean = vec![0.0_f64; FEATURE_DIM];
let mut variance = vec![0.0_f64; FEATURE_DIM];
// Accumulate sums for mean
for feature_vec in features {
for (m, val) in mean.iter_mut().zip(feature_vec.iter()) {
*m += val;
}
}
// Compute mean
for m in &mut mean {
*m /= n;
}
// Accumulate variance
for feature_vec in features {
for (v, (val, m)) in variance
.iter_mut()
.zip(feature_vec.iter().zip(mean.iter()))
{
let diff = val - m;
*v += diff * diff;
}
}
// Compute std with minimum clamp
let std_vec: Vec<f64> = variance
.iter()
.map(|v| (v / n).sqrt().max(MIN_STD))
.collect();
Self {
mean,
std: std_vec,
}
}
/// Compute normalization statistics from a contiguous SLICE of feature
/// vectors (the fold's train range). Audit follow-up
/// `docs/lookahead-bias-audit-2026-04-28.md` rec 3 — fitting `NormStats`
/// only on the training portion of each fold prevents val-window
/// statistics from leaking into the train-time z-score baseline. The
/// trainer fold loop uses this in place of the global `from_features`
/// fit to satisfy "Compute stats from training data only" (the
/// docstring contract at the top of this module).
///
/// `train_start..train_end` is the half-open range of bar indices to
/// fit on; the resulting stats are then applied to BOTH train and val
/// portions of the same fold via `normalize_batch`. Behaves identically
/// to `from_features(&features[train_start..train_end])` but takes a
/// borrow rather than forcing the caller to slice; useful when the
/// caller already owns the full feature buffer and wants per-fold fits
/// without extra allocation.
pub fn from_features_slice(
features: &[[f64; FEATURE_DIM]],
train_start: usize,
train_end: usize,
) -> Self {
let end = train_end.min(features.len());
let start = train_start.min(end);
Self::from_features(&features[start..end])
}
/// Z-score normalize a single 42-dimensional feature vector.
///
/// Returns `clamp((feature - mean) / std, ±NORMALIZED_FEATURE_BOUND)`
/// element-wise. The clamp is the structural guarantee that downstream GPU
/// consumers (state_gather → state[0] in `experience_state_gather`, aux
/// label_scale_ema, vol_normalizer) see bounded inputs regardless of what
/// corruption survives upstream. Without it, a single outlier from a
/// corrupt DBN bar inflates `std` enough to amplify itself by sqrt(N) post-
/// normalization and flow through training as a ±30000 magnitude state[0]
/// outlier.
pub fn normalize(&self, features: &[f64; FEATURE_DIM]) -> [f64; FEATURE_DIM] {
let mut result = [0.0_f64; FEATURE_DIM];
for ((r, val), (m, s)) in result
.iter_mut()
.zip(features.iter())
.zip(self.mean.iter().zip(self.std.iter()))
{
*r = ((val - m) / s).clamp(-NORMALIZED_FEATURE_BOUND, NORMALIZED_FEATURE_BOUND);
}
result
}
/// Z-score normalize a batch of 42-dimensional feature vectors.
pub fn normalize_batch(&self, features: &[[f64; FEATURE_DIM]]) -> Vec<[f64; FEATURE_DIM]> {
features.iter().map(|f| self.normalize(f)).collect()
}
/// In-place variant of `normalize_batch`: rewrites `features` rather
/// than allocating a new Vec. Saves a transient ~6GB peak when
/// `features` is the precompute_features pass over 17.8M bars
/// (which previously held both the pre- and post-normalised arrays
/// alive simultaneously while `.collect()` built the new one).
pub fn normalize_batch_in_place(&self, features: &mut [[f64; FEATURE_DIM]]) {
for f in features.iter_mut() {
*f = self.normalize(f);
}
}
/// Inverse of `normalize`: recover raw `feature` from a previously-
/// normalised value via `raw = norm * std + mean`. Caveat: the forward
/// path applies `clamp(±NORMALIZED_FEATURE_BOUND)` post-divide, so any
/// normalised value sitting at exactly ±20 is information-lossy under
/// this inverse (the original z-score may have been larger, but the
/// raw value pointer is gone). For the per-fold renormalisation flow
/// in `train_baseline_rl.rs` (audit fix rec 3) this matters only at
/// outlier boundaries; non-clamped bars round-trip exactly. The
/// alternative — storing raw features in fxcache — requires bumping
/// `FXCACHE_VERSION` and migrating all consumers atomically; see the
/// fxcache header docstring for the version-bump rationale.
pub fn denormalize(&self, normalised: &[f64; FEATURE_DIM]) -> [f64; FEATURE_DIM] {
let mut result = [0.0_f64; FEATURE_DIM];
for ((r, val), (m, s)) in result
.iter_mut()
.zip(normalised.iter())
.zip(self.mean.iter().zip(self.std.iter()))
{
*r = val * s + m;
}
result
}
/// Batched inverse of `normalize_batch`. See `denormalize` for the
/// clamp caveat.
pub fn denormalize_batch(&self, normalised: &[[f64; FEATURE_DIM]]) -> Vec<[f64; FEATURE_DIM]> {
normalised.iter().map(|f| self.denormalize(f)).collect()
}
}
/// Validate that a batch of normalized features is bounded and free of NaN/Inf.
///
/// Single source of truth for the post-normalization invariant — used by both
/// the fxcache fast path (after `discover_and_load`) and the DBN fallback
/// (after `normalize_batch`). Either path producing data that fails this gate
/// must error out rather than upload poisoned values to the GPU.
///
/// Returns `Ok(())` if every element of every vector is finite and within
/// `±NORMALIZED_FEATURE_BOUND`. On the first violation, returns an `Err` with
/// the bar index, feature index, and value so the operator can locate the
/// corrupt source row.
pub fn validate_normalized_features(
features: &[[f64; FEATURE_DIM]],
source: &str,
) -> anyhow::Result<()> {
for (bar_idx, feat_vec) in features.iter().enumerate() {
for (feat_idx, &val) in feat_vec.iter().enumerate() {
if !val.is_finite() {
anyhow::bail!(
"{source}: non-finite value at bar={bar_idx}, feat={feat_idx}: {val} \
— data corruption between source and post-normalization."
);
}
if val.abs() > NORMALIZED_FEATURE_BOUND {
anyhow::bail!(
"{source}: |feature[{bar_idx}][{feat_idx}]| = {} exceeds normalized bound \
(±{NORMALIZED_FEATURE_BOUND}) — corrupt bar (e.g. bar.open ≈ 0 producing \
extreme log return) or unit-mismatch (raw price written instead of \
z-normalized log return). Reject the dataset; do not upload to GPU.",
val
);
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Datelike, NaiveDate, NaiveTime, TimeZone, Utc, Weekday};
/// Create a single OHLCV bar at the given date.
fn make_bar(year: i32, month: u32, day: u32) -> OHLCVBar {
let date = NaiveDate::from_ymd_opt(year, month, day).unwrap_or_default();
let time = NaiveTime::from_hms_opt(10, 0, 0).unwrap_or_default();
let dt = date.and_time(time);
let timestamp = Utc.from_utc_datetime(&dt);
OHLCVBar {
timestamp,
open: 100.0,
high: 101.0,
low: 99.0,
close: 100.5,
volume: 1000.0,
}
}
/// Create bars for every weekday in [start, end) with one bar per day.
/// For testing purposes, this creates enough density to exceed the minimum
/// bars threshold when covering multi-month ranges.
fn make_bars_range(start: NaiveDate, end: NaiveDate) -> Vec<OHLCVBar> {
let mut bars = Vec::new();
let mut current = start;
let time = NaiveTime::from_hms_opt(10, 0, 0).unwrap_or_default();
while current < end {
let weekday = current.weekday();
if weekday != Weekday::Sat && weekday != Weekday::Sun {
// Generate multiple bars per trading day to simulate 1-min bars
// We need enough density: ~390 bars per day for 6.5h of trading
// For test efficiency, generate 20 bars per day (enough for density)
for minute_offset in 0..20 {
let bar_time =
NaiveTime::from_hms_opt(9, 30_u32.saturating_add(minute_offset), 0)
.unwrap_or(time);
let dt = current.and_time(bar_time);
let timestamp = Utc.from_utc_datetime(&dt);
bars.push(OHLCVBar {
timestamp,
open: 100.0 + (bars.len() as f64 * 0.001),
high: 101.0 + (bars.len() as f64 * 0.001),
low: 99.0 + (bars.len() as f64 * 0.001),
close: 100.5 + (bars.len() as f64 * 0.001),
volume: 1000.0,
});
}
}
current = current
.succ_opt()
.unwrap_or(current);
}
bars
}
#[test]
fn test_generate_walk_forward_empty_bars() {
let config = WalkForwardConfig::default();
let windows = generate_walk_forward_windows(&[], &config);
assert!(windows.is_empty());
}
#[test]
fn test_walk_forward_windows_24_months() {
// Generate 24 months of synthetic bars
let start = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap_or_default();
let end = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap_or_default();
let bars = make_bars_range(start, end);
assert!(
!bars.is_empty(),
"Should have generated bars for 24-month range"
);
let config = WalkForwardConfig::default(); // 12/3/3/3
let windows = generate_walk_forward_windows(&bars, &config);
// With 24 months of data and 12+3+3=18 months for first fold, stepping by 3:
// Fold 0: train 12mo, val 3mo, test 3mo = 18 months (fits in 24)
// Fold 1: train 15mo, val 3mo, test 3mo = 21 months (fits in 24)
// Fold 2: train 18mo, val 3mo, test 3mo = 24 months (might barely fit)
assert!(
windows.len() >= 2,
"Expected at least 2 windows, got {}",
windows.len()
);
// Check fold 0 has non-empty splits
if let Some(w0) = windows.first() {
assert!(!w0.train.is_empty(), "Fold 0 train should be non-empty");
assert!(!w0.val.is_empty(), "Fold 0 val should be non-empty");
assert!(!w0.test.is_empty(), "Fold 0 test should be non-empty");
// Train must end before val
assert!(
w0.train_end <= w0.val_end,
"Train end ({}) should be <= val end ({})",
w0.train_end,
w0.val_end
);
}
}
#[test]
fn test_walk_forward_expanding_windows() {
// Generate 30 months to get multiple folds
let start = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap_or_default();
let end = NaiveDate::from_ymd_opt(2025, 7, 1).unwrap_or_default();
let bars = make_bars_range(start, end);
let config = WalkForwardConfig::default();
let windows = generate_walk_forward_windows(&bars, &config);
assert!(
windows.len() >= 2,
"Need at least 2 windows to test expanding, got {}",
windows.len()
);
// Each successive fold should have strictly more training data
for pair in windows.windows(2) {
let (prev, next) = match (pair.first(), pair.get(1)) {
(Some(p), Some(n)) => (p, n),
_ => continue,
};
assert!(
next.train.len() > prev.train.len(),
"Fold {} train ({} bars) should have more data than fold {} train ({} bars)",
next.fold,
next.train.len(),
prev.fold,
prev.train.len()
);
}
}
#[test]
fn test_norm_stats_roundtrip() {
// Features: [[1,1,...], [2,2,...], [3,3,...]]
let f1 = [1.0_f64; FEATURE_DIM];
let f2 = [2.0_f64; FEATURE_DIM];
let f3 = [3.0_f64; FEATURE_DIM];
let features = [f1, f2, f3];
let stats = NormStats::from_features(&features);
// Mean should be 2.0 for all features
for m in &stats.mean {
assert!(
(*m - 2.0).abs() < 1e-10,
"Expected mean ~2.0, got {}",
m
);
}
// Normalizing the mean vector should give ~0 for all features
let normalized = stats.normalize(&f2);
for val in &normalized {
assert!(
val.abs() < 1e-10,
"Expected normalized mean ~0.0, got {}",
val
);
}
}
#[test]
fn test_norm_stats_empty() {
let features: &[[f64; FEATURE_DIM]] = &[];
let stats = NormStats::from_features(features);
// Should return safe defaults
assert_eq!(stats.mean.len(), FEATURE_DIM);
assert_eq!(stats.std.len(), FEATURE_DIM);
// Mean should be 0.0
for m in &stats.mean {
assert!((*m).abs() < 1e-10, "Expected mean 0.0, got {}", m);
}
// Std should be 1.0
for s in &stats.std {
assert!(
(*s - 1.0).abs() < 1e-10,
"Expected std 1.0, got {}",
s
);
}
}
/// Audit follow-up `docs/lookahead-bias-audit-2026-04-28.md` rec 3
/// behavioral test: per-fold `from_features_slice` must fit on
/// train-only data, NOT the global mean that includes val. Builds a
/// synthetic dataset where the train portion has mean 1.0 per
/// feature and the val portion has mean 9.0 per feature; the
/// global mean would be 5.0. Verifies the per-fold helper recovers
/// the train-only mean to ε=1e-5.
#[test]
fn test_norm_stats_per_fold_fit_train_only() {
const TRAIN_END: usize = 100;
const VAL_END: usize = 200;
const EPS: f64 = 1e-5;
// Build features where train slice [0..100) has mean 1.0 and
// val slice [100..200) has mean 9.0. Global mean would be 5.0.
let mut features: Vec<[f64; FEATURE_DIM]> = Vec::with_capacity(VAL_END);
for _ in 0..TRAIN_END {
features.push([1.0_f64; FEATURE_DIM]);
}
for _ in TRAIN_END..VAL_END {
features.push([9.0_f64; FEATURE_DIM]);
}
// Per-fold fit (audit-correct path).
let per_fold = NormStats::from_features_slice(&features, 0, TRAIN_END);
for (i, m) in per_fold.mean.iter().enumerate() {
assert!(
(m - 1.0).abs() < EPS,
"per-fold feat[{i}] mean: got {m}, expected 1.0 (train-only)"
);
}
// Global fit (audit-flagged path).
let global = NormStats::from_features(&features);
for (i, m) in global.mean.iter().enumerate() {
assert!(
(m - 5.0).abs() < EPS,
"global feat[{i}] mean: got {m}, expected 5.0 (train + val)"
);
}
// The two fits must produce DIFFERENT means — that's the whole
// point of the rec 3 fix. ε=1e-5 is the audit's behavioral
// contract.
let mean_diff = (per_fold.mean[0] - global.mean[0]).abs();
assert!(
mean_diff > 1.0,
"per-fold and global means must differ for shifted-val test: \
per_fold.mean[0]={}, global.mean[0]={}",
per_fold.mean[0],
global.mean[0]
);
}
/// Round-trip via denormalize → normalize: any feature within the
/// non-clamped range should recover bit-identically. Pins the
/// invariant that `train_baseline_rl.rs`'s per-fold renormalisation
/// helper preserves data fidelity for in-distribution bars.
#[test]
fn test_norm_stats_denormalize_roundtrip() {
let f1 = [1.0_f64; FEATURE_DIM];
let f2 = [2.0_f64; FEATURE_DIM];
let f3 = [3.0_f64; FEATURE_DIM];
let features = [f1, f2, f3];
let stats = NormStats::from_features(&features);
let normalised = stats.normalize_batch(&features);
let recovered = stats.denormalize_batch(&normalised);
for (orig, rec) in features.iter().zip(recovered.iter()) {
for (o, r) in orig.iter().zip(rec.iter()) {
assert!(
(o - r).abs() < 1e-10,
"denormalize round-trip: orig={o}, recovered={r}"
);
}
}
}
/// Audit follow-up rec 2: PURGE_BARS gap between train and val.
/// Verifies that `generate_walk_forward_indices_from_timestamps`
/// inserts `val_start = train_end + PURGE_BARS` (not `train_end`).
/// Uses synthetic timestamps spanning enough months to produce a
/// valid fold under the default config.
#[test]
fn test_walk_forward_purge_gap_indices_from_timestamps() {
// Generate 30 months of dense timestamps (1 ts per minute, 8h
// per weekday) — same density convention as `make_bars_range`.
let start = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap_or_default();
let end = NaiveDate::from_ymd_opt(2025, 7, 1).unwrap_or_default();
let bars = make_bars_range(start, end);
let timestamps: Vec<i64> = bars
.iter()
.map(|b| b.timestamp.timestamp_nanos_opt().unwrap_or(0))
.collect();
let config = WalkForwardConfig::default();
let ranges = generate_walk_forward_indices_from_timestamps(&timestamps, &config);
assert!(!ranges.is_empty(), "expected at least one fold");
for r in &ranges {
assert_eq!(
r.val_start - r.train_end,
PURGE_BARS,
"fold {}: val_start({}) - train_end({}) must equal PURGE_BARS({}) — \
audit rec 2 lookahead gap",
r.fold,
r.val_start,
r.train_end,
PURGE_BARS
);
}
}
}