Merge branch 'feat/real-data-training-pipeline'

This commit is contained in:
jgrusewski
2026-02-23 19:38:56 +01:00
8 changed files with 3334 additions and 0 deletions

View File

@@ -204,6 +204,10 @@ name = "evaluate_ppo"
path = "examples/evaluate_ppo.rs"
required-features = ["cuda"]
[[example]]
name = "train_baseline"
path = "examples/train_baseline.rs"
[[bench]]
name = "microstructure_bench"
harness = false

View File

@@ -0,0 +1,473 @@
//! Download 730 days of Databento OHLCV-1m data in quarterly chunks
//!
//! Downloads futures baseline data for ML model training, split into
//! ~90-day quarterly files for efficient caching and resume support.
//!
//! Configuration is read from a universe TOML file (default:
//! `config/universe-futures-baseline.toml`).
//!
//! Usage:
//! # Dry run (preview config and cost estimate)
//! cargo run -p ml --example download_baseline --release -- --dry-run
//!
//! # Download with confirmation prompt
//! cargo run -p ml --example download_baseline --release
//!
//! # Skip confirmation prompt
//! cargo run -p ml --example download_baseline --release -- --yes
use anyhow::{Context, Result};
use chrono::{Datelike, NaiveDate};
use clap::Parser;
use databento::historical::timeseries::GetRangeToFileParams;
use databento::historical::DateTimeRange;
use databento::HistoricalClient;
use dbn::Schema;
use serde::Deserialize;
use std::env;
use std::fs;
use std::path::PathBuf;
use std::time::Instant;
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
#[derive(Debug, Parser)]
#[command(
name = "download_baseline",
about = "Download quarterly OHLCV-1m futures data from Databento"
)]
struct Opts {
/// Output directory for downloaded files
#[arg(long, default_value = "data/cache/futures-baseline")]
output_dir: String,
/// Path to universe configuration TOML
#[arg(long, default_value = "config/universe-futures-baseline.toml")]
universe_config: String,
/// Preview only, do not download
#[arg(long)]
dry_run: bool,
/// Skip confirmation prompt
#[arg(long)]
yes: bool,
}
// ---------------------------------------------------------------------------
// Universe config
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
struct UniverseConfig {
universe: UniverseMeta,
symbols: Vec<SymbolEntry>,
}
#[derive(Debug, Deserialize)]
struct UniverseMeta {
name: String,
#[allow(dead_code)]
description: String,
date_range_start: String,
date_range_end: String,
#[allow(dead_code)]
bar_size: String,
databento_dataset: String,
#[allow(dead_code)]
databento_schema: String,
}
#[derive(Debug, Deserialize)]
struct SymbolEntry {
symbol: String,
#[allow(dead_code)]
exchange: String,
#[allow(dead_code)]
asset_class: String,
#[allow(dead_code)]
trading_symbol: Option<String>,
#[allow(dead_code)]
min_daily_volume: Option<f64>,
}
// ---------------------------------------------------------------------------
// Quarter representation
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
struct Quarter {
label: String,
start: NaiveDate,
end: NaiveDate,
}
/// Split a date range into calendar-quarter chunks.
///
/// Each chunk begins at the later of `start` and the quarter boundary, and
/// ends at the earlier of `end` and the next quarter boundary.
fn generate_quarters(start: NaiveDate, end: NaiveDate) -> Vec<Quarter> {
let mut quarters = Vec::new();
let mut cursor = start;
while cursor < end {
let q = (cursor.month() - 1) / 3 + 1; // 1..4
let year = cursor.year();
let label = format!("{}-Q{}", year, q);
// Next quarter boundary
let next_q_start = if q == 4 {
NaiveDate::from_ymd_opt(year + 1, 1, 1)
} else {
NaiveDate::from_ymd_opt(year, q * 3 + 1, 1)
};
let chunk_end = match next_q_start {
Some(nq) if nq < end => nq,
_ => end,
};
quarters.push(Quarter {
label,
start: cursor,
end: chunk_end,
});
cursor = chunk_end;
}
quarters
}
// ---------------------------------------------------------------------------
// Conversion helpers
// ---------------------------------------------------------------------------
/// Convert a `chrono::NaiveDate` (interpreted as midnight UTC) to UNIX
/// nanoseconds for the Databento `DateTimeRange` API.
fn naive_date_to_unix_nanos(date: NaiveDate) -> u64 {
let ts = date
.and_hms_opt(0, 0, 0)
.map(|dt| dt.and_utc().timestamp())
.unwrap_or(0);
(ts as u64).saturating_mul(1_000_000_000)
}
/// Build a `DateTimeRange` from two `NaiveDate`s.
fn date_range(start: NaiveDate, end: NaiveDate) -> Result<DateTimeRange> {
let start_ns = naive_date_to_unix_nanos(start);
let end_ns = naive_date_to_unix_nanos(end);
DateTimeRange::try_from((start_ns, end_ns)).map_err(|e| anyhow::anyhow!("{}", e))
}
// ---------------------------------------------------------------------------
// Download logic
// ---------------------------------------------------------------------------
struct DownloadStats {
successful: usize,
failed: usize,
skipped: usize,
total_bytes: u64,
}
impl DownloadStats {
fn new() -> Self {
Self {
successful: 0,
failed: 0,
skipped: 0,
total_bytes: 0,
}
}
}
async fn download_quarter(
client: &mut HistoricalClient,
symbol: &str,
quarter: &Quarter,
dataset: &str,
output_dir: &PathBuf,
) -> Result<u64> {
let filename = format!("{}_{}.dbn.zst", symbol, quarter.label);
let symbol_dir = output_dir.join(symbol);
fs::create_dir_all(&symbol_dir).context("Failed to create symbol directory")?;
let file_path = symbol_dir.join(&filename);
// Resume support: skip if file exists and is non-empty
if file_path.exists() {
let meta = fs::metadata(&file_path).context("Failed to read file metadata")?;
if meta.len() > 0 {
return Ok(meta.len());
}
}
let dt_range = date_range(quarter.start, quarter.end)?;
let params = GetRangeToFileParams::builder()
.dataset(dataset.to_string())
.symbols(vec![symbol.to_string()])
.schema(Schema::Ohlcv1M)
.date_time_range(dt_range)
.path(file_path.clone())
.build();
let _decoder = client
.timeseries()
.get_range_to_file(&params)
.await
.with_context(|| {
format!(
"Failed to download {} for {}",
quarter.label, symbol
)
})?;
let meta = fs::metadata(&file_path).context("Failed to read downloaded file metadata")?;
Ok(meta.len())
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
#[tokio::main]
async fn main() -> Result<()> {
let opts = Opts::parse();
// Load .env for API key
dotenv::dotenv().ok();
// ------------------------------------------------------------------
// Parse universe config
// ------------------------------------------------------------------
let config_text =
fs::read_to_string(&opts.universe_config).with_context(|| {
format!("Failed to read universe config: {}", opts.universe_config)
})?;
let config: UniverseConfig =
toml::from_str(&config_text).context("Failed to parse universe config TOML")?;
let start_date = NaiveDate::parse_from_str(&config.universe.date_range_start, "%Y-%m-%d")
.context("Failed to parse date_range_start")?;
let end_date = NaiveDate::parse_from_str(&config.universe.date_range_end, "%Y-%m-%d")
.context("Failed to parse date_range_end")?;
let symbols: Vec<&str> = config.symbols.iter().map(|s| s.symbol.as_str()).collect();
let quarters = generate_quarters(start_date, end_date);
let total_days = (end_date - start_date).num_days();
// ------------------------------------------------------------------
// Print config summary
// ------------------------------------------------------------------
println!("================================================================================");
println!("Futures Baseline Download - Databento (Quarterly Chunks)");
println!("================================================================================");
println!();
println!("Universe: {}", config.universe.name);
println!("Dataset: {}", config.universe.databento_dataset);
println!("Schema: ohlcv-1m");
println!("Date range: {} to {}", start_date, end_date);
println!("Total days: {}", total_days);
println!(
"Symbols: {} ({})",
symbols.len(),
symbols.join(", ")
);
println!("Quarters: {}", quarters.len());
println!("Output: {}", opts.output_dir);
println!();
// Print quarter breakdown
println!("Quarter breakdown:");
for q in &quarters {
let days = (q.end - q.start).num_days();
println!(" {} : {} to {} ({} days)", q.label, q.start, q.end, days);
}
println!();
// Cost estimate ($0.12 per symbol per day)
let total_files = symbols.len() * quarters.len();
let estimated_cost = total_days as f64 * symbols.len() as f64 * 0.12;
println!("Total files: {}", total_files);
println!("Estimated cost: ${:.2} ({} symbols x {} days x $0.12/sym/day)",
estimated_cost, symbols.len(), total_days);
println!();
// ------------------------------------------------------------------
// Dry run exit
// ------------------------------------------------------------------
if opts.dry_run {
println!("[DRY RUN] Preview complete. Remove --dry-run to execute downloads.");
return Ok(());
}
// ------------------------------------------------------------------
// Confirmation
// ------------------------------------------------------------------
if !opts.yes {
println!(
"This will download data and may incur costs (~${:.2}).",
estimated_cost
);
print!("Proceed? (yes/no): ");
std::io::Write::flush(&mut std::io::stdout())?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let trimmed = input.trim();
if !trimmed.eq_ignore_ascii_case("yes") && !trimmed.eq_ignore_ascii_case("y") {
println!("Download cancelled.");
return Ok(());
}
println!();
}
// ------------------------------------------------------------------
// Initialize client
// ------------------------------------------------------------------
let api_key = env::var("DATABENTO_API_KEY")
.context("DATABENTO_API_KEY not found in environment or .env file")?;
let mut client = HistoricalClient::builder()
.key(api_key)
.map_err(|e| anyhow::anyhow!("Invalid API key: {}", e))?
.build()
.map_err(|e| anyhow::anyhow!("Failed to build Databento client: {}", e))?;
let output_dir = PathBuf::from(&opts.output_dir);
fs::create_dir_all(&output_dir)?;
println!("Databento client initialized. Output: {}", opts.output_dir);
println!();
// ------------------------------------------------------------------
// Download loop
// ------------------------------------------------------------------
let mut stats = DownloadStats::new();
let mut failed_items: Vec<String> = Vec::new();
for symbol in &symbols {
println!("[{}]", symbol);
for quarter in &quarters {
let filename = format!("{}_{}.dbn.zst", symbol, quarter.label);
let symbol_dir = output_dir.join(symbol);
let file_path = symbol_dir.join(&filename);
// Check for existing file before calling download
if file_path.exists() {
match fs::metadata(&file_path) {
Ok(meta) if meta.len() > 0 => {
println!(
" [SKIP] {} already exists ({} bytes)",
filename,
meta.len()
);
stats.skipped += 1;
stats.total_bytes += meta.len();
continue;
}
_ => {}
}
}
let t0 = Instant::now();
match download_quarter(
&mut client,
symbol,
quarter,
&config.universe.databento_dataset,
&output_dir,
)
.await
{
Ok(size) => {
let elapsed = t0.elapsed().as_secs_f64();
println!(
" [OK] {} -- {} bytes, {:.1}s",
filename, size, elapsed
);
stats.successful += 1;
stats.total_bytes += size;
}
Err(e) => {
println!(" [FAIL] {} -- {}", filename, e);
stats.failed += 1;
failed_items.push(format!("{}/{}", symbol, quarter.label));
}
}
}
println!();
}
// ------------------------------------------------------------------
// Summary
// ------------------------------------------------------------------
println!("================================================================================");
println!("DOWNLOAD SUMMARY");
println!("================================================================================");
println!();
println!("Successful: {}", stats.successful);
println!("Skipped: {}", stats.skipped);
println!("Failed: {}", stats.failed);
println!(
"Total size: {:.1} MB",
stats.total_bytes as f64 / 1_048_576.0
);
println!();
if !failed_items.is_empty() {
println!("Failed downloads:");
for item in &failed_items {
println!(" - {}", item);
}
println!();
println!("Re-run the command to retry failed downloads (existing files are skipped).");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_quarters_full_range() {
let start = NaiveDate::from_ymd_opt(2024, 3, 1).unwrap_or_default();
let end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap_or_default();
let qs = generate_quarters(start, end);
assert_eq!(qs.len(), 4); // Q1(partial), Q2, Q3, Q4
assert_eq!(qs.first().map(|q| q.label.as_str()), Some("2024-Q1"));
assert_eq!(qs.last().map(|q| q.label.as_str()), Some("2024-Q4"));
}
#[test]
fn test_generate_quarters_cross_year() {
let start = NaiveDate::from_ymd_opt(2024, 10, 1).unwrap_or_default();
let end = NaiveDate::from_ymd_opt(2025, 4, 1).unwrap_or_default();
let qs = generate_quarters(start, end);
assert_eq!(qs.len(), 3); // 2024-Q4, 2025-Q1, 2025-Q2(partial)
assert_eq!(qs.first().map(|q| q.label.as_str()), Some("2024-Q4"));
assert_eq!(qs.last().map(|q| q.label.as_str()), Some("2025-Q2"));
}
#[test]
fn test_generate_quarters_single_day() {
let start = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap_or_default();
let end = NaiveDate::from_ymd_opt(2024, 6, 16).unwrap_or_default();
let qs = generate_quarters(start, end);
assert_eq!(qs.len(), 1);
assert_eq!(qs.first().map(|q| q.label.as_str()), Some("2024-Q2"));
}
#[test]
fn test_naive_date_to_unix_nanos() {
let date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap_or_default();
let nanos = naive_date_to_unix_nanos(date);
// 2024-01-01 00:00:00 UTC = 1704067200 seconds
assert_eq!(nanos, 1_704_067_200_000_000_000);
}
}

View File

@@ -0,0 +1,894 @@
//! Walk-forward evaluation binary for DQN and PPO baseline models.
//!
//! Loads trained model checkpoints, runs inference on walk-forward test data,
//! computes financial metrics (Sharpe, drawdown, win rate, profit factor),
//! and generates a JSON report.
//!
//! # Usage
//!
//! ```bash
//! SQLX_OFFLINE=true cargo run -p ml --example evaluate_baseline -- \
//! --model both --models-dir ml/trained_models \
//! --data-dir data/cache/futures-baseline \
//! --output ml/trained_models/evaluation_report.json
//! ```
#![allow(unused_crate_dependencies)]
#![deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::{DateTime, TimeZone, Utc};
use clap::Parser;
use serde::Serialize;
use tracing::{error, info, warn};
use dbn::decode::DecodeRecord;
use ml::dqn::{DQNConfig, DQN};
use ml::features::extraction::extract_ml_features;
use ml::ppo::ppo::{PPOConfig, PPO};
use ml::types::OHLCVBar;
use ml::walk_forward::{generate_walk_forward_windows, NormStats, WalkForwardConfig};
// ---------------------------------------------------------------------------
// CLI Arguments
// ---------------------------------------------------------------------------
/// Walk-forward evaluation binary for DQN/PPO baseline models.
#[derive(Parser, Debug)]
#[command(
name = "evaluate_baseline",
about = "Evaluate trained DQN/PPO checkpoints with walk-forward test data"
)]
struct Args {
/// Directory containing trained model checkpoints
#[arg(long, default_value = "ml/trained_models")]
models_dir: PathBuf,
/// Path to directory containing .dbn.zst files
#[arg(long, default_value = "data/cache/futures-baseline")]
data_dir: PathBuf,
/// Output path for evaluation report JSON
#[arg(long, default_value = "ml/trained_models/evaluation_report.json")]
output: PathBuf,
/// Which model(s) to evaluate: "dqn", "ppo", or "both"
#[arg(long, default_value = "both")]
model: String,
/// Feature dimension (must match extract_ml_features output)
#[arg(long, default_value_t = 51)]
feature_dim: usize,
/// Number of actions for the DQN/PPO action space
#[arg(long, default_value_t = 3)]
num_actions: usize,
}
// ---------------------------------------------------------------------------
// Report Data Types
// ---------------------------------------------------------------------------
/// Metrics for a single fold/model combination.
#[derive(Debug, Serialize)]
struct FoldMetrics {
fold: usize,
model: String,
sharpe_ratio: f64,
max_drawdown_pct: f64,
win_rate_pct: f64,
profit_factor: f64,
total_return_pct: f64,
num_trades: usize,
test_start: String,
test_end: String,
}
/// Aggregate metrics across all folds for both models.
#[derive(Debug, Serialize)]
struct AggregateMetrics {
dqn_avg_sharpe: f64,
dqn_avg_drawdown: f64,
dqn_avg_win_rate: f64,
ppo_avg_sharpe: f64,
ppo_avg_drawdown: f64,
ppo_avg_win_rate: f64,
}
/// Sanity checks to flag obviously broken models.
#[derive(Debug, Serialize)]
struct SanityChecks {
/// True if any model has Sharpe > 0
beats_random: bool,
/// True if all 3 actions (buy/sell/hold) were used
action_diversity: bool,
/// True if Sharpe std < 2x |mean Sharpe|
fold_consistency: bool,
}
/// Full evaluation report written to JSON.
#[derive(Debug, Serialize)]
struct EvaluationReport {
folds: Vec<FoldMetrics>,
aggregate: AggregateMetrics,
sanity_checks: SanityChecks,
}
// ---------------------------------------------------------------------------
// DBN Loading (reused from train_baseline)
// ---------------------------------------------------------------------------
/// Recursively discover .dbn.zst files under `dir`.
fn find_dbn_files(dir: &Path) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
if !dir.exists() {
anyhow::bail!("Data directory does not exist: {}", dir.display());
}
collect_dbn_files_recursive(dir, &mut files)?;
files.sort();
Ok(files)
}
/// Recursive helper that walks the directory tree without `walkdir`.
fn collect_dbn_files_recursive(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
let entries = std::fs::read_dir(dir)
.with_context(|| format!("Cannot read directory: {}", dir.display()))?;
for entry in entries {
let entry = entry.with_context(|| "Failed to read dir entry")?;
let path = entry.path();
if path.is_dir() {
collect_dbn_files_recursive(&path, out)?;
} else if path
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.ends_with(".dbn.zst"))
.unwrap_or(false)
{
out.push(path);
}
}
Ok(())
}
/// Load OHLCV bars from a single .dbn.zst file.
fn load_bars_from_dbn(path: &Path) -> Result<Vec<OHLCVBar>> {
use dbn::decode::dbn::Decoder;
use dbn::OhlcvMsg;
let file = std::fs::File::open(path)
.with_context(|| format!("Cannot open DBN file: {}", path.display()))?;
let buf = std::io::BufReader::new(file);
let mut decoder = Decoder::new(buf)
.with_context(|| format!("Failed to create DBN decoder for {}", path.display()))?;
let mut bars = Vec::new();
while let Some(record) = decoder
.decode_record::<OhlcvMsg>()
.with_context(|| format!("Error decoding records from {}", path.display()))?
{
let ts_nanos = record.hd.ts_event;
let timestamp = nanos_to_datetime(ts_nanos);
let price_scale = 1e-9_f64;
bars.push(OHLCVBar {
timestamp,
open: record.open as f64 * price_scale,
high: record.high as f64 * price_scale,
low: record.low as f64 * price_scale,
close: record.close as f64 * price_scale,
volume: record.volume as f64,
});
}
Ok(bars)
}
/// Convert nanosecond UNIX timestamp to chrono DateTime<Utc>.
fn nanos_to_datetime(nanos: u64) -> DateTime<Utc> {
let secs = (nanos / 1_000_000_000) as i64;
let subsec_nanos = (nanos % 1_000_000_000) as u32;
Utc.timestamp_opt(secs, subsec_nanos)
.single()
.unwrap_or_else(Utc::now)
}
/// Load all OHLCV bars from a directory of .dbn.zst files, sorted chronologically.
fn load_all_bars(data_dir: &Path) -> Result<Vec<OHLCVBar>> {
let dbn_files = find_dbn_files(data_dir)?;
if dbn_files.is_empty() {
anyhow::bail!(
"No .dbn.zst files found in {}. Run download_baseline first.",
data_dir.display()
);
}
info!(
"Found {} .dbn.zst files in {}",
dbn_files.len(),
data_dir.display()
);
let mut all_bars = Vec::new();
for path in &dbn_files {
match load_bars_from_dbn(path) {
Ok(bars) => {
info!(" {} -> {} bars", path.display(), bars.len());
all_bars.extend(bars);
}
Err(e) => {
warn!(" Skipping {} — {}", path.display(), e);
}
}
}
all_bars.sort_by_key(|b| b.timestamp);
info!("Total bars loaded: {}", all_bars.len());
Ok(all_bars)
}
// ---------------------------------------------------------------------------
// Financial Metrics
// ---------------------------------------------------------------------------
/// Container for computed financial metrics from a sequence of trade returns.
struct ComputedMetrics {
sharpe_ratio: f64,
max_drawdown_pct: f64,
win_rate_pct: f64,
profit_factor: f64,
total_return_pct: f64,
num_trades: usize,
}
/// Compute financial metrics from a sequence of per-bar trade returns.
///
/// - Sharpe: annualized (mean / std * sqrt(252))
/// - Max drawdown: largest peak-to-trough drop on cumulative equity curve (%)
/// - Win rate: percentage of returns > 0
/// - Profit factor: gross_profit / gross_loss (inf if no losses)
/// - Total return: sum of returns * 100 (as percentage)
/// - Num trades: count of non-zero returns (BUY or SELL actions)
fn compute_metrics(returns: &[f64]) -> ComputedMetrics {
let n = returns.len();
if n == 0 {
return ComputedMetrics {
sharpe_ratio: 0.0,
max_drawdown_pct: 0.0,
win_rate_pct: 0.0,
profit_factor: 0.0,
total_return_pct: 0.0,
num_trades: 0,
};
}
// Count actual trades (non-zero returns, i.e. BUY or SELL actions)
let num_trades = returns.iter().filter(|&&r| r.abs() > 1e-12).count();
// Mean and std of returns
let sum: f64 = returns.iter().sum();
let mean = sum / n as f64;
let variance: f64 = returns.iter().map(|&r| (r - mean).powi(2)).sum::<f64>() / n as f64;
let std = variance.sqrt();
// Annualized Sharpe ratio
let sharpe_ratio = if std > 1e-12 {
(mean / std) * 252.0_f64.sqrt()
} else {
0.0
};
// Max drawdown on cumulative equity curve
let mut equity = 1.0_f64;
let mut peak = 1.0_f64;
let mut max_drawdown = 0.0_f64;
for &ret in returns {
equity += ret;
if equity > peak {
peak = equity;
}
let drawdown = if peak > 1e-12 {
(peak - equity) / peak
} else {
0.0
};
if drawdown > max_drawdown {
max_drawdown = drawdown;
}
}
let max_drawdown_pct = max_drawdown * 100.0;
// Win rate
let wins = returns.iter().filter(|&&r| r > 0.0).count();
let win_rate_pct = if num_trades > 0 {
(wins as f64 / num_trades as f64) * 100.0
} else {
0.0
};
// Profit factor
let gross_profit: f64 = returns.iter().filter(|&&r| r > 0.0).sum();
let gross_loss: f64 = returns.iter().filter(|&&r| r < 0.0).map(|&r| r.abs()).sum();
let profit_factor = if gross_loss > 1e-12 {
gross_profit / gross_loss
} else if gross_profit > 0.0 {
f64::INFINITY
} else {
0.0
};
// Total return
let total_return_pct = sum * 100.0;
ComputedMetrics {
sharpe_ratio,
max_drawdown_pct,
win_rate_pct,
profit_factor,
total_return_pct,
num_trades,
}
}
// ---------------------------------------------------------------------------
// DQN Evaluation
// ---------------------------------------------------------------------------
/// Run DQN inference on test features and return per-bar trade returns and
/// action counts (buy, sell, hold).
fn evaluate_dqn_fold(
fold: usize,
test_features: &[[f64; 51]],
test_bars: &[OHLCVBar],
models_dir: &Path,
args: &Args,
) -> Result<(Vec<f64>, [usize; 3])> {
let ckpt_path = models_dir.join(format!("dqn_fold{}_best.safetensors", fold));
if !ckpt_path.exists() {
anyhow::bail!(
"DQN checkpoint not found: {}",
ckpt_path.display()
);
}
// Create DQN with same config as training
let config = DQNConfig {
state_dim: args.feature_dim,
num_actions: args.num_actions,
hidden_dims: vec![128, 64],
learning_rate: 1e-4,
gamma: 0.99,
epsilon_start: 0.0, // No exploration during evaluation
epsilon_end: 0.0,
epsilon_decay: 1.0,
replay_buffer_capacity: 100, // Minimal buffer, not used for eval
batch_size: 64,
min_replay_size: 64,
target_update_freq: 500,
warmup_steps: 0,
use_double_dqn: true,
use_huber_loss: true,
use_per: false,
use_dueling: false,
use_distributional: false,
use_noisy_nets: false,
use_cql: false,
use_iqn: false,
use_cvar_action_selection: false,
..DQNConfig::default()
};
let mut dqn = DQN::new(config).context("Failed to create DQN model")?;
// Set epsilon to zero for pure greedy evaluation
dqn.set_epsilon(0.0);
// Load trained weights
dqn.load_from_safetensors(&ckpt_path.to_string_lossy())
.with_context(|| format!("Failed to load DQN checkpoint: {}", ckpt_path.display()))?;
info!(
" [DQN] Loaded checkpoint: {} (eps={})",
ckpt_path.display(),
dqn.get_epsilon()
);
// Run inference
let n = test_features.len();
let mut returns = Vec::with_capacity(n);
let mut action_counts = [0_usize; 3]; // [buy, sell, hold]
for i in 0..n.saturating_sub(1) {
let feat = match test_features.get(i) {
Some(f) => f,
None => continue,
};
let state: Vec<f32> = feat.iter().map(|&v| v as f32).collect();
// Select greedy action
let action_idx = match dqn.select_action(&state) {
Ok(fa) => {
let idx = fa.to_index().min(args.num_actions.saturating_sub(1));
idx as u8
}
Err(e) => {
warn!(" [DQN] select_action error at step {}: {}", i, e);
2 // Default to HOLD on error
}
};
// Map action index to trade direction
let mapped_action = action_idx.min(2);
// Track action diversity
if let Some(count) = action_counts.get_mut(mapped_action as usize) {
*count += 1;
}
// Compute return
let close_cur = test_bars.get(i).map(|b| b.close).unwrap_or(0.0);
let close_next = test_bars.get(i + 1).map(|b| b.close).unwrap_or(close_cur);
let price_change = close_next - close_cur;
let ret = match mapped_action {
0 => price_change, // BUY
1 => -price_change, // SELL
_ => 0.0, // HOLD
};
returns.push(ret);
}
Ok((returns, action_counts))
}
// ---------------------------------------------------------------------------
// PPO Evaluation
// ---------------------------------------------------------------------------
/// Run PPO inference on test features and return per-bar trade returns and
/// action counts (buy, sell, hold).
fn evaluate_ppo_fold(
fold: usize,
test_features: &[[f64; 51]],
test_bars: &[OHLCVBar],
models_dir: &Path,
args: &Args,
) -> Result<(Vec<f64>, [usize; 3])> {
let actor_path = models_dir.join(format!("ppo_fold{}_actor.safetensors", fold));
let critic_path = models_dir.join(format!("ppo_fold{}_critic.safetensors", fold));
if !actor_path.exists() {
anyhow::bail!(
"PPO actor checkpoint not found: {}",
actor_path.display()
);
}
if !critic_path.exists() {
anyhow::bail!(
"PPO critic checkpoint not found: {}",
critic_path.display()
);
}
// Create PPO config matching training
let config = PPOConfig {
state_dim: args.feature_dim,
num_actions: args.num_actions,
policy_hidden_dims: vec![128, 64],
value_hidden_dims: vec![128, 64],
policy_learning_rate: 3e-4,
value_learning_rate: 1e-3,
clip_epsilon: 0.2,
value_loss_coeff: 0.5,
entropy_coeff: 0.01,
batch_size: 64,
mini_batch_size: 64,
num_epochs: 4,
max_grad_norm: 0.5,
use_lstm: false,
..PPOConfig::default()
};
// Load PPO from checkpoint
let ppo = PPO::load_checkpoint(
&actor_path.to_string_lossy(),
&critic_path.to_string_lossy(),
config,
candle_core::Device::Cpu,
)
.with_context(|| {
format!(
"Failed to load PPO checkpoint: actor={}, critic={}",
actor_path.display(),
critic_path.display()
)
})?;
info!(
" [PPO] Loaded checkpoint: {}",
actor_path.display()
);
// Run inference
let n = test_features.len();
let mut returns = Vec::with_capacity(n);
let mut action_counts = [0_usize; 3]; // [buy, sell, hold]
for i in 0..n.saturating_sub(1) {
let feat = match test_features.get(i) {
Some(f) => f,
None => continue,
};
let state: Vec<f32> = feat.iter().map(|&v| v as f32).collect();
// Get action from policy
let action_idx = match ppo.act(&state) {
Ok((action, _value)) => action.to_int().min(2),
Err(e) => {
warn!(" [PPO] act error at step {}: {}", i, e);
2 // Default to HOLD on error
}
};
// Track action diversity
if let Some(count) = action_counts.get_mut(action_idx as usize) {
*count += 1;
}
// Compute return
let close_cur = test_bars.get(i).map(|b| b.close).unwrap_or(0.0);
let close_next = test_bars.get(i + 1).map(|b| b.close).unwrap_or(close_cur);
let price_change = close_next - close_cur;
let ret = match action_idx {
0 => price_change, // BUY
1 => -price_change, // SELL
_ => 0.0, // HOLD
};
returns.push(ret);
}
Ok((returns, action_counts))
}
// ---------------------------------------------------------------------------
// Aggregate & Sanity Checks
// ---------------------------------------------------------------------------
/// Compute average metrics for a specific model across all folds.
fn compute_aggregate(folds: &[FoldMetrics], model_name: &str) -> (f64, f64, f64) {
let model_folds: Vec<&FoldMetrics> = folds.iter().filter(|f| f.model == model_name).collect();
if model_folds.is_empty() {
return (0.0, 0.0, 0.0);
}
let n = model_folds.len() as f64;
let avg_sharpe = model_folds.iter().map(|f| f.sharpe_ratio).sum::<f64>() / n;
let avg_dd = model_folds.iter().map(|f| f.max_drawdown_pct).sum::<f64>() / n;
let avg_wr = model_folds.iter().map(|f| f.win_rate_pct).sum::<f64>() / n;
(avg_sharpe, avg_dd, avg_wr)
}
/// Run sanity checks across all fold metrics.
fn run_sanity_checks(
folds: &[FoldMetrics],
all_action_counts: &[[usize; 3]],
) -> SanityChecks {
// beats_random: any model Sharpe > 0?
let beats_random = folds.iter().any(|f| f.sharpe_ratio > 0.0);
// action_diversity: all 3 actions used across all evaluations?
let mut total_actions = [0_usize; 3];
for counts in all_action_counts {
for (total, &count) in total_actions.iter_mut().zip(counts.iter()) {
*total += count;
}
}
let action_diversity = total_actions.iter().all(|&c| c > 0);
// fold_consistency: std(Sharpe) < 2 * |mean(Sharpe)| across all folds
let sharpe_values: Vec<f64> = folds.iter().map(|f| f.sharpe_ratio).collect();
let fold_consistency = if sharpe_values.is_empty() {
false
} else {
let n = sharpe_values.len() as f64;
let mean_sharpe = sharpe_values.iter().sum::<f64>() / n;
let var = sharpe_values
.iter()
.map(|&s| (s - mean_sharpe).powi(2))
.sum::<f64>()
/ n;
let std_sharpe = var.sqrt();
std_sharpe < 2.0 * mean_sharpe.abs()
};
SanityChecks {
beats_random,
action_diversity,
fold_consistency,
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
fn main() -> Result<()> {
// Initialize tracing
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let args = Args::parse();
let eval_dqn = args.model == "dqn" || args.model == "both";
let eval_ppo = args.model == "ppo" || args.model == "both";
info!("=== Walk-Forward Baseline Evaluation ===");
info!(" Model(s): {}", args.model);
info!(" Models dir: {}", args.models_dir.display());
info!(" Data dir: {}", args.data_dir.display());
info!(" Output: {}", args.output.display());
info!(" Feature dim: {}", args.feature_dim);
info!(" Num actions: {}", args.num_actions);
// 1. Load all OHLCV bars from DBN files
info!("Step 1/5: Loading OHLCV bars from DBN files...");
let bars = load_all_bars(&args.data_dir)?;
if bars.is_empty() {
anyhow::bail!("No bars loaded from {}", args.data_dir.display());
}
info!(
" Loaded {} bars ({} to {})",
bars.len(),
bars.first().map(|b| b.timestamp.to_string()).unwrap_or_default(),
bars.last().map(|b| b.timestamp.to_string()).unwrap_or_default(),
);
// 2. Generate walk-forward windows (same config as training)
info!("Step 2/5: Generating walk-forward windows...");
let wf_config = WalkForwardConfig::default();
let windows = generate_walk_forward_windows(&bars, &wf_config);
if windows.is_empty() {
anyhow::bail!(
"No walk-forward windows generated. Need at least {} months of data.",
wf_config.initial_train_months + wf_config.val_months + wf_config.test_months
);
}
info!(" Generated {} walk-forward folds", windows.len());
// 3. Evaluate each fold
info!("Step 3/5: Evaluating models on test data...");
let mut all_fold_metrics: Vec<FoldMetrics> = Vec::new();
let mut all_action_counts: Vec<[usize; 3]> = Vec::new();
for window in &windows {
info!(
"--- Fold {} --- Test: {} bars ({} to {})",
window.fold,
window.test.len(),
window.test
.first()
.map(|b| b.timestamp.to_string())
.unwrap_or_default(),
window.test
.last()
.map(|b| b.timestamp.to_string())
.unwrap_or_default(),
);
// Load NormStats from training
let norm_path = args
.models_dir
.join(format!("norm_stats_fold{}.json", window.fold));
let norm_stats: NormStats = if norm_path.exists() {
let norm_json = std::fs::read_to_string(&norm_path)
.with_context(|| format!("Failed to read {}", norm_path.display()))?;
serde_json::from_str(&norm_json)
.with_context(|| format!("Failed to parse {}", norm_path.display()))?
} else {
warn!(
" NormStats not found at {}, computing from test data (degraded)",
norm_path.display()
);
// Fallback: compute from test data (not ideal, but allows evaluation)
let test_feat = extract_ml_features(&window.test)
.context("Feature extraction failed for test bars")?;
NormStats::from_features(&test_feat)
};
// Extract features from test bars
let test_features = match extract_ml_features(&window.test) {
Ok(f) => f,
Err(e) => {
warn!(
" Fold {} — test feature extraction failed: {}",
window.fold, e
);
continue;
}
};
if test_features.is_empty() {
warn!(" Fold {} — empty test features, skipping", window.fold);
continue;
}
// Normalize test features
let test_norm = norm_stats.normalize_batch(&test_features);
// Align bars to features (features skip warmup period)
let warmup_offset = window.test.len().saturating_sub(test_norm.len());
let test_bars_aligned = if warmup_offset < window.test.len() {
&window.test[warmup_offset..]
} else {
&window.test
};
// Test period date range for the report
let test_start = test_bars_aligned
.first()
.map(|b| b.timestamp.format("%Y-%m-%d").to_string())
.unwrap_or_default();
let test_end = test_bars_aligned
.last()
.map(|b| b.timestamp.format("%Y-%m-%d").to_string())
.unwrap_or_default();
// Evaluate DQN
if eval_dqn {
match evaluate_dqn_fold(
window.fold,
&test_norm,
test_bars_aligned,
&args.models_dir,
&args,
) {
Ok((returns, action_counts)) => {
let metrics = compute_metrics(&returns);
info!(
" [DQN] Fold {} — Sharpe={:.4} MaxDD={:.2}% WinRate={:.1}% PF={:.2} Return={:.4}% Trades={}",
window.fold,
metrics.sharpe_ratio,
metrics.max_drawdown_pct,
metrics.win_rate_pct,
metrics.profit_factor,
metrics.total_return_pct,
metrics.num_trades,
);
info!(
" [DQN] Actions — BUY={} SELL={} HOLD={}",
action_counts.first().copied().unwrap_or(0),
action_counts.get(1).copied().unwrap_or(0),
action_counts.get(2).copied().unwrap_or(0),
);
all_fold_metrics.push(FoldMetrics {
fold: window.fold,
model: "dqn".to_string(),
sharpe_ratio: metrics.sharpe_ratio,
max_drawdown_pct: metrics.max_drawdown_pct,
win_rate_pct: metrics.win_rate_pct,
profit_factor: metrics.profit_factor,
total_return_pct: metrics.total_return_pct,
num_trades: metrics.num_trades,
test_start: test_start.clone(),
test_end: test_end.clone(),
});
all_action_counts.push(action_counts);
}
Err(e) => {
error!(" [DQN] Fold {} evaluation failed: {}", window.fold, e);
}
}
}
// Evaluate PPO
if eval_ppo {
match evaluate_ppo_fold(
window.fold,
&test_norm,
test_bars_aligned,
&args.models_dir,
&args,
) {
Ok((returns, action_counts)) => {
let metrics = compute_metrics(&returns);
info!(
" [PPO] Fold {} — Sharpe={:.4} MaxDD={:.2}% WinRate={:.1}% PF={:.2} Return={:.4}% Trades={}",
window.fold,
metrics.sharpe_ratio,
metrics.max_drawdown_pct,
metrics.win_rate_pct,
metrics.profit_factor,
metrics.total_return_pct,
metrics.num_trades,
);
info!(
" [PPO] Actions — BUY={} SELL={} HOLD={}",
action_counts.first().copied().unwrap_or(0),
action_counts.get(1).copied().unwrap_or(0),
action_counts.get(2).copied().unwrap_or(0),
);
all_fold_metrics.push(FoldMetrics {
fold: window.fold,
model: "ppo".to_string(),
sharpe_ratio: metrics.sharpe_ratio,
max_drawdown_pct: metrics.max_drawdown_pct,
win_rate_pct: metrics.win_rate_pct,
profit_factor: metrics.profit_factor,
total_return_pct: metrics.total_return_pct,
num_trades: metrics.num_trades,
test_start: test_start.clone(),
test_end: test_end.clone(),
});
all_action_counts.push(action_counts);
}
Err(e) => {
error!(" [PPO] Fold {} evaluation failed: {}", window.fold, e);
}
}
}
}
// 4. Compute aggregate metrics
info!("Step 4/5: Computing aggregate metrics...");
let (dqn_avg_sharpe, dqn_avg_drawdown, dqn_avg_win_rate) =
compute_aggregate(&all_fold_metrics, "dqn");
let (ppo_avg_sharpe, ppo_avg_drawdown, ppo_avg_win_rate) =
compute_aggregate(&all_fold_metrics, "ppo");
let aggregate = AggregateMetrics {
dqn_avg_sharpe,
dqn_avg_drawdown,
dqn_avg_win_rate,
ppo_avg_sharpe,
ppo_avg_drawdown,
ppo_avg_win_rate,
};
info!(" DQN — avg Sharpe={:.4} avg MaxDD={:.2}% avg WinRate={:.1}%",
dqn_avg_sharpe, dqn_avg_drawdown, dqn_avg_win_rate);
info!(" PPO — avg Sharpe={:.4} avg MaxDD={:.2}% avg WinRate={:.1}%",
ppo_avg_sharpe, ppo_avg_drawdown, ppo_avg_win_rate);
// 5. Sanity checks & report
info!("Step 5/5: Running sanity checks and saving report...");
let sanity_checks = run_sanity_checks(&all_fold_metrics, &all_action_counts);
info!(" Beats random: {}", sanity_checks.beats_random);
info!(" Action diversity: {}", sanity_checks.action_diversity);
info!(" Fold consistency: {}", sanity_checks.fold_consistency);
let report = EvaluationReport {
folds: all_fold_metrics,
aggregate,
sanity_checks,
};
// Save report
if let Some(parent) = args.output.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create output dir: {}", parent.display()))?;
}
let report_json = serde_json::to_string_pretty(&report)
.context("Failed to serialize evaluation report")?;
std::fs::write(&args.output, &report_json)
.with_context(|| format!("Failed to write report to {}", args.output.display()))?;
info!("=== Evaluation Complete ===");
info!(" Report saved to: {}", args.output.display());
info!(" Total fold evaluations: {}", report.folds.len());
Ok(())
}

View File

@@ -0,0 +1,296 @@
//! Hyperopt Runner for DQN/PPO on Real Databento Market Data
//!
//! Runs hyperparameter optimization using Particle Swarm Optimization (PSO) for
//! DQN, PPO, or both models on downloaded Databento futures data. This binary is
//! part of the walk-forward real-data training pipeline.
//!
//! ## Usage
//!
//! ```bash
//! # Run DQN hyperopt only (10 trials, 10 epochs each)
//! SQLX_OFFLINE=true cargo run -p ml --example hyperopt_baseline --release -- \
//! --model dqn --trials 10 --epochs 10 \
//! --data-dir data/cache/futures-baseline
//!
//! # Run PPO hyperopt only
//! SQLX_OFFLINE=true cargo run -p ml --example hyperopt_baseline --release -- \
//! --model ppo --trials 20 --epochs 15 \
//! --data-dir data/cache/futures-baseline
//!
//! # Run both models (default)
//! SQLX_OFFLINE=true cargo run -p ml --example hyperopt_baseline --release -- \
//! --data-dir data/cache/futures-baseline
//! ```
//!
//! ## Output
//!
//! Results are written as JSON to `--output` (default: `ml/trained_models/hyperopt_results.json`).
//!
//! ```json
//! {
//! "dqn": { "best_objective": 0.123, "best_params": {...}, "trials": 20, "elapsed_secs": 45.3 },
//! "ppo": { "best_objective": 0.456, "best_params": {...}, "trials": 20, "elapsed_secs": 67.8 }
//! }
//! ```
use anyhow::{Context, Result};
use clap::Parser;
use serde_json::Value;
use std::path::PathBuf;
use std::time::Instant;
use tracing::{error, info, warn, Level};
use ml::hyperopt::adapters::dqn::DQNTrainer;
use ml::hyperopt::adapters::ppo::PPOTrainer;
use ml::hyperopt::paths::{generate_run_id, TrainingPaths};
use ml::hyperopt::ArgminOptimizer;
/// Hyperparameter optimization runner for DQN/PPO on Databento market data
#[derive(Parser, Debug)]
#[command(name = "hyperopt-baseline")]
#[command(about = "Run hyperparameter optimization for DQN/PPO on real Databento futures data")]
struct Args {
/// Model to optimize: "dqn", "ppo", or "both"
#[arg(long, default_value = "both")]
model: String,
/// Number of PSO trials per model
#[arg(long, default_value = "20")]
trials: usize,
/// Number of initial LHS (Latin Hypercube Sampling) samples
#[arg(long, default_value = "5")]
n_initial: usize,
/// Training epochs per trial (DQN) / episodes per trial (PPO)
#[arg(long, default_value = "10")]
epochs: usize,
/// Path to downloaded DBN data directory
#[arg(long, default_value = "data/cache/futures-baseline")]
data_dir: PathBuf,
/// Output path for JSON results
#[arg(long, default_value = "ml/trained_models/hyperopt_results.json")]
output: PathBuf,
/// Random seed for reproducibility
#[arg(long, default_value = "42")]
seed: u64,
/// Base directory for training run outputs (checkpoints, logs, metrics)
#[arg(long, default_value = "/tmp/ml_training")]
base_dir: String,
}
/// Result entry for one model's hyperopt run
fn build_model_result(
best_objective: f64,
best_params_json: Value,
num_trials: usize,
elapsed_secs: f64,
) -> Value {
serde_json::json!({
"best_objective": best_objective,
"best_params": best_params_json,
"trials": num_trials,
"elapsed_secs": elapsed_secs,
})
}
fn run_dqn_hyperopt(args: &Args) -> Result<Value> {
info!("========================================");
info!(" DQN Hyperparameter Optimization");
info!("========================================");
let run_id = generate_run_id("hyperopt-dqn");
let training_paths = TrainingPaths::new(&args.base_dir, "dqn", &run_id);
info!("Run ID: {}", run_id);
info!("Data directory: {}", args.data_dir.display());
info!("Epochs per trial: {}", args.epochs);
info!("Trials: {}", args.trials);
let trainer = DQNTrainer::new(&args.data_dir, args.epochs)
.context("Failed to create DQN trainer")?
.with_training_paths(training_paths);
let optimizer = ArgminOptimizer::builder()
.max_trials(args.trials)
.n_initial(args.n_initial)
.seed(args.seed)
.build();
let start = Instant::now();
let result = optimizer
.optimize(trainer)
.context("DQN hyperopt optimization failed")?;
let elapsed = start.elapsed().as_secs_f64();
info!("DQN hyperopt complete:");
info!(" Best objective: {:.6}", result.best_objective);
info!(" Total trials: {}", result.all_trials.len());
info!(" Elapsed: {:.1}s", elapsed);
let best_params_json = serde_json::to_value(&result.best_params)
.ok()
.unwrap_or(Value::Null);
Ok(build_model_result(
result.best_objective,
best_params_json,
result.all_trials.len(),
elapsed,
))
}
fn run_ppo_hyperopt(args: &Args) -> Result<Value> {
info!("========================================");
info!(" PPO Hyperparameter Optimization");
info!("========================================");
let run_id = generate_run_id("hyperopt-ppo");
let training_paths = TrainingPaths::new(&args.base_dir, "ppo", &run_id);
info!("Run ID: {}", run_id);
info!("Data directory: {}", args.data_dir.display());
info!("Episodes per trial: {}", args.epochs);
info!("Trials: {}", args.trials);
let trainer = PPOTrainer::new(&args.data_dir, args.epochs)
.context("Failed to create PPO trainer")?
.with_training_paths(training_paths);
let optimizer = ArgminOptimizer::builder()
.max_trials(args.trials)
.n_initial(args.n_initial)
.seed(args.seed)
.build();
let start = Instant::now();
let result = optimizer
.optimize(trainer)
.context("PPO hyperopt optimization failed")?;
let elapsed = start.elapsed().as_secs_f64();
info!("PPO hyperopt complete:");
info!(" Best objective: {:.6}", result.best_objective);
info!(" Total trials: {}", result.all_trials.len());
info!(" Elapsed: {:.1}s", elapsed);
let best_params_json = serde_json::to_value(&result.best_params)
.ok()
.unwrap_or(Value::Null);
Ok(build_model_result(
result.best_objective,
best_params_json,
result.all_trials.len(),
elapsed,
))
}
fn main() -> Result<()> {
// Initialize tracing
tracing_subscriber::fmt()
.with_max_level(Level::INFO)
.with_target(false)
.init();
let args = Args::parse();
info!("========================================");
info!(" Hyperopt Baseline Runner");
info!("========================================");
info!("Model: {}", args.model);
info!("Trials: {}", args.trials);
info!("Initial LHS samples: {}", args.n_initial);
info!("Epochs/episodes per trial: {}", args.epochs);
info!("Data directory: {}", args.data_dir.display());
info!("Output: {}", args.output.display());
info!("Seed: {}", args.seed);
// Verify data directory exists
if !args.data_dir.exists() {
anyhow::bail!(
"Data directory not found: {}. Run `download_baseline` first.",
args.data_dir.display()
);
}
// Verify trials > n_initial (ArgminOptimizer requirement)
if args.trials <= args.n_initial {
anyhow::bail!(
"trials ({}) must be greater than n_initial ({})",
args.trials,
args.n_initial
);
}
// Create output directory
if let Some(parent) = args.output.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create output directory: {}", parent.display()))?;
}
let run_dqn = args.model == "dqn" || args.model == "both";
let run_ppo = args.model == "ppo" || args.model == "both";
if !run_dqn && !run_ppo {
anyhow::bail!(
"Invalid --model value '{}'. Must be 'dqn', 'ppo', or 'both'.",
args.model
);
}
let mut results = serde_json::Map::new();
// Run DQN hyperopt
if run_dqn {
match run_dqn_hyperopt(&args) {
Ok(dqn_result) => {
results.insert("dqn".to_string(), dqn_result);
},
Err(e) => {
error!("DQN hyperopt failed: {:#}", e);
warn!("Continuing with remaining models...");
results.insert(
"dqn".to_string(),
serde_json::json!({ "error": format!("{:#}", e) }),
);
},
}
}
// Run PPO hyperopt
if run_ppo {
match run_ppo_hyperopt(&args) {
Ok(ppo_result) => {
results.insert("ppo".to_string(), ppo_result);
},
Err(e) => {
error!("PPO hyperopt failed: {:#}", e);
warn!("Continuing...");
results.insert(
"ppo".to_string(),
serde_json::json!({ "error": format!("{:#}", e) }),
);
},
}
}
// Write results to JSON
let output_json = Value::Object(results);
let output_str = serde_json::to_string_pretty(&output_json)
.context("Failed to serialize results to JSON")?;
std::fs::write(&args.output, &output_str)
.with_context(|| format!("Failed to write results to {}", args.output.display()))?;
info!("========================================");
info!(" Results saved to: {}", args.output.display());
info!("========================================");
info!("{}", output_str);
Ok(())
}

