Files
foxhunt/crates/ml/examples/train_baseline_rl.rs
jgrusewski db874b1841 feat(foxhuntq): Phase 1c snapshot-resolution alpha + leakage fix + variable-dim fxcache
Three things landing atomically because they're load-bearing for each other:

1. **Trend-scanning leakage fix** — trend_scanning.rs was emitting OLS slope+t-stat
   over a *forward* window [t, t+L]. With the Phase 1a label = sign(price[t+60]
   − price[t]), the forward feature window overlaps the label window, contaminating
   it. Purged walk-forward only sterilizes forward-looking *labels* that cross
   the train/val split, not forward-looking *features* that peek inside the same
   horizon the label measures. The leak inflated MLP accuracy from 0.49
   (legacy 74-dim baseline) to 0.75 — vanished to 0.50 after switching to a
   trailing window. Bounded the perfect-fit t-stat sentinel from ±1e6 → ±20
   (p<1e-30 is already meaningless); eliminated the 16k corruption-cap drops.

2. **Variable-dim alpha column** — fxcache schema now carries the alpha-feature
   width via metadata (`alpha_feature_dim`), not a compile-time constant. Same
   on-disk format hosts the 134-dim bar-level stack OR the 81-dim snapshot stack.
   Reader + auto-detect honor the metadata-declared dim; downstream MLP auto-sizes
   `in_dim`. Single schema, no forks.

3. **Snapshot pipeline (Phase 1c falsification)** — `snapshot_pipeline.rs`: 81-dim
   per-MBP10-snapshot extractor reusing 10 snapshot-native alpha blocks + 6 new
   snapshot-specific features (time-since-trade, time-since-snap, event-rate,
   spread-bps, L1-imbalance, microprice-mid drift). `precompute_features` gets
   `--row-unit snapshot` flag; emits one fxcache row per LOB update (1.97M rows
   from MBP-10 data vs 206K for bar mode).

**Smoke verdict on real data** (ES.FUT, 1.97M snapshots, 384K val):
- Bar-level honest alpha: accuracy=0.5005, AUC=0.5043 (no signal)
- **Snapshot-level alpha**: accuracy=0.5241, AUC=0.6849 (real signal, 384K val)
- GBM corroboration: accuracy=0.5401 (non-linear partitioning sees more)
- Horizon decay: alpha peaks at K=20-50 snapshots (~5-25ms), gone by K=500
- Regime-conditional: spread-Q4 quintile hits 0.752 accuracy on 76k samples

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 01:01:15 +02:00

