Merge branch 'feat/real-data-training-pipeline'
This commit is contained in:
@@ -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
|
||||
|
||||
473
ml/examples/download_baseline.rs
Normal file
473
ml/examples/download_baseline.rs
Normal 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(¶ms)
|
||||
.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);
|
||||
}
|
||||
}
|
||||
894
ml/examples/evaluate_baseline.rs
Normal file
894
ml/examples/evaluate_baseline.rs
Normal 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(())
|
||||
}
|
||||
296
ml/examples/hyperopt_baseline.rs
Normal file
296
ml/examples/hyperopt_baseline.rs
Normal 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(())
|
||||
}
|
||||
834
ml/examples/train_baseline.rs
Normal file
834
ml/examples/train_baseline.rs
Normal 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(())
|
||||
}
|
||||
@@ -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
485
ml/src/walk_forward.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
347
ml/tests/real_data_pipeline_test.rs
Normal file
347
ml/tests/real_data_pipeline_test.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user