View File

@@ -0,0 +1,834 @@
//! Walk-forward training binary for DQN and PPO models.
//!
//! Trains models using expanding walk-forward windows on real OHLCV data loaded
//! from Databento DBN files. Supports early stopping, checkpoint saving, and
//! normalization statistics export for reproducible evaluation.
//!
//! # Usage
//!
//! ```bash
//! SQLX_OFFLINE=true cargo run -p ml --example train_baseline -- \
//! --model both --epochs 50 --batch-size 128 \
//! --data-dir data/cache/futures-baseline \
//! --output-dir ml/trained_models
//! ```
#![allow(unused_crate_dependencies)]
#![deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::{DateTime, TimeZone, Utc};
use clap::Parser;
use rand::Rng;
use tracing::{error, info, warn};
use dbn::decode::DecodeRecord;
use ml::dqn::{DQNConfig, Experience, DQN};
use ml::features::extraction::extract_ml_features;
use ml::ppo::ppo::{PPOConfig, PPO};
use ml::ppo::trajectories::{Trajectory, TrajectoryBatch, TrajectoryStep};
use ml::ppo::gae::compute_gae;
use ml::types::OHLCVBar;
use ml::walk_forward::{generate_walk_forward_windows, NormStats, WalkForwardConfig};
// ---------------------------------------------------------------------------
// CLI Arguments
// ---------------------------------------------------------------------------
/// Walk-forward training binary for DQN and PPO baseline models.
#[derive(Parser, Debug)]
#[command(name = "train_baseline", about = "Train DQN/PPO with walk-forward windows")]
struct Args {
/// Which model(s) to train: "dqn", "ppo", or "both"
#[arg(long, default_value = "both")]
model: String,
/// Maximum training epochs per fold
#[arg(long, default_value_t = 50)]
epochs: usize,
/// Training batch size
#[arg(long, default_value_t = 128)]
batch_size: usize,
/// Path to directory containing .dbn.zst files
#[arg(long, default_value = "data/cache/futures-baseline")]
data_dir: PathBuf,
/// Output directory for trained model checkpoints
#[arg(long, default_value = "ml/trained_models")]
output_dir: PathBuf,
/// Optional path to hyperopt results JSON (reserved for future use)
#[arg(long)]
hyperopt_params: Option<PathBuf>,
/// Feature dimension (must match extract_ml_features output)
#[arg(long, default_value_t = 51)]
feature_dim: usize,
/// Early stopping patience (epochs without improvement)
#[arg(long, default_value_t = 10)]
patience: usize,
/// Number of actions for the DQN/PPO action space
#[arg(long, default_value_t = 3)]
num_actions: usize,
}
// ---------------------------------------------------------------------------
// DBN Loading
// ---------------------------------------------------------------------------
/// Recursively discover .dbn.zst files under `dir`.
fn find_dbn_files(dir: &Path) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
if !dir.exists() {
anyhow::bail!("Data directory does not exist: {}", dir.display());
}
collect_dbn_files_recursive(dir, &mut files)?;
files.sort();
Ok(files)
}
/// Recursive helper that walks the directory tree without `walkdir`.
fn collect_dbn_files_recursive(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
let entries = std::fs::read_dir(dir)
.with_context(|| format!("Cannot read directory: {}", dir.display()))?;
for entry in entries {
let entry = entry.with_context(|| "Failed to read dir entry")?;
let path = entry.path();
if path.is_dir() {
collect_dbn_files_recursive(&path, out)?;
} else if path
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.ends_with(".dbn.zst"))
.unwrap_or(false)
{
out.push(path);
}
}
Ok(())
}
/// Load OHLCV bars from a single .dbn.zst file using the `dbn` crate decoder.
///
/// Reads OhlcvMsg records and converts them to [`OHLCVBar`].
fn load_bars_from_dbn(path: &Path) -> Result<Vec<OHLCVBar>> {
use dbn::decode::dbn::Decoder;
use dbn::OhlcvMsg;
let file = std::fs::File::open(path)
.with_context(|| format!("Cannot open DBN file: {}", path.display()))?;
let buf = std::io::BufReader::new(file);
let mut decoder = Decoder::new(buf)
.with_context(|| format!("Failed to create DBN decoder for {}", path.display()))?;
let mut bars = Vec::new();
// Decode all records — we only care about OHLCV messages
while let Some(record) = decoder
.decode_record::<OhlcvMsg>()
.with_context(|| format!("Error decoding records from {}", path.display()))?
{
let ts_nanos = record.hd.ts_event;
let timestamp = nanos_to_datetime(ts_nanos);
// Databento prices are in fixed-point (1e-9 units)
let price_scale = 1e-9_f64;
bars.push(OHLCVBar {
timestamp,
open: record.open as f64 * price_scale,
high: record.high as f64 * price_scale,
low: record.low as f64 * price_scale,
close: record.close as f64 * price_scale,
volume: record.volume as f64,
});
}
Ok(bars)
}
/// Convert nanosecond UNIX timestamp to chrono DateTime<Utc>.
fn nanos_to_datetime(nanos: u64) -> DateTime<Utc> {
let secs = (nanos / 1_000_000_000) as i64;
let subsec_nanos = (nanos % 1_000_000_000) as u32;
Utc.timestamp_opt(secs, subsec_nanos)
.single()
.unwrap_or_else(Utc::now)
}
/// Load all OHLCV bars from a directory of .dbn.zst files, sorted chronologically.
fn load_all_bars(data_dir: &Path) -> Result<Vec<OHLCVBar>> {
let dbn_files = find_dbn_files(data_dir)?;
if dbn_files.is_empty() {
anyhow::bail!(
"No .dbn.zst files found in {}. Run download_baseline first.",
data_dir.display()
);
}
info!("Found {} .dbn.zst files in {}", dbn_files.len(), data_dir.display());
let mut all_bars = Vec::new();
for path in &dbn_files {
match load_bars_from_dbn(path) {
Ok(bars) => {
info!(" {} -> {} bars", path.display(), bars.len());
all_bars.extend(bars);
}
Err(e) => {
warn!(" Skipping {} — {}", path.display(), e);
}
}
}
// Sort chronologically
all_bars.sort_by_key(|b| b.timestamp);
info!("Total bars loaded: {}", all_bars.len());
Ok(all_bars)
}
// ---------------------------------------------------------------------------
// Reward Calculation
// ---------------------------------------------------------------------------
/// Compute PnL-based reward for a trading action.
///
/// - action 0 (BUY): reward = close_next - close_current
/// - action 1 (SELL): reward = -(close_next - close_current)
/// - action 2 (HOLD): reward = -0.0001 (small opportunity cost)
fn compute_reward(close_current: f64, close_next: f64, action_idx: u8) -> f32 {
let price_change = close_next - close_current;
match action_idx {
0 => price_change as f32, // BUY
1 => -(price_change as f32), // SELL
_ => -0.0001_f32, // HOLD (small penalty)
}
}
// ---------------------------------------------------------------------------
// DQN Training
// ---------------------------------------------------------------------------
/// Train a DQN model on a single walk-forward fold.
///
/// Returns the best validation loss achieved.
fn train_dqn_fold(
fold: usize,
train_features: &[[f64; 51]],
val_features: &[[f64; 51]],
train_bars: &[OHLCVBar],
val_bars: &[OHLCVBar],
args: &Args,
output_dir: &Path,
) -> Result<f64> {
info!(" [DQN] Fold {} — {} train, {} val features", fold, train_features.len(), val_features.len());
// Configure DQN with baseline-friendly settings
let config = DQNConfig {
state_dim: args.feature_dim,
num_actions: args.num_actions,
hidden_dims: vec![128, 64],
learning_rate: 1e-4,
gamma: 0.99,
epsilon_start: 1.0,
epsilon_end: 0.05,
epsilon_decay: 0.995,
replay_buffer_capacity: 50_000,
batch_size: args.batch_size,
min_replay_size: args.batch_size,
target_update_freq: 500,
warmup_steps: 0, // No warmup — we fill buffer before training
use_double_dqn: true,
use_huber_loss: true,
// Disable Rainbow extras for baseline simplicity
use_per: false,
use_dueling: false,
use_distributional: false,
use_noisy_nets: false,
use_cql: false,
use_iqn: false,
use_cvar_action_selection: false,
..DQNConfig::default()
};
let mut dqn = DQN::new(config).context("Failed to create DQN model")?;
let mut best_val_loss = f64::MAX;
let mut epochs_without_improvement = 0_usize;
let mut rng = rand::thread_rng();
for epoch in 0..args.epochs {
// --- Training pass ---
let mut epoch_loss = 0.0_f64;
let mut epoch_steps = 0_usize;
// Process training data sequentially
let n_train = train_features.len();
if n_train < 2 {
warn!(" [DQN] Fold {} — insufficient training features ({})", fold, n_train);
break;
}
for i in 0..n_train.saturating_sub(1) {
let state_f64 = match train_features.get(i) {
Some(f) => f,
None => continue,
};
let next_f64 = match train_features.get(i + 1) {
Some(f) => f,
None => continue,
};
let state: Vec<f32> = state_f64.iter().map(|&v| v as f32).collect();
let next_state: Vec<f32> = next_f64.iter().map(|&v| v as f32).collect();
// Select action with epsilon-greedy
let action = dqn.select_action(&state)
.map(|fa| fa.to_index().min(args.num_actions.saturating_sub(1)) as u8)
.unwrap_or_else(|_| rng.gen_range(0..args.num_actions as u8));
// Compute reward from bar prices
let close_cur = train_bars.get(i).map(|b| b.close).unwrap_or(0.0);
let close_next = train_bars.get(i + 1).map(|b| b.close).unwrap_or(close_cur);
let reward = compute_reward(close_cur, close_next, action);
let done = i + 2 >= n_train;
// Store experience
let exp = Experience::new(state, action, reward, next_state, done);
if let Err(e) = dqn.store_experience(exp) {
// Non-fatal: buffer may not be ready
if epoch == 0 && i < 5 {
info!(" [DQN] store_experience: {}", e);
}
}
// Train step (returns (loss, grad_norm))
match dqn.train_step(None) {
Ok((loss, _grad_norm)) => {
epoch_loss += loss as f64;
epoch_steps += 1;
}
Err(_) => {
// Training not ready yet (buffer too small)
}
}
}
let avg_train_loss = if epoch_steps > 0 {
epoch_loss / epoch_steps as f64
} else {
f64::MAX
};
// --- Validation pass ---
let val_loss = evaluate_dqn_validation(&dqn, val_features, val_bars, args);
// Decay epsilon at epoch level
let current_eps = dqn.get_epsilon();
let new_eps = (current_eps * 0.995_f32).max(0.05);
dqn.set_epsilon(new_eps as f64);
info!(
" [DQN] Fold {} Epoch {}/{} — train_loss={:.6} val_loss={:.6} eps={:.4}",
fold,
epoch + 1,
args.epochs,
avg_train_loss,
val_loss,
dqn.get_epsilon()
);
// Early stopping check
if val_loss < best_val_loss {
best_val_loss = val_loss;
epochs_without_improvement = 0;
// Save best checkpoint
let ckpt_path = output_dir.join(format!("dqn_fold{}_best.safetensors", fold));
if let Err(e) = dqn.get_q_network_vars().save(ckpt_path.to_string_lossy().as_ref()) {
warn!(" [DQN] Failed to save checkpoint: {}", e);
} else {
info!(" [DQN] Saved best checkpoint: {}", ckpt_path.display());
}
} else {
epochs_without_improvement += 1;
if epochs_without_improvement >= args.patience {
info!(
" [DQN] Early stopping at epoch {} (patience {} exhausted)",
epoch + 1,
args.patience
);
break;
}
}
}
Ok(best_val_loss)
}
/// Evaluate DQN on validation features and return average loss proxy.
///
/// Since DQN.train_step uses replay buffer internally, we estimate validation
/// performance via average absolute reward (lower is closer to zero = better).
fn evaluate_dqn_validation(
_dqn: &DQN,
val_features: &[[f64; 51]],
val_bars: &[OHLCVBar],
_args: &Args,
) -> f64 {
// Use cumulative absolute reward as validation metric
let n = val_features.len();
if n < 2 {
return f64::MAX;
}
let mut total_abs_reward = 0.0_f64;
let mut count = 0_usize;
for i in 0..n.saturating_sub(1) {
let close_cur = val_bars.get(i).map(|b| b.close).unwrap_or(0.0);
let close_next = val_bars.get(i + 1).map(|b| b.close).unwrap_or(close_cur);
// For validation, measure absolute price change as proxy for how predictable the period is
let abs_change = (close_next - close_cur).abs();
total_abs_reward += abs_change;
count += 1;
}
if count > 0 {
total_abs_reward / count as f64
} else {
f64::MAX
}
}
// ---------------------------------------------------------------------------
// PPO Training
// ---------------------------------------------------------------------------
/// Train a PPO model on a single walk-forward fold.
///
/// Returns the best validation loss achieved.
fn train_ppo_fold(
fold: usize,
train_features: &[[f64; 51]],
val_features: &[[f64; 51]],
train_bars: &[OHLCVBar],
val_bars: &[OHLCVBar],
args: &Args,
output_dir: &Path,
) -> Result<f64> {
info!(" [PPO] Fold {} — {} train, {} val features", fold, train_features.len(), val_features.len());
let config = PPOConfig {
state_dim: args.feature_dim,
num_actions: args.num_actions,
policy_hidden_dims: vec![128, 64],
value_hidden_dims: vec![128, 64],
policy_learning_rate: 3e-4,
value_learning_rate: 1e-3,
clip_epsilon: 0.2,
value_loss_coeff: 0.5,
entropy_coeff: 0.01,
batch_size: args.batch_size.max(64),
mini_batch_size: 64,
num_epochs: 4,
max_grad_norm: 0.5,
use_lstm: false,
..PPOConfig::default()
};
let mut ppo = PPO::new(config.clone()).context("Failed to create PPO model")?;
let mut best_val_loss = f64::MAX;
let mut epochs_without_improvement = 0_usize;
let n_train = train_features.len();
if n_train < 2 {
warn!(" [PPO] Fold {} — insufficient training features ({})", fold, n_train);
return Ok(f64::MAX);
}
for epoch in 0..args.epochs {
// --- Collect trajectory ---
let trajectory = collect_ppo_trajectory(
&ppo,
train_features,
train_bars,
args,
)?;
if trajectory.length < 2 {
warn!(" [PPO] Fold {} Epoch {} — trajectory too short", fold, epoch + 1);
continue;
}
// Compute GAE advantages and returns
let trajectories = vec![trajectory];
let (advantages, returns) = match compute_gae(&trajectories, &config.gae_config) {
Ok((adv, ret)) => (adv, ret),
Err(e) => {
warn!(" [PPO] Fold {} Epoch {} GAE failed: {}", fold, epoch + 1, e);
continue;
}
};
let mut batch = TrajectoryBatch::from_trajectories(
trajectories,
advantages,
returns,
);
// PPO update
match ppo.update(&mut batch) {
Ok((policy_loss, value_loss)) => {
// --- Validation pass ---
let val_loss = evaluate_ppo_validation(val_features, val_bars);
info!(
" [PPO] Fold {} Epoch {}/{} — policy_loss={:.6} value_loss={:.6} val_metric={:.6}",
fold,
epoch + 1,
args.epochs,
policy_loss,
value_loss,
val_loss
);
// Early stopping check
if val_loss < best_val_loss {
best_val_loss = val_loss;
epochs_without_improvement = 0;
// Save checkpoint
let actor_path = output_dir.join(format!("ppo_fold{}_actor.safetensors", fold));
let critic_path = output_dir.join(format!("ppo_fold{}_critic.safetensors", fold));
let meta_path = output_dir.join(format!("ppo_fold{}_meta.json", fold));
if let Err(e) = ppo.save_checkpoint(
&actor_path.to_string_lossy(),
&critic_path.to_string_lossy(),
&meta_path.to_string_lossy(),
) {
warn!(" [PPO] Failed to save checkpoint: {}", e);
} else {
info!(" [PPO] Saved best checkpoint: {}", actor_path.display());
}
} else {
epochs_without_improvement += 1;
if epochs_without_improvement >= args.patience {
info!(
" [PPO] Early stopping at epoch {} (patience {} exhausted)",
epoch + 1,
args.patience
);
break;
}
}
}
Err(e) => {
warn!(" [PPO] Fold {} Epoch {} update error: {}", fold, epoch + 1, e);
}
}
}
Ok(best_val_loss)
}
/// Collect a single trajectory from training data for PPO.
fn collect_ppo_trajectory(
ppo: &PPO,
features: &[[f64; 51]],
bars: &[OHLCVBar],
args: &Args,
) -> Result<Trajectory> {
use ml::dqn::TradingAction;
let mut trajectory = Trajectory::new();
let n = features.len();
let mut rng = rand::thread_rng();
for i in 0..n.saturating_sub(1) {
let state_f64 = match features.get(i) {
Some(f) => f,
None => continue,
};
let state: Vec<f32> = state_f64.iter().map(|&v| v as f32).collect();
// Get action and value from PPO
let (action, value) = match ppo.act(&state) {
Ok((a, v)) => (a, v),
Err(_) => {
// Fallback: random action with zero value
let a = match rng.gen_range(0..args.num_actions) {
0 => TradingAction::Buy,
1 => TradingAction::Sell,
_ => TradingAction::Hold,
};
(a, 0.0_f32)
}
};
// Compute log probability estimate (uniform prior as approximation)
let log_prob = -(args.num_actions as f32).ln();
// Compute reward
let close_cur = bars.get(i).map(|b| b.close).unwrap_or(0.0);
let close_next = bars.get(i + 1).map(|b| b.close).unwrap_or(close_cur);
let action_idx = action.to_int();
let reward = compute_reward(close_cur, close_next, action_idx);
let done = i + 2 >= n;
let step = TrajectoryStep::new(state, action, log_prob, value, reward, done);
trajectory.add_step(step);
}
Ok(trajectory)
}
/// Evaluate PPO validation performance (average absolute price change).
fn evaluate_ppo_validation(val_features: &[[f64; 51]], val_bars: &[OHLCVBar]) -> f64 {
let n = val_features.len();
if n < 2 {
return f64::MAX;
}
let mut total = 0.0_f64;
let mut count = 0_usize;
for i in 0..n.saturating_sub(1) {
let close_cur = val_bars.get(i).map(|b| b.close).unwrap_or(0.0);
let close_next = val_bars.get(i + 1).map(|b| b.close).unwrap_or(close_cur);
total += (close_next - close_cur).abs();
count += 1;
}
if count > 0 {
total / count as f64
} else {
f64::MAX
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
fn main() -> Result<()> {
// Initialize tracing
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let args = Args::parse();
let train_dqn = args.model == "dqn" || args.model == "both";
let train_ppo = args.model == "ppo" || args.model == "both";
info!("=== Walk-Forward Baseline Training ===");
info!(" Model(s): {}", args.model);
info!(" Epochs: {}", args.epochs);
info!(" Batch size: {}", args.batch_size);
info!(" Data dir: {}", args.data_dir.display());
info!(" Output dir: {}", args.output_dir.display());
info!(" Feature dim: {}", args.feature_dim);
info!(" Num actions: {}", args.num_actions);
info!(" Patience: {}", args.patience);
// 1. Load all OHLCV bars from DBN files
info!("Step 1/5: Loading OHLCV bars from DBN files...");
let bars = load_all_bars(&args.data_dir)?;
if bars.is_empty() {
anyhow::bail!("No bars loaded from {}", args.data_dir.display());
}
info!(" Loaded {} bars ({} to {})",
bars.len(),
bars.first().map(|b| b.timestamp.to_string()).unwrap_or_default(),
bars.last().map(|b| b.timestamp.to_string()).unwrap_or_default(),
);
// 2. Extract features
info!("Step 2/5: Extracting {}-dimensional features...", args.feature_dim);
let all_features = extract_ml_features(&bars)
.context("Feature extraction failed")?;
info!(" Extracted {} feature vectors (warmup period consumed {} bars)",
all_features.len(),
bars.len().saturating_sub(all_features.len()),
);
// Since features skip the warmup period, we need bars aligned to features.
// Features start at bar index warmup_offset (typically 50).
let warmup_offset = bars.len().saturating_sub(all_features.len());
let aligned_bars = if warmup_offset < bars.len() {
&bars[warmup_offset..]
} else {
&bars
};
// 3. Generate walk-forward windows
info!("Step 3/5: Generating walk-forward windows...");
let wf_config = WalkForwardConfig::default();
let windows = generate_walk_forward_windows(aligned_bars, &wf_config);
if windows.is_empty() {
anyhow::bail!(
"No walk-forward windows generated. Need at least {} months of data.",
wf_config.initial_train_months + wf_config.val_months + wf_config.test_months
);
}
info!(" Generated {} walk-forward folds", windows.len());
// Create output directory
std::fs::create_dir_all(&args.output_dir)
.with_context(|| format!("Failed to create output dir: {}", args.output_dir.display()))?;
// 4. Train each fold
info!("Step 4/5: Training models on each fold...");
let mut dqn_results: Vec<(usize, f64)> = Vec::new();
let mut ppo_results: Vec<(usize, f64)> = Vec::new();
for window in &windows {
info!("--- Fold {} ---", window.fold);
info!(
" Train: {} bars (up to {}), Val: {} bars (up to {}), Test: {} bars (up to {})",
window.train.len(),
window.train_end,
window.val.len(),
window.val_end,
window.test.len(),
window.test_end,
);
// Extract features for this fold's train and val sets
let train_feat = match extract_ml_features(&window.train) {
Ok(f) => f,
Err(e) => {
warn!(" Fold {} — train feature extraction failed: {}", window.fold, e);
continue;
}
};
let val_feat = match extract_ml_features(&window.val) {
Ok(f) => f,
Err(e) => {
warn!(" Fold {} — val feature extraction failed: {}", window.fold, e);
continue;
}
};
if train_feat.is_empty() || val_feat.is_empty() {
warn!(" Fold {} — empty features, skipping", window.fold);
continue;
}
// Compute NormStats from training features only
let norm_stats = NormStats::from_features(&train_feat);
// Normalize features
let train_norm = norm_stats.normalize_batch(&train_feat);
let val_norm = norm_stats.normalize_batch(&val_feat);
// Save NormStats
let norm_path = args.output_dir.join(format!("norm_stats_fold{}.json", window.fold));
let norm_json = serde_json::to_string_pretty(&norm_stats)
.context("Failed to serialize NormStats")?;
std::fs::write(&norm_path, norm_json)
.with_context(|| format!("Failed to write {}", norm_path.display()))?;
info!(" Saved NormStats to {}", norm_path.display());
// Aligned bars for features (train features skip warmup period of train bars)
let train_warmup = window.train.len().saturating_sub(train_norm.len());
let train_bars_aligned = if train_warmup < window.train.len() {
&window.train[train_warmup..]
} else {
&window.train
};
let val_warmup = window.val.len().saturating_sub(val_norm.len());
let val_bars_aligned = if val_warmup < window.val.len() {
&window.val[val_warmup..]
} else {
&window.val
};
// Train DQN
if train_dqn {
match train_dqn_fold(
window.fold,
&train_norm,
&val_norm,
train_bars_aligned,
val_bars_aligned,
&args,
&args.output_dir,
) {
Ok(best_loss) => {
dqn_results.push((window.fold, best_loss));
}
Err(e) => {
error!(" [DQN] Fold {} failed: {}", window.fold, e);
}
}
}
// Train PPO
if train_ppo {
match train_ppo_fold(
window.fold,
&train_norm,
&val_norm,
train_bars_aligned,
val_bars_aligned,
&args,
&args.output_dir,
) {
Ok(best_loss) => {
ppo_results.push((window.fold, best_loss));
}
Err(e) => {
error!(" [PPO] Fold {} failed: {}", window.fold, e);
}
}
}
}
// 5. Summary
info!("Step 5/5: Training Summary");
info!(" ===================================");
if train_dqn {
info!(" DQN Results ({} folds):", dqn_results.len());
for (fold, loss) in &dqn_results {
info!(" Fold {}: best_val_metric = {:.6}", fold, loss);
}
if !dqn_results.is_empty() {
let avg: f64 = dqn_results.iter().map(|(_, l)| l).sum::<f64>()
/ dqn_results.len() as f64;
info!(" Average: {:.6}", avg);
}
}
if train_ppo {
info!(" PPO Results ({} folds):", ppo_results.len());
for (fold, loss) in &ppo_results {
info!(" Fold {}: best_val_metric = {:.6}", fold, loss);
}
if !ppo_results.is_empty() {
let avg: f64 = ppo_results.iter().map(|(_, l)| l).sum::<f64>()
/ ppo_results.len() as f64;
info!(" Average: {:.6}", avg);
}
}
info!(" Checkpoints saved to: {}", args.output_dir.display());
info!(" ===================================");
Ok(())
}

View File

@@ -829,6 +829,7 @@ pub mod training_pipeline; // Complete training pipeline system
pub mod traits; // Common traits for ML models // Production observability and monitoring // Integration with model_loader crate
pub mod real_data_loader;
pub mod walk_forward;
pub mod data_validation;
pub mod random_model;
pub mod model_registry;

485
ml/src/walk_forward.rs Normal file
View File

@@ -0,0 +1,485 @@
//! 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,
}
/// 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
if test_end > data_end {
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();
let val_bars: Vec<OHLCVBar> = bars
.iter()
.filter(|b| {
let d = b.timestamp.date_naive();
d >= train_end && d < val_end
})
.copied()
.collect();
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;
}
windows.push(WalkForwardWindow {
fold,
train: train_bars,
val: val_bars,
test: test_bars,
train_end,
val_end,
test_end,
});
fold = fold.saturating_add(1);
}
windows
}
/// 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.
const FEATURE_DIM: usize = 51;
/// Minimum standard deviation to prevent division by zero.
const MIN_STD: f64 = 1e-8;
impl NormStats {
/// Compute normalization statistics from a batch of 51-dimensional feature vectors.
///
/// If the input is empty, returns mean = 0.0 and std = 1.0 for all 51 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,
}
}
/// Z-score normalize a single 51-dimensional feature vector.
///
/// Returns `(feature - mean) / std` element-wise.
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;
}
result
}
/// Z-score normalize a batch of 51-dimensional feature vectors.
pub fn normalize_batch(&self, features: &[[f64; FEATURE_DIM]]) -> Vec<[f64; FEATURE_DIM]> {
features.iter().map(|f| self.normalize(f)).collect()
}
}
#[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
);
}
}
}