1354 lines
59 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![allow(
clippy::assertions_on_constants,
clippy::assertions_on_result_states,
clippy::clone_on_copy,
clippy::decimal_literal_representation,
clippy::doc_markdown,
clippy::empty_line_after_doc_comments,
clippy::field_reassign_with_default,
clippy::get_unwrap,
clippy::identity_op,
clippy::inconsistent_digit_grouping,
clippy::indexing_slicing,
clippy::integer_division,
clippy::len_zero,
clippy::let_underscore_must_use,
clippy::manual_div_ceil,
clippy::manual_let_else,
clippy::manual_range_contains,
clippy::modulo_arithmetic,
clippy::needless_range_loop,
clippy::non_ascii_literal,
clippy::redundant_clone,
clippy::shadow_reuse,
clippy::shadow_same,
clippy::shadow_unrelated,
clippy::single_match_else,
clippy::str_to_string,
clippy::string_slice,
clippy::tests_outside_test_module,
clippy::too_many_lines,
clippy::unnecessary_wraps,
clippy::unseparated_literal_suffix,
clippy::use_debug,
clippy::useless_vec,
clippy::wildcard_enum_match_arm,
clippy::else_if_without_else,
clippy::expect_used,
clippy::missing_const_for_fn,
clippy::similar_names,
clippy::type_complexity,
clippy::collapsible_else_if,
clippy::doc_lazy_continuation,
clippy::items_after_test_module,
clippy::map_clone,
clippy::multiple_unsafe_ops_per_block,
clippy::unwrap_or_default,
clippy::assign_op_pattern,
clippy::needless_borrow,
clippy::println_empty_string,
clippy::unnecessary_cast,
clippy::used_underscore_binding,
clippy::create_dir,
clippy::implicit_saturating_sub,
clippy::exit,
clippy::expect_fun_call,
clippy::too_many_arguments,
clippy::unnecessary_map_or,
clippy::unwrap_used,
dead_code,
unused_imports,
unused_variables,
clippy::cloned_ref_to_slice_refs,
clippy::neg_multiply,
clippy::while_let_loop,
clippy::bool_assert_comparison,
clippy::excessive_precision,
clippy::trivially_copy_pass_by_ref,
clippy::op_ref,
clippy::redundant_closure,
clippy::unnecessary_lazy_evaluations,
clippy::if_then_some_else_none,
clippy::unnecessary_to_owned,
clippy::single_component_path_imports,
)]
//! Walk-forward RL 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 \
//! --data-dir test_data/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 clap::Parser;
use serde_json::Value;
use tracing::{error, info, warn};
use ml::trainers::dqn::{DQNHyperparameters, DQNTrainer};
use ml::trainers::ppo::{PpoHyperparameters, PpoTrainer, PpoTrainingMetrics};
#[allow(unreachable_pub)]
mod baseline_common;
use baseline_common::completion::{write_failure_marker, write_success_marker, CompletionMetrics};
use baseline_common::{load_all_bars, spread_cost_bps};
use common::metrics::{server as metrics_server, training_metrics as metrics};
use ml::features::extraction::{extract_ml_features, FeatureVector};
use ml_core::gpu::profile::GpuProfile;
use ml::types::OHLCVBar;
use ml::walk_forward::{
generate_walk_forward_indices_from_timestamps,
WalkForwardConfig,
};
// ---------------------------------------------------------------------------
// CLI Arguments
// ---------------------------------------------------------------------------
/// Walk-forward training binary for DQN and PPO baseline models.
#[derive(Parser, Debug)]
#[command(name = "train_baseline_rl", about = "Train DQN/PPO with walk-forward RL 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,
// batch_size always auto-scaled from VRAM (no CLI override).
/// Path to directory containing .dbn.zst files (env: FOXHUNT_DATA_DIR)
#[arg(long, env = "FOXHUNT_DATA_DIR")]
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 -- overrides matching config fields
#[arg(long)]
hyperopt_params: Option<PathBuf>,
/// Feature dimension (42 market + 3 portfolio = 45, or 53 with OFI; must match trainer `state_dim`)
#[arg(long, default_value_t = 43)]
feature_dim: usize,
/// Early stopping patience (epochs without improvement)
#[arg(long, default_value_t = 10)]
patience: usize,
/// Number of actions (5 exposure levels for DQN, pass --num-actions 45 for PPO)
#[arg(long, default_value_t = 5)]
num_actions: usize,
/// Walk-forward: initial training window in months
#[arg(long, default_value_t = 12)]
train_months: u32,
/// Walk-forward: validation window in months
#[arg(long, default_value_t = 3)]
val_months: u32,
/// Walk-forward: test window in months
#[arg(long, default_value_t = 3)]
test_months: u32,
/// Walk-forward: step size in months between folds
#[arg(long, default_value_t = 3)]
step_months: u32,
/// Cap the number of walk-forward folds trained. 0 = no cap (use all
/// generated folds). Used by the multi_fold_convergence smoke test to run
/// a 3-fold subset locally instead of the full 6-fold L40S gate.
#[arg(long, default_value_t = 0)]
max_folds: usize,
/// Optional cap on total bars loaded from fxcache. When set, truncates the
/// fxcache data to the first N bars after load but before GPU upload.
/// Used by smoke tests to keep VRAM usage predictable (full ES.FUT is 697k
/// bars ≈ 290 MB on GPU with OFI; a ~500k cap reserves ~60 MB headroom
/// on 4 GB GPUs). 0 = unlimited (default; matches pre-change behaviour).
/// OFI + features + targets + timestamps truncate in lockstep per the
/// data_loading.rs precedent (commit 2ac956298 OFI/bars desync fix).
#[arg(long, default_value_t = 0)]
max_bars: usize,
/// Learning rate for optimizer
#[arg(long, default_value_t = 1e-4)]
learning_rate: f64,
/// Symbol subdirectory to load (e.g. "ES.FUT", "NQ.FUT")
#[arg(long, default_value = "ES.FUT")]
symbol: String,
/// Maximum absolute per-bar return; larger moves are clamped (contract roll filter)
#[arg(long, default_value_t = 0.01)]
max_bar_return: f64,
/// Round-trip commission cost in basis points (1 bps = 0.01%)
/// Applied to BUY/SELL rewards; HOLD is free.
/// Default 1.0 bps covers ~$4 exchange+broker for ES e-mini.
#[arg(long, default_value_t = 1.0)]
tx_cost_bps: f64,
/// Instrument tick size in price units (ES=0.25, NQ=0.25, ZN=1/64)
#[arg(long, default_value_t = 0.25)]
tick_size: f64,
/// Typical bid-ask spread in ticks (ES=1.0, ZN=1.0, 6E=2.0)
/// Half-spread slippage is added to `tx_cost_bps` per trade.
#[arg(long, default_value_t = 1.0)]
spread_ticks: f64,
/// Number of top hyperopt configs to train as ensemble (default 1 = best only).
/// When > 1, loads `top_k_params` from the hyperopt JSON and trains a separate
/// model for each param set, saving as `dqn_ensemble_{k}_fold_{fold}.safetensors`.
#[arg(long, default_value_t = 1)]
ensemble_top_k: usize,
/// Optional path to MBP-10 order book data directory for OFI features.
/// When set, enables 8 OFI features (OFI L1/L5, depth imbalance, VPIN, etc.)
/// expanding state dimension from 43 to 51.
#[arg(long)]
mbp10_data_dir: Option<PathBuf>,
/// Optional path to trade data directory (.dbn.zst files with Schema::Trades).
/// When set, real trade buy/sell classification feeds VPIN and Kyle's Lambda
/// instead of the tick-rule proxy. Requires --mbp10-data-dir to take effect.
#[arg(long)]
trades_data_dir: Option<PathBuf>,
/// Imbalance bar formation threshold (signed buy/sell volume that triggers
/// a bar close). Must match the value used by `precompute_features` to
/// populate the fxcache, otherwise this trainer's discover_and_load call
/// MISSES and falls back to in-process MBP-10 loading. Default matches
/// `dqn-production.toml: imbalance_bar_threshold = 0.5`.
#[arg(long, default_value_t = 0.5)]
imbalance_bar_threshold: f64,
/// Imbalance bar EWMA alpha. Convention in this codebase: weight on the
/// OLD threshold in `T_new = α·T_old + (1-α)·observed`. So α=1.0 disables
/// adaptation. Default matches `dqn-production.toml: 0.1`. Must match the
/// value used by `precompute_features` for fxcache hit.
#[arg(long, default_value_t = 0.1)]
imbalance_bar_ewma_alpha: f64,
/// Volume bar size: contracts of one-sided volume per bar. Used when
/// `data_source != "mbp10"`. Default 100. MUST match precompute's value
/// for cache HIT.
#[arg(long, default_value_t = 100)]
volume_bar_size: u64,
/// Enable offline RL mode: train exclusively from a pre-collected dataset,
/// skipping online experience collection. Requires --dataset-path.
#[arg(long, default_value_t = false)]
offline: bool,
/// Path to a pre-collected experience dataset (bincode format).
/// Used with --offline to load a fixed dataset into the replay buffer.
#[arg(long)]
dataset_path: Option<PathBuf>,
/// Collect and save a dataset from the current policy, then exit.
/// Use this to generate datasets for offline training.
#[arg(long)]
collect_dataset: Option<PathBuf>,
/// Disable Branching DQN (3-head: exposure, order, urgency). Enabled by default.
#[arg(long)]
no_branching: bool,
/// Initial trading capital in dollars. Lower capital teaches conservative
/// position sizing. Must match hyperopt --initial-capital for consistency.
#[arg(long, default_value_t = 35_000.0)]
initial_capital: f64,
/// Named training profile to load from config/training/<profile>.toml.
/// Profile values are applied after hyperopt JSON but before explicit CLI args.
/// Known profiles: dqn-production, dqn-smoketest, dqn-hyperopt.
#[arg(long, default_value = "dqn-production")]
training_profile: String,
/// Feature cache directory (overrides FOXHUNT_FEATURE_CACHE_DIR and auto-discovery)
#[arg(long)]
feature_cache_dir: Option<String>,
/// Per-run RNG seed (Plan 5 Task 5 Phase B). Default 42 — historic implicit
/// value; changing produces a different but still deterministic trajectory.
/// Exported to children via `FOXHUNT_SEED` so every CUDA module that
/// previously used a fixed seed (Xavier init, action selector, PPO replay
/// seeds, regime dropout) mixes this value through `cuda_pipeline::mix_seed`
/// (SplitMix64 avalanche). Used by `argo-train.sh --multi-seed N` to fan
/// out N independent training trajectories on identical data + folds.
#[arg(long, default_value_t = 42)]
seed: u64,
/// SP15 Phase 1.6: trailing quarters held out as sealed final test set.
/// Default 1 (Q9 sealed). Walk-forward folds are generated on
/// Q1..Q(9 - holdout_quarters - dev_quarters) only.
#[arg(long, default_value_t = 1)]
holdout_quarters: usize,
/// SP15 Phase 1.6: trailing quarters used as final dev validation slice
/// (after the sealed holdout). Default 1 (Q8 dev). Walk-forward folds
/// are generated on Q1..Q(9 - holdout_quarters - dev_quarters) only.
#[arg(long, default_value_t = 1)]
dev_quarters: usize,
}
// ---------------------------------------------------------------------------
// Hyperopt parameter loading
// ---------------------------------------------------------------------------
/// Load `best_params` from a hyperopt results JSON file.
///
/// Expected format: `{ "model_key": { "best_params": { ... }, ... } }`
/// Returns `None` if the file doesn't exist or can't be parsed.
#[allow(clippy::cognitive_complexity)]
fn load_hyperopt_params(hp_path: &Option<PathBuf>, model_key: &str) -> Option<Value> {
let file_path = hp_path.as_ref()?;
if !file_path.exists() {
info!("Hyperopt params file not found: {}, using defaults", file_path.display());
return None;
}
let contents = match std::fs::read_to_string(file_path) {
Ok(c) => c,
Err(e) => {
warn!("Failed to read hyperopt params {}: {}", file_path.display(), e);
return None;
}
};
let json: Value = match serde_json::from_str(&contents) {
Ok(v) => v,
Err(e) => {
warn!("Failed to parse hyperopt params JSON: {}", e);
return None;
}
};
let params = json.get(model_key)
.and_then(|m| m.get("best_params"))
.cloned();
if params.is_some() {
info!("Loaded hyperopt params for '{}' from {}", model_key, file_path.display());
} else {
warn!("No best_params found for '{}' in {}", model_key, file_path.display());
}
params
}
/// Load top-K param sets from a hyperopt results JSON file.
///
/// Expected format: `{ "model_key": { "top_k_params": [{ "params": {...}, ... }, ...], "best_params": {...} } }`
/// Falls back to `best_params` as a single entry when `top_k_params` is absent.
/// Returns a vec of length `k` (or fewer if not enough entries), each entry `Some(params)` or `None`.
fn load_top_k_params(hp_path: &Option<PathBuf>, model_key: &str, k: usize) -> Vec<Option<Value>> {
let file_path = match hp_path.as_ref() {
Some(p) if p.exists() => p,
_ => return vec![None; k],
};
let Ok(contents) = std::fs::read_to_string(file_path) else {
return vec![None; k];
};
let Ok(json): Result<Value, _> = serde_json::from_str(&contents) else {
return vec![None; k];
};
let top_k = json
.get(model_key)
.and_then(|m| m.get("top_k_params"))
.and_then(|v| v.as_array());
if let Some(arr) = top_k {
arr.iter()
.take(k)
.map(|entry| entry.get("params").cloned())
.collect()
} else {
// Fallback: just use best_params as the single entry
let best = json
.get(model_key)
.and_then(|m| m.get("best_params"))
.cloned();
vec![best]
}
}
fn hp_f64(params: &Option<Value>, key: &str) -> Option<f64> {
params.as_ref()?.get(key)?.as_f64()
}
/// JSON-born values are always f64 (JSON has no float32 type), but most
/// DQNHyperparameters kernel-facing fields are f32 (post f64→f32 ABI refactor
/// in commit d64adc14f). This helper narrows f64→f32 at the ingest boundary
/// so call sites don't repeat `as f32` casts.
fn hp_f32(params: &Option<Value>, key: &str) -> Option<f32> {
params.as_ref()?.get(key)?.as_f64().map(|v| v as f32)
}
fn hp_usize(params: &Option<Value>, key: &str) -> Option<usize> {
params.as_ref()?.get(key)?.as_u64().map(|v| v as usize)
}
fn hp_bool(params: &Option<Value>, key: &str) -> Option<bool> {
params.as_ref()?.get(key)?.as_bool()
}
// ---------------------------------------------------------------------------
// Fold data helpers
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// DQN Training
// ---------------------------------------------------------------------------
/// Build DQN hyperparameters from args, hyperopt JSON, and GPU profile.
///
/// Extracted from the old `train_dqn_fold` so it can be called ONCE before the fold
/// loop. The returned hyperparams are ready for `DQNTrainer::new`.
#[allow(clippy::cognitive_complexity)]
fn build_dqn_hyperparams(
args: &Args,
hp: &Option<Value>,
total_cost_bps: f64,
) -> DQNHyperparameters {
let gpu_profile = GpuProfile::load();
let hp_hidden_base = hp_usize(hp, "hidden_dim_base");
let dqn_hidden_base = hp_hidden_base.or_else(|| {
info!(" [DQN] hidden_dim_base: {} (from GPU profile)", gpu_profile.training.hidden_dim_base);
Some(gpu_profile.training.hidden_dim_base)
});
let epsilon_start = hp_f32(hp, "epsilon_start").unwrap_or(0.05);
let mut hyperparams = DQNHyperparameters {
learning_rate: hp_f64(hp, "learning_rate").unwrap_or(args.learning_rate),
batch_size: gpu_profile.training.batch_size,
gamma: hp_f32(hp, "gamma").unwrap_or(0.95),
epsilon_start,
epsilon_end: hp_f32(hp, "epsilon_end").unwrap_or(0.01),
epsilon_decay: hp_f32(hp, "epsilon_decay").unwrap_or(0.995),
buffer_size: hp_usize(hp, "buffer_size").unwrap_or(gpu_profile.training.buffer_size),
min_replay_size: hp_usize(hp, "min_replay_size").unwrap_or(1000),
epochs: args.epochs,
checkpoint_frequency: 10,
hidden_dim_base: dqn_hidden_base,
warmup_steps: 0,
early_stopping_enabled: true,
transaction_cost_multiplier: total_cost_bps as f32,
per_alpha: hp_f32(hp, "per_alpha").unwrap_or(0.6),
per_beta_start: hp_f32(hp, "per_beta_start").unwrap_or(0.4),
dueling_hidden_dim: hp_usize(hp, "dueling_hidden_dim").unwrap_or(128),
n_steps: hp_usize(hp, "n_steps").unwrap_or(3),
tau: hp_f32(hp, "tau").unwrap_or(0.005),
num_atoms: hp_usize(hp, "num_atoms")
.unwrap_or(gpu_profile.training.num_atoms),
v_min: hp_f32(hp, "v_min").unwrap_or_else(|| {
let gamma = hp_f64(hp, "gamma").unwrap_or(0.95);
-(10.0_f64 / (1.0 - gamma) * 1.2).clamp(20.0, 300.0) as f32
}),
v_max: hp_f32(hp, "v_max").unwrap_or_else(|| {
let gamma = hp_f64(hp, "gamma").unwrap_or(0.95);
(10.0_f64 / (1.0 - gamma) * 1.2).clamp(20.0, 300.0) as f32
}),
noisy_sigma_init: hp_f32(hp, "noisy_sigma_init").unwrap_or(0.5),
num_quantiles: hp_usize(hp, "num_quantiles").unwrap_or(32),
noisy_epsilon_floor: Some(hp_f32(hp, "noisy_epsilon_floor").unwrap_or(0.05)),
hold_penalty_weight: hp_f32(hp, "hold_penalty_weight").unwrap_or(0.01),
max_position_absolute: hp_f32(hp, "max_position_absolute").unwrap_or(2.0),
max_leverage: hp_f32(hp, "max_leverage").unwrap_or(5.0),
huber_delta: hp_f32(hp, "huber_delta").unwrap_or(10.0),
entropy_coefficient: hp_f64(hp, "entropy_coefficient").unwrap_or(0.01),
curiosity_weight: hp_f32(hp, "curiosity_weight").unwrap_or(0.1),
// SP4 Layer B: `weight_decay` removed from DQNHyperparameters; per-group
// rates are sourced from ISV[WD_RATE[group]] at runtime.
kelly_fractional: hp_f32(hp, "kelly_fractional").unwrap_or(0.5),
kelly_max_fraction: hp_f32(hp, "kelly_max_fraction").unwrap_or(0.25),
mbp10_data_dir: args.mbp10_data_dir.as_ref().map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| "test_data/futures-baseline-mbp10".to_string()),
trades_data_dir: args.trades_data_dir.as_ref().map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| "test_data/futures-baseline-trades".to_string()),
offline_mode: args.offline,
dataset_path: args.dataset_path.as_ref().map(|p| p.to_string_lossy().into_owned()),
replay_buffer_vram_fraction: gpu_profile.training.replay_buffer_vram_fraction as f32,
gpu_timesteps_per_episode: gpu_profile.experience.gpu_timesteps_per_episode,
gpu_n_episodes: gpu_profile.experience.gpu_n_episodes.unwrap_or(256),
// SP15 Phase 1.6: Q1-Q7 train, Q8 dev val, Q9 sealed final test
holdout_quarters: args.holdout_quarters,
dev_quarters: args.dev_quarters,
..DQNHyperparameters::default()
};
// Load training profile (TOML) and apply to hyperparams.
let profile = ml::training_profile::DqnTrainingProfile::load(&args.training_profile);
profile.apply_to(&mut hyperparams);
// CLI args override profile
hyperparams.epochs = args.epochs;
hyperparams.learning_rate = hp_f64(hp, "learning_rate").unwrap_or(args.learning_rate);
hyperparams.initial_capital = args.initial_capital as f32;
hyperparams
}
/// Train a single DQN fold on a pre-initialized trainer.
///
/// The trainer already has GPU data uploaded (via `init_from_fxcache`).
/// This function sets per-fold ranges, resets state, and runs training.
///
/// Returns the best validation loss achieved.
#[allow(clippy::cognitive_complexity, clippy::too_many_arguments)]
fn train_dqn_fold(
rt: &tokio::runtime::Runtime,
trainer: &mut DQNTrainer,
fold: usize,
train_features: &[[f64; 42]],
val_features: &[[f64; 42]],
train_targets: &[[f64; 6]],
val_targets: &[[f64; 6]],
range: &ml::walk_forward::FoldRange,
output_dir: &Path,
checkpoint_prefix: &str,
) -> Result<f64> {
info!(" [DQN] Fold {} -- {} train, {} val features", fold, train_features.len(), val_features.len());
// Set fold range + val data + reset
trainer.set_training_range(range.train_start, range.train_end, range.val_start, range.val_end);
trainer.set_val_data_from_slices(val_features, val_targets, range.val_start);
rt.block_on(trainer.reset_for_fold())
.context("reset_for_fold failed")?;
// Checkpoint callback: save best model to output directory
let output_dir_owned = output_dir.to_path_buf();
let prefix_owned = checkpoint_prefix.to_owned();
let checkpoint_callback = move |epoch: usize, data: Vec<u8>, is_best: bool| -> Result<String> {
let suffix = if is_best { "best" } else { &format!("epoch{}", epoch) };
let ckpt_path = output_dir_owned.join(format!("{}_fold{}_{}.safetensors", prefix_owned, fold, suffix));
let tmp_path = ckpt_path.with_extension("safetensors.tmp");
std::fs::write(&tmp_path, &data)
.with_context(|| format!("Failed to write checkpoint tmp: {}", tmp_path.display()))?;
if let Err(e) = std::fs::rename(&tmp_path, &ckpt_path) {
drop(std::fs::remove_file(&tmp_path));
return Err(e).with_context(|| format!("Failed to rename checkpoint: {} -> {}", tmp_path.display(), ckpt_path.display()));
}
info!(" [DQN] Fold {} saved checkpoint: {} (prefix: {})", fold, ckpt_path.display(), prefix_owned);
Ok(ckpt_path.to_string_lossy().into_owned())
};
let metrics = rt.block_on(
trainer.train_fold_from_slices(train_features, train_targets, checkpoint_callback)
).map_err(|e| {
error!(" [DQN] Fold {} training error chain: {:#}", fold, e);
e
}).context("DQNTrainer training failed")?;
info!(
" [DQN] Fold {} complete -- loss={:.6} epochs_trained={} converged={}",
fold, metrics.loss, metrics.epochs_trained, metrics.convergence_achieved
);
Ok(metrics.loss)
}
// ---------------------------------------------------------------------------
// Training orchestration
// ---------------------------------------------------------------------------
/// Container for per-model RL results collected during training.
struct RlTrainingResult {
model_name: String,
fold_results: Vec<(usize, f64)>,
total_epochs: usize,
}
/// Run the full walk-forward RL training pipeline.
///
/// Returns per-model results so `main()` can write completion markers.
#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
fn run_training(args: &Args) -> Result<Vec<RlTrainingResult>> {
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!(" Symbol: {}", args.symbol);
info!(" Epochs: {}", args.epochs);
info!(" Batch size: auto (from VRAM)");
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!(" Learning rate: {:.1e}", args.learning_rate);
info!(" Tx cost: {:.1} bps commission + {:.1} tick spread (tick_size={:.4})",
args.tx_cost_bps, args.spread_ticks, args.tick_size);
info!(" Patience: {}", args.patience);
if let Some(ref hp_path) = args.hyperopt_params {
info!(" Hyperopt params: {}", hp_path.display());
}
if args.ensemble_top_k > 1 {
info!(" Ensemble top-K: {} (training multiple models per fold)", args.ensemble_top_k);
}
// 1. Try fxcache first, fall back to DBN loading + feature extraction
info!("Step 1/5: Loading data...");
let data_load_start = std::time::Instant::now();
let cache_dir_override = args.feature_cache_dir.as_ref().map(|s| std::path::PathBuf::from(s));
let mbp10 = args.mbp10_data_dir.as_ref().filter(|p| p.exists());
let trades = args.trades_data_dir.as_ref().filter(|p| p.exists());
// Cache key data_source MUST match what `precompute_features` writes with
// (default: "mbp10"). Mismatch produces silent cache MISS → DBN fallback,
// which uploaded RAW (un-normalised) features pre-fix and caused
// label_scale=5443 (raw-price magnitude) on Argo deploys. The DBN fallback
// below now applies NormStats::normalize_batch as defence-in-depth so even
// a future data_source drift cannot reintroduce raw values into training.
let fxcache_data = ml::fxcache::discover_and_load(
&args.data_dir,
&args.symbol,
mbp10.map(|p| p.as_path()),
trades.map(|p| p.as_path()),
"mbp10",
args.imbalance_bar_threshold,
args.imbalance_bar_ewma_alpha,
args.volume_bar_size,
cache_dir_override.as_deref(),
);
// Load data into fxcache-compatible arrays: features, targets, timestamps, ofi
let fxcache = if let Some(cached) = fxcache_data {
info!(" Loaded {} bars + features from fxcache in {:.1}s",
cached.bar_count, data_load_start.elapsed().as_secs_f64());
// Defence-in-depth: validate post-load even on the fast path. fxcache files
// can be stale (different code, different normalization stats, schema drift)
// and the cache_key + schema_hash check doesn't catch every corruption shape.
// Reject rather than upload poisoned values. Same gate the DBN fallback runs.
ml::walk_forward::validate_normalized_features(&cached.features, "fxcache load")?;
cached
} else {
// Fall back to DBN loading — this is SLOW (148GB MBP-10 parsing).
//
// Defence-in-depth: apply NormStats::normalize_batch here too. The
// fxcache fast path is z-normalised at write time
// (precompute_features.rs:625-631). If the cache lookup misses for ANY
// reason (data_source mismatch, schema-hash drift, missing file) and
// the loader falls into this branch, raw features would have flowed
// unchanged into the GPU state buffer — exactly the bug observed on
// Argo train-h5gxb (epoch-0 Sharpe=141 from raw-price labels). Always
// normalise here so the consumer (`init_from_fxcache`) sees the same
// unit-scale features regardless of which path produced them.
info!(" Loading OHLCV bars from DBN files...");
let bars = load_all_bars(&args.data_dir, &args.symbol)?;
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(),
);
info!(" Extracting {}-dimensional features...", args.feature_dim);
let all_features_raw = extract_ml_features(&bars)
.context("Feature extraction failed")?;
let warmup_offset = bars.len().saturating_sub(all_features_raw.len());
info!(" Extracted {} feature vectors (warmup period consumed {} bars)",
all_features_raw.len(), warmup_offset);
// Apply z-score normalisation — same op `precompute_features.rs:629`
// applies on the canonical fxcache path. Without this, feature[0]
// carries raw log-returns and downstream GPU consumers (aux head,
// vol_normalizer) see scale-mismatched values.
let norm_stats = ml::walk_forward::NormStats::from_features(&all_features_raw);
let all_features = norm_stats.normalize_batch(&all_features_raw);
info!(" Features z-score normalised (DBN-fallback path; {} bars × 42 dims)",
all_features.len());
// Defence-in-depth: same gate the fxcache path runs after load. Both
// paths must produce data that satisfies `|feat| ≤ NORMALIZED_FEATURE_BOUND`
// before it's allowed to flow to the GPU. NormStats::normalize already
// clamps each output, so this should always pass — failing here would
// indicate the clamp itself is broken.
ml::walk_forward::validate_normalized_features(&all_features, "DBN fallback")?;
// Build FxCacheData from DBN results (features are already warmup-trimmed)
let aligned_bars = &bars[warmup_offset..];
let n = all_features.len();
let timestamps: Vec<i64> = aligned_bars.iter()
.map(|b| b.timestamp.timestamp_nanos_opt().unwrap_or(0))
.collect();
let targets: Vec<[f64; 6]> = aligned_bars.iter()
.map(|b| [b.close, b.close, b.close, b.close, b.open, b.open])
.collect();
let ofi = vec![[0.0_f64; ml_core::state_layout::OFI_DIM]; n];
ml::fxcache::FxCacheData {
timestamps,
features: all_features,
targets,
ofi,
cache_key: [0u8; 32],
bar_count: n,
has_ofi: false,
alpha_features: None,
}
};
// Apply --max-bars cap. 0 = unlimited (default behaviour unchanged).
// OFI + features + targets + timestamps truncate in lockstep per the
// data_loading.rs precedent (commit 2ac956298 OFI/bars desync fix).
let fxcache = if args.max_bars > 0 && fxcache.bar_count > args.max_bars {
let n = args.max_bars;
let full = fxcache.bar_count;
info!(
" max-bars={}: truncating fxcache {} → {} bars (incl. OFI)",
n, full, n
);
let mut features = fxcache.features;
let mut targets = fxcache.targets;
let mut ofi = fxcache.ofi;
let mut timestamps = fxcache.timestamps;
features.truncate(n);
targets.truncate(n);
ofi.truncate(n);
timestamps.truncate(n);
debug_assert_eq!(features.len(), n);
debug_assert_eq!(targets.len(), n);
debug_assert_eq!(ofi.len(), n);
debug_assert_eq!(timestamps.len(), n);
ml::fxcache::FxCacheData {
timestamps,
features,
targets,
ofi,
cache_key: fxcache.cache_key,
bar_count: n,
has_ofi: fxcache.has_ofi,
alpha_features: None,
}
} else {
fxcache
};
// 2. Generate walk-forward fold ranges from timestamps (zero-copy)
info!("Step 2/5: Generating walk-forward fold ranges...");
let wf_config = WalkForwardConfig {
initial_train_months: args.train_months,
val_months: args.val_months,
test_months: args.test_months,
step_months: args.step_months,
};
let mut fold_ranges = generate_walk_forward_indices_from_timestamps(&fxcache.timestamps, &wf_config);
if fold_ranges.is_empty() {
anyhow::bail!(
"No walk-forward folds generated. Need at least {} months of data.",
wf_config.initial_train_months + wf_config.val_months + wf_config.test_months
);
}
// --max-folds cap (smoke-test support). 0 = no cap. Truncates from the end,
// keeping chronological ordering so the first N folds train.
if args.max_folds > 0 && fold_ranges.len() > args.max_folds {
let full_count = fold_ranges.len();
fold_ranges.truncate(args.max_folds);
info!(
" --max-folds={} capped {} folds down to {}",
args.max_folds, full_count, fold_ranges.len()
);
}
info!(" Generated {} walk-forward folds (zero-copy index ranges)", fold_ranges.len());
// Record data loading + feature extraction time
if train_dqn {
metrics::record_data_load("dqn", data_load_start.elapsed().as_secs_f64());
}
if train_ppo {
metrics::record_data_load("ppo", data_load_start.elapsed().as_secs_f64());
}
// Create output directory
std::fs::create_dir_all(&args.output_dir)
.with_context(|| format!("Failed to create output dir: {}", args.output_dir.display()))?;
// Resolve the shared norm_stats.json sibling of the fxcache. precompute_features
// writes one norm_stats per cache key — RL training previously used the same
// stats for every fold; the audit follow-up
// `docs/lookahead-bias-audit-2026-04-28.md` rec 3 calls for per-fold fit
// (mirroring `train_baseline_supervised.rs:466`). The fold loop below now
// refits per fold from the train-only RAW features and saves a per-fold
// `norm_stats_fold{N}.json` reflecting that fit, so evaluate_baseline still
// resolves the per-fold path it expects without any extra plumbing.
let shared_norm_stats_src = if fxcache.cache_key == [0u8; 32] {
None
} else {
ml::fxcache::norm_stats_path_for_key(
&args.data_dir,
cache_dir_override.as_deref(),
&fxcache.cache_key,
)
.filter(|p| p.exists())
};
// Audit follow-up rec 3 — denormalise fxcache features back to RAW so the
// fold loop can refit `NormStats` from train-only data. Source paths:
//
// - Fxcache fast path: `cached.features` is z-normalised at write time
// (`precompute_features.rs:684`); we load the sidecar JSON to
// recover the producer's global stats, then apply
// `denormalize_batch(features) ≈ features × std + mean` to get back
// to raw. The clamp at write-time means strictly-OOB values (|z|=20)
// round-trip lossy, but the bound is generous enough that real ES
// futures bars never hit it (per `walk_forward.rs::NORMALIZED_FEATURE_BOUND`
// comment, this traps corruption only).
//
// - DBN fallback / missing JSON: skip per-fold renormalisation and
// continue with the existing global-fit path. This is the slow
// path (148 GB MBP-10 parsing); a single global fit there matches
// the legacy behavior. Workflows that produce DBN-fallback data
// should regenerate the fxcache to gain the per-fold-fit upgrade.
//
// The fxcache stays in its current shape — we never mutate `fxcache.features`
// here, so all other consumers (`set_val_data_from_slices`, ensemble trainers'
// `init_from_fxcache` calls in their constructor block, etc.) keep their
// existing pre-normalised contract. Per-fold renormalisation re-uploads to
// GPU explicitly via `init_from_fxcache` at fold start (DQN trainer only;
// ensemble path documented as a known follow-up below).
let raw_features: Option<Vec<[f64; 42]>> = if let Some(ref src) = shared_norm_stats_src {
match std::fs::read_to_string(src) {
Ok(json) => match serde_json::from_str::<ml::walk_forward::NormStats>(&json) {
Ok(global_stats) => {
let raw = global_stats.denormalize_batch(&fxcache.features);
info!(
" Recovered {} raw feature vectors via {} for per-fold NormStats fit (audit rec 3)",
raw.len(),
src.display()
);
Some(raw)
}
Err(e) => {
warn!(
"norm_stats.json malformed at {} ({}) — falling back to global-fit features (audit rec 3 disabled this run)",
src.display(), e
);
None
}
},
Err(e) => {
warn!(
"norm_stats.json read failed at {} ({}) — falling back to global-fit features (audit rec 3 disabled this run)",
src.display(), e
);
None
}
}
} else {
warn!(
" No global norm_stats.json sidecar (cache_key={:?}) — falling back to global-fit features (audit rec 3 disabled this run; regenerate fxcache for per-fold fit)",
if fxcache.cache_key == [0u8; 32] { "DBN-fallback" } else { "missing-file" }
);
None
};
// 3. Create tokio runtime ONCE, create DQN trainer ONCE, upload fxcache to GPU ONCE
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("Failed to create tokio runtime")?;
// Compute average spread slippage in bps from the full dataset
let avg_price = {
let sum: f64 = fxcache.targets.iter().map(|t| t[2]).sum(); // raw_close
if fxcache.bar_count > 0 { sum / fxcache.bar_count as f64 } else { 0.0 }
};
let avg_spread_bps = spread_cost_bps(avg_price, args.tick_size, args.spread_ticks);
let total_cost_bps = args.tx_cost_bps + avg_spread_bps;
info!(" Total tx cost: {:.2} bps (commission {:.1} + spread {:.2})",
total_cost_bps, args.tx_cost_bps, avg_spread_bps);
// Compute bars_per_day from actual timestamps — correct annualization for imbalance bars
let bars_per_day = {
let ts = &fxcache.timestamps;
if ts.len() > 1 {
let first_ns = ts[0];
let last_ns = ts[ts.len() - 1];
let span_days = (last_ns - first_ns) as f64 / (86_400.0 * 1e9);
let trading_days = span_days * (252.0 / 365.0); // ~252/365 are trading days
if trading_days > 1.0 { ts.len() as f64 / trading_days } else { 390.0 }
} else {
390.0
}
};
info!(" Bars per day (from data): {:.0} ({} total bars)", bars_per_day, fxcache.bar_count);
// Build DQN trainer ONCE (shared across folds)
let mut dqn_trainer = if train_dqn {
let hp = load_hyperopt_params(&args.hyperopt_params, "dqn");
let mut hyperparams = build_dqn_hyperparams(args, &hp, total_cost_bps);
hyperparams.bars_per_day = bars_per_day as f32;
let mut trainer = DQNTrainer::new(hyperparams)
.context("Failed to create DQNTrainer")?;
// Upload full fxcache to GPU ONCE — all folds index into this data
info!(" Uploading {} bars to GPU via init_from_fxcache...", fxcache.bar_count);
rt.block_on(trainer.init_from_fxcache(
&fxcache.features, &fxcache.targets, &fxcache.ofi,
)).context("init_from_fxcache failed")?;
info!(" GPU data uploaded — ready for fold loop");
Some(trainer)
} else {
None
};
// Build ensemble trainers ONCE (shared across folds) — upload data once each.
// Previously these were created inside the fold loop, re-uploading per fold.
let mut ensemble_trainers: Vec<DQNTrainer> = Vec::new();
if train_dqn && args.ensemble_top_k > 1 && args.hyperopt_params.is_some() {
let param_sets = load_top_k_params(
&args.hyperopt_params,
"dqn",
args.ensemble_top_k,
);
// k=0 is the primary trainer, so build trainers for k=1..
for (k, hp) in param_sets.iter().enumerate().skip(1) {
let ens_hyperparams = build_dqn_hyperparams(args, hp, total_cost_bps);
match DQNTrainer::new(ens_hyperparams) {
Ok(mut ens_trainer) => {
info!(" Uploading fxcache to ensemble trainer {}...", k);
if let Err(e) = rt.block_on(ens_trainer.init_from_fxcache(
&fxcache.features, &fxcache.targets, &fxcache.ofi,
)) {
error!(" [DQN] Ensemble trainer {} init_from_fxcache failed: {}", k, e);
continue;
}
ensemble_trainers.push(ens_trainer);
}
Err(e) => {
error!(" [DQN] Failed to create ensemble trainer {}: {}", k, e);
}
}
}
info!(" Created {} ensemble trainers (data uploaded once each)", ensemble_trainers.len());
}
// 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();
// Reusable per-fold renormalised features buffer. Populated only on
// the audit-rec-3 path (when `raw_features` is `Some`); the fxcache's
// global-fit features remain the fallback source slice.
let mut fold_features_buf: Vec<[f64; 42]>;
for (fold_idx, range) in fold_ranges.iter().enumerate() {
info!("--- Fold {} ---", range.fold);
info!(
" Train: bars [{}..{}] ({} bars), Val: bars [{}..{}] ({} bars)",
range.train_start, range.train_end,
range.train_end - range.train_start,
range.val_start, range.val_end,
range.val_end - range.val_start,
);
// Audit follow-up rec 3 — per-fold NormStats fit from train-only
// RAW features, applied to the entire dataset for this fold's
// training run. Re-uploaded to GPU via `init_from_fxcache` below.
// When `raw_features.is_none()` (DBN-fallback or missing JSON),
// we copy the global stats sidecar instead — preserves legacy
// behavior on those paths.
let fold_features_slice: &[[f64; 42]] = if let Some(ref raw) = raw_features {
let fold_stats = ml::walk_forward::NormStats::from_features_slice(
raw, range.train_start, range.train_end,
);
let renormalised = fold_stats.normalize_batch(raw);
// Defence-in-depth gate before GPU upload — same contract the
// fxcache fast path enforces at load time.
ml::walk_forward::validate_normalized_features(
&renormalised,
&format!("per-fold renormalisation fold {}", range.fold),
)?;
// Persist per-fold stats for evaluate_baseline. Replaces the
// shared-stats copy below (audit rec 3 — each fold gets its own
// train-only-fit JSON).
let dst = args.output_dir.join(format!("norm_stats_fold{}.json", range.fold));
match serde_json::to_string_pretty(&fold_stats) {
Ok(json) => {
if let Err(e) = std::fs::write(&dst, &json) {
warn!(
" norm_stats_fold{} write failed ({}): {}",
range.fold, dst.display(), e
);
} else {
info!(
" Wrote per-fold norm_stats: {} (fitted on bars [{}..{}])",
dst.display(), range.train_start, range.train_end
);
}
}
Err(e) => warn!(" norm_stats_fold{} serialise failed: {}", range.fold, e),
}
fold_features_buf = renormalised;
// Re-upload renormalised features to GPU via `init_from_fxcache`.
// The trainer was created + uploaded once before the fold loop;
// calling init again replaces `gpu_data` (see
// `crates/ml/src/trainers/dqn/trainer/mod.rs` ::init_from_fxcache,
// `self.gpu_data = Some(gpu_data)` overwrites the prior fold's
// upload). The replay buffer is reset by `reset_for_fold` inside
// `train_dqn_fold`, so prior-fold normalised features can't carry
// over into the new fold's experience.
//
// Ensemble trainers' constructor block (above) uploaded the
// GLOBAL-fit fxcache; this is a known follow-up — the rec-3 fix
// here covers the canonical primary-path. The ensemble code
// path is gated by `args.ensemble_top_k > 1` and only fires
// under hyperopt-best ensembling, separate from baseline
// val-window investigations.
if let Some(ref mut trainer) = dqn_trainer {
rt.block_on(trainer.init_from_fxcache(
&fold_features_buf, &fxcache.targets, &fxcache.ofi,
)).context("per-fold init_from_fxcache failed")?;
info!(" Re-uploaded per-fold renormalised features ({} bars)", fold_features_buf.len());
}
&fold_features_buf
} else {
// Legacy path: fxcache global-fit features. Copy the shared
// norm_stats.json once per fold so evaluate_baseline finds the
// expected per-fold JSON path (matches pre-rec-3 behaviour).
if let Some(src) = &shared_norm_stats_src {
let dst = args.output_dir.join(format!("norm_stats_fold{}.json", range.fold));
if let Err(e) = std::fs::copy(src, &dst) {
warn!(
" norm_stats_fold{} copy failed ({} -> {}): {}",
range.fold, src.display(), dst.display(), e
);
}
}
fxcache.features.as_slice()
};
// Slice features/targets for this fold from the fold-renormalised
// (or global-fit fallback) source.
let train_feat = &fold_features_slice[range.train_start..range.train_end];
let val_feat = &fold_features_slice[range.val_start..range.val_end];
let train_tgt = &fxcache.targets[range.train_start..range.train_end];
let val_tgt = &fxcache.targets[range.val_start..range.val_end];
if train_feat.is_empty() || val_feat.is_empty() {
warn!(" Fold {} -- empty features, skipping", range.fold);
continue;
}
// After audit-rec-3 wiring, `train_feat`/`val_feat` are normalised
// with this fold's TRAIN-ONLY stats — no val-distribution leakage
// into the train-time z-score baseline.
let train_norm = train_feat;
let val_norm = val_feat;
// Train DQN
if let Some(ref mut trainer) = dqn_trainer {
let fold_str = fold_idx.to_string();
let fold_start = std::time::Instant::now();
if args.ensemble_top_k > 1 && args.hyperopt_params.is_some() {
// Ensemble mode: train one model per top-K hyperopt param set.
// Trainers were created + data uploaded BEFORE the fold loop.
// Here we just set_training_range + reset_for_fold on each.
let total_members = 1 + ensemble_trainers.len(); // k=0 (primary) + secondaries
// k=0: Primary ensemble member uses the shared trainer
{
let k = 0;
info!(
" [DQN] Training ensemble member {}/{} on fold {}",
k + 1, total_members, range.fold
);
let prefix = format!("dqn_ensemble_{}", k);
match train_dqn_fold(
&rt, trainer, range.fold,
&train_norm, &val_norm, train_tgt, val_tgt,
range, &args.output_dir, &prefix,
) {
Ok(best_loss) => {
info!(" [DQN] Ensemble member {} fold {} best_loss={:.6}",
k, range.fold, best_loss);
let elapsed = fold_start.elapsed().as_secs_f64();
metrics::set_epoch("dqn", &fold_str, fold_idx as f64);
metrics::set_epoch_loss("dqn", &fold_str, best_loss);
metrics::set_validation_loss("dqn", &fold_str, best_loss);
metrics::set_iteration_seconds("dqn", &fold_str, elapsed);
dqn_results.push((range.fold, best_loss));
}
Err(e) => {
error!(" [DQN] Ensemble member {} fold {} failed: {}",
k, range.fold, e);
}
}
}
// k=1..: Secondary ensemble members reuse pre-created trainers
for (ens_idx, ens_trainer) in ensemble_trainers.iter_mut().enumerate() {
let k = ens_idx + 1;
info!(
" [DQN] Training ensemble member {}/{} on fold {}",
k + 1, total_members, range.fold
);
let prefix = format!("dqn_ensemble_{}", k);
match train_dqn_fold(
&rt, ens_trainer, range.fold,
&train_norm, &val_norm, train_tgt, val_tgt,
range, &args.output_dir, &prefix,
) {
Ok(best_loss) => {
info!(" [DQN] Ensemble member {} fold {} best_loss={:.6}",
k, range.fold, best_loss);
}
Err(e) => {
error!(" [DQN] Ensemble member {} fold {} failed: {}",
k, range.fold, e);
}
}
}
} else {
// Single-model mode (default)
match train_dqn_fold(
&rt, trainer, range.fold,
&train_norm, &val_norm, train_tgt, val_tgt,
range, &args.output_dir, "dqn",
) {
Ok(best_loss) => {
let elapsed = fold_start.elapsed().as_secs_f64();
metrics::set_epoch("dqn", &fold_str, fold_idx as f64);
metrics::set_epoch_loss("dqn", &fold_str, best_loss);
metrics::set_validation_loss("dqn", &fold_str, best_loss);
metrics::set_iteration_seconds("dqn", &fold_str, elapsed);
dqn_results.push((range.fold, best_loss));
}
Err(e) => {
error!(" [DQN] Fold {} failed: {:#}", range.fold, e);
}
}
}
}
// Train PPO (zero-copy: pass fxcache feature slices directly)
if train_ppo {
let hp_ppo = load_hyperopt_params(&args.hyperopt_params, "ppo");
let fold_str = fold_idx.to_string();
let fold_start = std::time::Instant::now();
// Compute per-fold tx cost from fxcache targets (raw close at index 2)
let train_closes: Vec<f64> = fxcache.targets[range.train_start..range.train_end]
.iter().map(|t| t[2]).collect();
let avg_price = if train_closes.is_empty() {
0.0
} else {
train_closes.iter().sum::<f64>() / train_closes.len() as f64
};
let avg_spread_bps_ppo = spread_cost_bps(avg_price, args.tick_size, args.spread_ticks);
let total_cost_bps_ppo = args.tx_cost_bps + avg_spread_bps_ppo;
// Build PpoHyperparameters inline (same as old train_ppo_fold)
let hp_ppo_hidden_base = hp_usize(&hp_ppo, "hidden_dim_base");
let ppo_hidden_base = hp_ppo_hidden_base.or_else(|| {
let profile = ml_core::gpu::profile::GpuProfile::load();
info!(" [PPO] hidden_dim_base: {} (from GPU profile)", profile.training.hidden_dim_base);
Some(profile.training.hidden_dim_base)
});
let ppo_hp = PpoHyperparameters {
learning_rate: hp_f64(&hp_ppo, "learning_rate").unwrap_or(args.learning_rate),
actor_learning_rate: Some(hp_f64(&hp_ppo, "policy_learning_rate").unwrap_or(args.learning_rate)),
critic_learning_rate: Some(hp_f64(&hp_ppo, "value_learning_rate").unwrap_or(args.learning_rate * 3.0)),
batch_size: ml_core::gpu::profile::GpuProfile::load().training.batch_size,
gamma: hp_f64(&hp_ppo, "gamma").unwrap_or(0.99),
clip_epsilon: hp_f64(&hp_ppo, "clip_epsilon").unwrap_or(0.2) as f32,
vf_coef: hp_f64(&hp_ppo, "value_loss_coeff").unwrap_or(0.5) as f32,
ent_coef: hp_f64(&hp_ppo, "entropy_coeff").unwrap_or(0.01) as f32,
gae_lambda: hp_f64(&hp_ppo, "gae_lambda").unwrap_or(0.95) as f32,
rollout_steps: hp_usize(&hp_ppo, "rollout_steps").unwrap_or(2048),
minibatch_size: hp_usize(&hp_ppo, "minibatch_size").unwrap_or(64),
epochs: args.epochs,
early_stopping_enabled: true,
transaction_cost_bps: total_cost_bps_ppo / 100.0,
hidden_dim_base: ppo_hidden_base,
..PpoHyperparameters::conservative()
};
let fold_ckpt_dir = args.output_dir.join(format!("ppo_fold{}", range.fold));
let ppo_trainer = PpoTrainer::new(
ppo_hp,
args.feature_dim,
&fold_ckpt_dir,
true,
None,
);
match ppo_trainer {
Ok(trainer) => {
let fold_idx_cap = range.fold;
let epochs_cap = args.epochs;
let progress_cb = move |m: PpoTrainingMetrics| {
info!("[PPO] Fold {} Epoch {}/{} -- value_loss={:.6}", fold_idx_cap, m.epoch, epochs_cap, m.value_loss);
};
match rt.block_on(trainer.train_from_slices(&train_norm, progress_cb)) {
Ok(metrics) => {
let best_loss = metrics.value_loss as f64;
let elapsed = fold_start.elapsed().as_secs_f64();
metrics::set_epoch("ppo", &fold_str, fold_idx as f64);
metrics::set_epoch_loss("ppo", &fold_str, best_loss);
metrics::set_validation_loss("ppo", &fold_str, best_loss);
metrics::set_iteration_seconds("ppo", &fold_str, elapsed);
ppo_results.push((range.fold, best_loss));
}
Err(e) => error!("PPO fold {} failed: {:#}", range.fold, e),
}
}
Err(e) => error!(" [PPO] Fold {} trainer init failed: {:#}", range.fold, e),
}
}
}
// 5. Summary
info!("Step 5/5: Training Summary");
info!(" ===================================");
let mut all_results = Vec::new();
let num_folds = fold_ranges.len();
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);
}
all_results.push(RlTrainingResult {
model_name: "dqn".to_owned(),
fold_results: dqn_results,
total_epochs: num_folds * args.epochs,
});
}
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);
}
all_results.push(RlTrainingResult {
model_name: "ppo".to_owned(),
fold_results: ppo_results,
total_epochs: num_folds * args.epochs,
});
}
info!(" Checkpoints saved to: {}", args.output_dir.display());
info!(" ===================================");
Ok(all_results)
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
fn main() -> Result<()> {
// Initialize tracing with optional OTLP export to Tempo
let otlp_endpoint = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok();
if let Err(e) = common::observability::init_observability(
"train_baseline_rl",
otlp_endpoint.as_deref(),
) {
eprintln!("Observability init failed (non-fatal): {e}");
}
let args = Args::parse();
// Pre-allocate CUBLAS workspace for deterministic + faster tensor core ops.
// Enable TF32 for all FP32 matmuls — ~8x throughput on H100 tensor cores.
// Plan 5 Task 5 Phase B: export FOXHUNT_SEED so every CUDA module that
// previously used a fixed seed mixes the per-run seed through
// `cuda_pipeline::mix_seed`. Must happen BEFORE any module spins up an
// RNG (Xavier init in particular runs at trainer construction time).
// SAFETY: called once at startup before any multi-threading or CUDA work begins.
#[allow(unsafe_code)]
unsafe {
std::env::set_var("CUBLAS_WORKSPACE_CONFIG", ":4096:8");
std::env::set_var("NVIDIA_TF32_OVERRIDE", "1");
std::env::set_var("FOXHUNT_SEED", args.seed.to_string());
}
info!(
"=== Training: {} seed={} max_folds={} (epochs={}) ===",
args.model, args.seed, args.max_folds, args.epochs
);
metrics::init();
metrics_server::start_metrics_server(9094);
common::metrics::questdb_sink::init(None);
metrics::set_active_workers(1.0);
// Ensure output directory exists before training so markers can always be written.
if let Err(e) = std::fs::create_dir_all(&args.output_dir) {
error!("Failed to create output dir {}: {}", args.output_dir.display(), e);
}
let result = run_training(&args);
metrics::set_active_workers(0.0);
// Push final metrics to pushgateway so they persist after pod termination
if let Err(e) = metrics_server::push_to_gateway(None, "train_baseline_rl") {
tracing::warn!("Failed to push metrics to gateway (non-fatal): {e}");
}
common::metrics::questdb_sink::flush();
match result {
Ok(results) => {
for training_result in &results {
let best_val = training_result
.fold_results
.iter()
.map(|(_, loss)| *loss)
.fold(f64::MAX, f64::min);
let metrics = CompletionMetrics {
model: training_result.model_name.clone(),
symbol: args.symbol.clone(),
best_val_loss: (best_val < f64::MAX).then_some(best_val),
sharpe_ratio: None,
epochs_completed: training_result.total_epochs,
folds_completed: training_result.fold_results.len(),
};
write_success_marker(&args.output_dir, &metrics);
}
Ok(())
}
Err(e) => {
let msg = format!("{:#}", e);
error!("Training failed: {}", msg);
write_failure_marker(&args.output_dir, &msg);
Err(e)
}
}
}