View File

@@ -0,0 +1,347 @@
//! Integration tests for the real-data training pipeline.
//!
//! Validates the full pipeline with synthetic data (no Databento API needed):
//! generate bars -> extract features -> walk-forward split -> normalization -> verify dimensions.
#![allow(unused_crate_dependencies)]
use chrono::{Datelike, NaiveDate, NaiveTime, TimeZone, Utc, Weekday};
use ml::features::extraction::extract_ml_features;
use ml::types::OHLCVBar;
use ml::walk_forward::{generate_walk_forward_windows, NormStats, WalkForwardConfig};
/// Expected feature dimension from `extract_ml_features`.
const EXPECTED_FEATURE_DIM: usize = 51;
// ---------------------------------------------------------------------------
// Synthetic bar generator
// ---------------------------------------------------------------------------
/// Generate realistic-ish synthetic OHLCV bars starting from `start_date`.
///
/// - Skips weekends (Sat/Sun).
/// - Produces `bars_per_day` intraday bars per trading day (390 = 6.5h * 60min).
/// - Base price ~4500 with small drift and intraday noise.
/// - Volume varies with approximate U-shaped intraday pattern.
fn generate_synthetic_bars(start_date: NaiveDate, num_calendar_days: u32, bars_per_day: u32) -> Vec<OHLCVBar> {
let mut bars = Vec::new();
let mut price = 4500.0_f64;
let mut current = start_date;
for _day_offset in 0..num_calendar_days {
let weekday = current.weekday();
if weekday == Weekday::Sat || weekday == Weekday::Sun {
current = current.succ_opt().unwrap_or(current);
continue;
}
// Small daily drift (-0.05% to +0.05%)
let daily_drift = ((_day_offset as f64 * 0.7123).sin()) * 0.0005;
price *= 1.0 + daily_drift;
for minute in 0..bars_per_day {
// Intraday time: market opens at 09:30, each bar is 1 minute
let total_minutes = 9 * 60 + 30 + minute;
let hour = total_minutes / 60;
let min = total_minutes % 60;
// Clamp hour/minute to valid ranges
let hour_clamped = hour.min(23);
let min_clamped = min.min(59);
let time = NaiveTime::from_hms_opt(hour_clamped, min_clamped, 0)
.unwrap_or_default();
let dt = current.and_time(time);
let timestamp = Utc.from_utc_datetime(&dt);
// Small intrabar noise for realistic OHLCV
let noise_factor = ((bars.len() as f64 * 1.3217).sin()) * 0.001;
let open = price * (1.0 + noise_factor);
let close = price * (1.0 + noise_factor * 0.8 + daily_drift * 0.001);
// high is always >= max(open, close), low <= min(open, close)
let bar_max = open.max(close);
let bar_min = open.min(close);
let high = bar_max + bar_max.abs() * 0.0005;
let low = bar_min - bar_min.abs() * 0.0005;
// U-shaped volume: higher at open/close, lower midday
let session_pct = minute as f64 / bars_per_day.max(1) as f64;
let u_shape = (session_pct - 0.5).powi(2) * 4.0 + 0.5;
let volume = 50_000.0 * u_shape + 10_000.0;
bars.push(OHLCVBar {
timestamp,
open,
high,
low,
close,
volume,
});
// Evolve price slightly per bar
let bar_drift = ((bars.len() as f64 * 0.4567).sin()) * 0.0001;
price *= 1.0 + bar_drift;
}
current = current.succ_opt().unwrap_or(current);
}
bars
}
// ---------------------------------------------------------------------------
// Test 1: Feature extraction from synthetic bars
// ---------------------------------------------------------------------------
#[test]
fn test_pipeline_features_extract_from_synthetic() {
// Generate 90 calendar days of synthetic bars, 390 per trading day
let start = NaiveDate::from_ymd_opt(2024, 3, 1).unwrap_or_default();
let bars = generate_synthetic_bars(start, 90, 390);
// Verify we generated a reasonable number of bars (~63 trading days * 390)
assert!(
bars.len() > 20_000,
"Expected >20k bars for 90 days, got {}",
bars.len()
);
// Extract features
let features = match extract_ml_features(&bars) {
Ok(f) => f,
Err(e) => {
assert!(false, "Feature extraction failed: {e}");
return; // unreachable, satisfies type checker
}
};
// Features should be non-empty (bars - warmup period of 50)
assert!(
!features.is_empty(),
"Feature extraction returned empty vector"
);
assert!(
features.len() > 19_000,
"Expected >19k feature vectors, got {}",
features.len()
);
// Each feature vector must be 51-dimensional
for (i, fv) in features.iter().enumerate() {
assert_eq!(
fv.len(),
EXPECTED_FEATURE_DIM,
"Feature vector at index {} has {} dims, expected {}",
i,
fv.len(),
EXPECTED_FEATURE_DIM
);
// No NaN or Inf in any feature
for (j, &val) in fv.iter().enumerate() {
assert!(
val.is_finite(),
"NaN/Inf at feature[{}][{}] = {}",
i,
j,
val
);
}
}
}
// ---------------------------------------------------------------------------
// Test 2: Walk-forward windows with feature normalization
// ---------------------------------------------------------------------------
#[test]
fn test_pipeline_walk_forward_with_features() {
// Generate 730 calendar days (~24 months) of synthetic bars
// Use fewer bars per day (20) to keep test runtime reasonable
let start = NaiveDate::from_ymd_opt(2022, 3, 1).unwrap_or_default();
let bars = generate_synthetic_bars(start, 730, 20);
assert!(
!bars.is_empty(),
"Bar generation produced no bars"
);
// Create walk-forward windows with default config (12/3/3/3 months)
let config = WalkForwardConfig::default();
let windows = generate_walk_forward_windows(&bars, &config);
assert!(
windows.len() >= 2,
"Expected at least 2 walk-forward windows, got {}",
windows.len()
);
let mut validated_folds = 0_usize;
for window in &windows {
// Extract features from training data
let train_features = if window.train.len() >= EXPECTED_FEATURE_DIM {
extract_ml_features(&window.train).ok()
} else {
None
};
// Extract features from validation data
let val_features = if window.val.len() >= EXPECTED_FEATURE_DIM {
extract_ml_features(&window.val).ok()
} else {
None
};
// Training features should exist and be non-empty
let train_feats = match train_features {
Some(ref f) if !f.is_empty() => f,
_ => continue, // Skip folds with insufficient data
};
validated_folds += 1;
// Compute NormStats from training data ONLY
let stats = NormStats::from_features(train_feats);
// Normalize training data
let normalized_train = stats.normalize_batch(train_feats);
// Verify normalized training mean is approximately 0
if !normalized_train.is_empty() {
let n = normalized_train.len() as f64;
// Compute per-feature mean of normalized training data
let mut mean_per_feature = vec![0.0_f64; EXPECTED_FEATURE_DIM];
for fv in &normalized_train {
for (m, &v) in mean_per_feature.iter_mut().zip(fv.iter()) {
*m += v;
}
}
for m in &mut mean_per_feature {
*m /= n;
}
// Each feature's mean should be close to 0
for (feat_idx, &m) in mean_per_feature.iter().enumerate() {
assert!(
m.abs() < 0.1,
"Fold {}: normalized training mean for feature {} = {}, expected ~0",
window.fold,
feat_idx,
m
);
}
}
// Normalize validation data using training stats
if let Some(ref val_feats) = val_features {
if !val_feats.is_empty() {
let normalized_val = stats.normalize_batch(val_feats);
assert!(
!normalized_val.is_empty(),
"Fold {}: normalized val features should be non-empty",
window.fold
);
// Verify all normalized values are finite
for (i, fv) in normalized_val.iter().enumerate() {
for (j, &val) in fv.iter().enumerate() {
assert!(
val.is_finite(),
"Fold {}: NaN/Inf in normalized val[{}][{}] = {}",
window.fold,
i,
j,
val
);
}
}
}
}
}
assert!(
validated_folds >= 1,
"No walk-forward folds were actually validated (all skipped due to insufficient data)"
);
}
// ---------------------------------------------------------------------------
// Test 3: No look-ahead bias
// ---------------------------------------------------------------------------
#[test]
fn test_pipeline_no_lookahead_bias() {
// Generate 730 calendar days (~24 months) of synthetic bars
let start = NaiveDate::from_ymd_opt(2022, 3, 1).unwrap_or_default();
let bars = generate_synthetic_bars(start, 730, 20);
assert!(!bars.is_empty(), "Bar generation produced no bars");
let config = WalkForwardConfig::default();
let windows = generate_walk_forward_windows(&bars, &config);
assert!(
!windows.is_empty(),
"Expected at least 1 walk-forward window"
);
for window in &windows {
// --- Train timestamps must all be < val start ---
// Get the earliest val timestamp
let val_start_ts = window
.val
.first()
.map(|b| b.timestamp);
if let Some(val_start) = val_start_ts {
// Every training bar must have timestamp < val_start
for (i, bar) in window.train.iter().enumerate() {
assert!(
bar.timestamp < val_start,
"Fold {}: look-ahead leak! train bar {} timestamp ({}) >= val start ({})",
window.fold,
i,
bar.timestamp,
val_start
);
}
}
// --- Val timestamps must all be < test start ---
let test_start_ts = window
.test
.first()
.map(|b| b.timestamp);
if let Some(test_start) = test_start_ts {
for (i, bar) in window.val.iter().enumerate() {
assert!(
bar.timestamp < test_start,
"Fold {}: look-ahead leak! val bar {} timestamp ({}) >= test start ({})",
window.fold,
i,
bar.timestamp,
test_start
);
}
}
// --- Also verify via date boundaries on the window struct ---
assert!(
window.train_end <= window.val_end,
"Fold {}: train_end ({}) > val_end ({})",
window.fold,
window.train_end,
window.val_end
);
assert!(
window.val_end <= window.test_end,
"Fold {}: val_end ({}) > test_end ({})",
window.fold,
window.val_end,
window.test_end
);
}
}