Files
foxhunt/crates/ml/examples/evaluate_baseline.rs
jgrusewski 882497caa4 fix: OFI_DIAG reads new canonical indices [42..62), remove state_dim from evaluate_baseline
- OFI_DIAG now reads positions [42..62) using OFI_START constant instead of
  hardcoded [66..74). Verified: raw_mean=0.0891, delta_mean=-0.0370,
  book_agg=0.4500, log_dur=-0.2303 (was all zeros before).
- Removed 3 stale state_dim field initializers from evaluate_baseline.rs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:33:42 +02:00

2352 lines
97 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![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 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.
//!
//! # GPU-Batched Inference
//!
//! Both DQN and PPO evaluation use chunked GPU-batched inference (chunk size 1024).
//! Instead of one GPU forward pass per bar (N kernel launches), bars are grouped into
//! chunks with shared portfolio state, and each chunk does a single batched GPU forward
//! pass. This reduces GPU kernel launches by ~1000x vs the old per-bar loop.
//!
//! Within a chunk, all bars share the portfolio state (equity, exposure, spread) from
//! the start of the chunk. Between chunks, the portfolio state is updated based on the
//! sequential trade simulation results. This matches the hyperopt adapter approach.
//!
//! # Usage
//!
//! ```bash
//! SQLX_OFFLINE=true cargo run -p ml --example evaluate_baseline -- \
//! --model both --models-dir ml/trained_models \
//! --data-dir test_data/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 clap::Parser;
use serde::Serialize;
use serde_json::Value;
use tracing::{error, info, warn};
use common::metrics::{server as metrics_server, training_metrics as tm};
use ml::common::action::{ExposureLevel, FactoredAction, OrderType as ActionOrderType, Urgency};
use ml::dqn::{DQNConfig, OrderRouter, DQN};
use ml::features::extraction::FeatureVector;
#[allow(unreachable_pub)]
mod baseline_common;
use baseline_common::{load_all_bars, spread_cost_bps};
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};
// Supervised model imports (same as evaluate_supervised)
use ml::training::unified_trainer::UnifiedTrainable;
use ml::diffusion::{DiffusionConfig, DiffusionTrainableAdapter};
use ml::kan::{KANConfig, KANTrainableAdapter};
use ml::liquid::{CfCTrainConfig, DeviceConfig, LiquidTrainableAdapter};
use ml::mamba::{Mamba2Config, trainable_adapter::Mamba2TrainableAdapter};
use ml::tft::{TFTConfig, TrainableTFT};
use ml::tgnn::trainable_adapter::TGGNTrainableAdapter;
use ml::tgnn::TGGNConfig;
use ml::tlob::{TLOBAdapterConfig, TLOBTrainableAdapter};
use ml::xlstm::{XLSTMConfig, XLSTMTrainableAdapter};
use ml_core::cuda_autograd::GpuTensor;
use std::sync::Arc;
use cudarc::driver::CudaStream;
/// Number of bars processed per GPU forward pass.
///
/// All bars in a chunk share the portfolio state (equity, exposure, spread) from
/// the start of the chunk. The portfolio is updated sequentially on CPU between
/// chunks. With 1024 bars, the stale-portfolio approximation is negligible
/// (~0.7 trading days for ES) while reducing GPU kernel launches by ~1000x.
const EVAL_CHUNK_SIZE: usize = 1024;
// ---------------------------------------------------------------------------
// 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 (env: FOXHUNT_DATA_DIR)
#[arg(long, env = "FOXHUNT_DATA_DIR")]
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 (51 market + 3 portfolio = 54, must match training config)
#[arg(long, default_value_t = 54)]
feature_dim: 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,
/// 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 returns; 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)
/// Full spread slippage is added to `tx_cost_bps` per round-trip trade.
#[arg(long, default_value_t = 1.0)]
spread_ticks: f64,
/// Bars per year for Sharpe annualization (ES/NQ=347760, 6E=345000, ZN=105840).
/// Default 347760 = 252 trading days × 1380 bars/day (ES 23h session).
#[arg(long, default_value_t = 347_760.0)]
bars_per_year: f64,
/// Optional path to hyperopt results JSON -- overrides matching config fields
/// (must match the file used during training so network architecture is identical)
#[arg(long)]
hyperopt_params: Option<PathBuf>,
/// Use GPU-accelerated backtest evaluation (default: enabled).
///
/// When enabled and CUDA is available, DQN folds are evaluated using
/// `GpuBacktestEvaluator` — all bars processed on GPU with a single metrics
/// readback. Results differ slightly from the CPU path because the GPU path
/// uses greedy argmax while the CPU path uses hierarchical softmax.
/// Falls back to the CPU path on any GPU error.
///
/// Pass `--no-gpu-eval` to force the CPU path.
#[arg(long, default_value_t = true)]
gpu_eval: bool,
/// Initial capital for GPU backtest evaluator (used only with --gpu-eval).
#[arg(long, default_value_t = 100_000.0)]
initial_capital: f64,
/// Maximum absolute position size in contracts (must match training config).
/// Disable leverage cap to match training env (no leverage constraint during training).
#[arg(long, default_value_t = 2.0)]
max_position: f64,
/// Use CUDA Graph capture for the DQN evaluation step loop.
///
/// When enabled, the entire step loop (gather + forward + env_step for
/// each step) is captured into a CUDA Graph on the first fold and
/// replayed for subsequent calls. Eliminates per-step kernel launch
/// overhead (~5-10us per launch × 4 kernels × max_len steps).
///
/// Falls back to the non-graphed GPU path if capture fails.
/// Only applies to the DQN pure-CUDA forward path (--gpu-eval).
#[arg(long, default_value_t = false)]
cuda_graphs: bool,
}
// ---------------------------------------------------------------------------
// 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
}
fn hp_f64(params: &Option<Value>, key: &str) -> Option<f64> {
params.as_ref()?.get(key)?.as_f64()
}
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()
}
/// All 8 supervised model names recognised by this binary.
const SUPERVISED_MODEL_NAMES: &[&str] = &[
"tft", "mamba2", "liquid", "tggn", "tlob", "kan", "xlstm", "diffusion",
];
/// Create a supervised model with default architecture (same defaults as
/// `train_baseline_supervised` / `evaluate_supervised`). The model is
/// freshly initialised — call `load_checkpoint` afterwards to populate
/// trained weights.
fn create_supervised_model(
name: &str,
feature_dim: usize,
) -> Result<Box<dyn UnifiedTrainable>> {
let lr = 1e-3; // irrelevant for eval, but constructors require it
match name {
"tft" => {
let config = TFTConfig {
input_dim: feature_dim,
hidden_dim: 128,
num_heads: 4,
num_layers: 2,
num_quantiles: 3,
num_static_features: 0,
num_known_features: 0,
num_unknown_features: feature_dim,
sequence_length: 1,
prediction_horizon: 1,
dropout_rate: 0.1,
..TFTConfig::default()
};
let mut adapter = TrainableTFT::new(config)
.map_err(|e| anyhow::anyhow!("Failed to create TFT: {}", e))?;
adapter
.set_learning_rate(lr)
.map_err(|e| anyhow::anyhow!("Failed to set TFT learning rate: {}", e))?;
Ok(Box::new(adapter))
}
"mamba2" => {
let config = Mamba2Config {
d_model: 128,
num_layers: 4,
d_state: 16,
max_seq_len: 60,
..Mamba2Config::default()
};
let native_dev = ml_core::native_types::NativeDevice::Cuda(0);
let mut adapter = Mamba2TrainableAdapter::from_native_device(config, &native_dev)
.map_err(|e| anyhow::anyhow!("Failed to create Mamba2: {}", e))?;
adapter
.set_learning_rate(lr)
.map_err(|e| anyhow::anyhow!("Failed to set Mamba2 learning rate: {}", e))?;
Ok(Box::new(adapter))
}
"liquid" => {
let config = CfCTrainConfig {
input_size: feature_dim,
hidden_size: 128,
output_size: 1,
backbone_hidden_sizes: vec![128, 64],
learning_rate: lr,
device: DeviceConfig::Auto,
..CfCTrainConfig::default()
};
let adapter = LiquidTrainableAdapter::new(config)
.map_err(|e| anyhow::anyhow!("Failed to create Liquid: {}", e))?;
Ok(Box::new(adapter))
}
"tggn" => {
let config = TGGNConfig {
node_dim: feature_dim,
hidden_dim: 32,
num_layers: 2,
max_nodes: 64,
max_edges: 128,
edge_dim: 4,
temporal_decay: 0.99,
update_frequency_ns: 1_000_000,
use_simd: false,
};
let mut adapter = TGGNTrainableAdapter::new(config)
.map_err(|e| anyhow::anyhow!("Failed to create TGGN: {}", e))?;
adapter
.set_learning_rate(lr)
.map_err(|e| anyhow::anyhow!("Failed to set TGGN learning rate: {}", e))?;
Ok(Box::new(adapter))
}
"tlob" => {
let config = TLOBAdapterConfig {
d_model: 128,
num_heads: 4,
num_layers: 2,
seq_len: 1,
feature_dim,
};
let mut adapter = TLOBTrainableAdapter::new(config)
.map_err(|e| anyhow::anyhow!("Failed to create TLOB: {}", e))?;
adapter
.set_learning_rate(lr)
.map_err(|e| anyhow::anyhow!("Failed to set TLOB learning rate: {}", e))?;
Ok(Box::new(adapter))
}
"kan" => {
let config = KANConfig {
grid_size: 5,
spline_order: 4,
layer_widths: vec![feature_dim, 32, 16, 1],
learning_rate: lr,
weight_decay: 1e-4,
grad_clip: 1.0,
};
let adapter = KANTrainableAdapter::new(config)
.map_err(|e| anyhow::anyhow!("Failed to create KAN: {}", e))?;
Ok(Box::new(adapter))
}
"xlstm" => {
let config = XLSTMConfig {
input_dim: feature_dim,
hidden_dim: 128,
..XLSTMConfig::default()
};
let mut adapter = XLSTMTrainableAdapter::new(config)
.map_err(|e| anyhow::anyhow!("Failed to create xLSTM: {}", e))?;
adapter
.set_learning_rate(lr)
.map_err(|e| anyhow::anyhow!("Failed to set xLSTM learning rate: {}", e))?;
Ok(Box::new(adapter))
}
"diffusion" => {
let config = DiffusionConfig {
feature_dim,
hidden_dim: 128,
..DiffusionConfig::default()
};
let ctx = cudarc::driver::CudaContext::new(0)
.map_err(|e| anyhow::anyhow!("CUDA context for Diffusion: {}", e))?;
let diff_stream = ctx.new_stream()
.map_err(|e| anyhow::anyhow!("CUDA stream for Diffusion: {}", e))?;
let adapter = DiffusionTrainableAdapter::new(config, &diff_stream)
.map_err(|e| anyhow::anyhow!("Failed to create Diffusion: {}", e))?;
Ok(Box::new(adapter))
}
_ => anyhow::bail!("Unknown supervised model: {}", name),
}
}
/// Locate the best checkpoint for a supervised model fold.
///
/// Convention: `<models_dir>/<model>/<model>_fold<N>_best` (with `.json` metadata).
/// Falls back to `<models_dir>/<model>_fold<N>_best` for flat layouts.
fn find_supervised_checkpoint(
models_dir: &std::path::Path,
model_name: &str,
fold: usize,
) -> Result<std::path::PathBuf> {
let subdir_ckpt = models_dir
.join(model_name)
.join(format!("{}_fold{}_best", model_name, fold));
let meta_subdir = format!("{}.json", subdir_ckpt.display());
if std::path::Path::new(&meta_subdir).exists() {
return Ok(subdir_ckpt);
}
// Flat layout fallback
let flat_ckpt = models_dir.join(format!("{}_fold{}_best", model_name, fold));
let meta_flat = format!("{}.json", flat_ckpt.display());
if std::path::Path::new(&meta_flat).exists() {
return Ok(flat_ckpt);
}
anyhow::bail!(
"No {} checkpoint found for fold {} in {} (tried {} and {})",
model_name,
fold,
models_dir.display(),
subdir_ckpt.display(),
flat_ckpt.display(),
)
}
// ---------------------------------------------------------------------------
// Report Data Types
// ---------------------------------------------------------------------------
/// Metrics for a single fold/model combination.
#[derive(Debug, Serialize)]
struct FoldMetrics {
fold: usize,
model: String,
sharpe_ratio: f64,
/// Trade-level Sharpe (non-zero returns only, sqrt(252) annualization).
/// Comparable to hyperopt's `PerformanceMetrics::from_trades` Sharpe.
trade_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_trade_sharpe: f64,
dqn_avg_drawdown: f64,
dqn_avg_win_rate: f64,
ppo_avg_sharpe: f64,
ppo_avg_trade_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,
}
// ---------------------------------------------------------------------------
// Financial Metrics
// ---------------------------------------------------------------------------
/// Container for computed financial metrics from a sequence of trade returns.
struct ComputedMetrics {
sharpe_ratio: f64,
/// Trade-level Sharpe ratio (matches hyperopt's `PerformanceMetrics::from_trades`).
/// Computed from non-zero returns only, annualized with `sqrt(252)`.
trade_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 (bar): annualized (mean / std * `sqrt(bars_per_year)`), includes HOLD bars
/// - Sharpe (trade): annualized (mean / std * `sqrt(252)`), non-zero returns only
/// - 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], bars_per_year: f64) -> ComputedMetrics {
let n = returns.len();
if n == 0 {
return ComputedMetrics {
sharpe_ratio: 0.0,
trade_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();
// Per-bar Sharpe: mean and std over ALL bars (including HOLD=0), annualized
// with sqrt(bars_per_year). This produces larger absolute values because the
// annualization factor (~590) is much larger than sqrt(252) (~15.9).
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();
let sharpe_ratio = if std > 1e-12 {
(mean / std) * bars_per_year.sqrt()
} else {
0.0
};
// Trade-level Sharpe (matches hyperopt's PerformanceMetrics::from_trades /
// calculate_sharpe_ratio in evaluation/metrics.rs):
// Only considers non-zero returns (actual trades), annualized with sqrt(252).
let trade_returns: Vec<f64> = returns.iter().copied().filter(|&r| r.abs() > 1e-12).collect();
let trade_sharpe_ratio = if trade_returns.len() > 1 {
let trade_mean: f64 = trade_returns.iter().sum::<f64>() / trade_returns.len() as f64;
let trade_var: f64 = trade_returns
.iter()
.map(|&r| (r - trade_mean).powi(2))
.sum::<f64>()
/ trade_returns.len() as f64;
let trade_std = trade_var.sqrt();
if trade_std > 1e-12 {
(trade_mean / trade_std) * 252.0_f64.sqrt()
} else {
0.0
}
} 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 *= 1.0 + 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 (from compounded equity curve)
let total_return_pct = (equity - 1.0) * 100.0;
ComputedMetrics {
sharpe_ratio,
trade_sharpe_ratio,
max_drawdown_pct,
win_rate_pct,
profit_factor,
total_return_pct,
num_trades,
}
}
// ---------------------------------------------------------------------------
// GPU-Batched Inference Helpers
// ---------------------------------------------------------------------------
/// Mutable portfolio state carried across chunks during evaluation.
struct PortfolioState {
equity: f64,
current_exposure: f64,
}
/// Build flat f32 state vectors for a chunk of bars.
///
/// Portfolio features (equity, exposure, spread) are frozen at the values from
/// the START of the chunk. Market features are per-bar. Returns a contiguous
/// `Vec<f32>` of length `chunk_len * feature_dim`.
fn build_chunk_states(
test_features: &[FeatureVector],
test_bars: &[OHLCVBar],
chunk_start: usize,
chunk_end: usize,
feature_dim: usize,
portfolio: &PortfolioState,
tick_size: f64,
spread_ticks: f64,
) -> Vec<f32> {
let chunk_len = chunk_end - chunk_start;
let chunk_equity = portfolio.equity as f32;
let chunk_exposure = portfolio.current_exposure as f32;
let mut flat_states: Vec<f32> = Vec::with_capacity(chunk_len * feature_dim);
for bar_idx in chunk_start..chunk_end {
let Some(feat) = test_features.get(bar_idx) else {
flat_states.extend(std::iter::repeat_n(0.0_f32, feature_dim));
continue;
};
// 51 market features (f64 -> f32)
for &v in feat {
flat_states.push(v as f32);
}
// 3 portfolio features matching training's PortfolioTracker.get_portfolio_features():
// [0] normalized_value = portfolio_value / initial_capital (~1.0)
// [1] normalized_position = position_size / max_position (-1.0 to +1.0)
// [2] avg_spread = tick_size * spread_ticks / price (~0.0001)
let close = test_bars.get(bar_idx).map(|b| b.close).unwrap_or(1.0);
let spread_estimate = if close > 1e-12 {
(tick_size * spread_ticks / close) as f32
} else {
0.0001_f32
};
flat_states.push(chunk_equity); // normalized_value
flat_states.push(chunk_exposure); // normalized_position
flat_states.push(spread_estimate); // avg_spread
}
flat_states
}
/// Simulate trades for a chunk of action indices, updating portfolio state and
/// accumulating returns and action counts.
///
/// Processes action indices sequentially on CPU. Each action maps to a
/// `FactoredAction` with exposure, order type, and urgency. Transaction costs
/// are proportional to position delta and differentiated by order type/urgency.
fn simulate_chunk_trades(
action_indices: &[usize],
chunk_start: usize,
test_bars: &[OHLCVBar],
args: &Args,
portfolio: &mut PortfolioState,
returns: &mut Vec<f64>,
action_counts: &mut [usize; 3],
model_name: &str,
is_dqn: bool,
) {
for (i, &action_idx) in action_indices.iter().enumerate() {
let bar_idx = chunk_start + i;
let action = if is_dqn {
match ExposureLevel::from_index(action_idx) {
Ok(exposure) => OrderRouter::route_default(exposure),
Err(e) => {
warn!(" [{}] exposure_from_index({}) error at bar {}: {}", model_name, action_idx, bar_idx, e);
FactoredAction::new(ExposureLevel::Flat, ActionOrderType::Market, Urgency::Normal)
}
}
} else {
match FactoredAction::from_index(action_idx) {
Ok(fa) => fa,
Err(e) => {
warn!(" [{}] from_index({}) error at bar {}: {}", model_name, action_idx, bar_idx, e);
FactoredAction::new(ExposureLevel::Flat, ActionOrderType::Market, Urgency::Normal)
}
}
};
// Track action diversity by legacy category (buy/sell/hold)
let legacy_idx = if action.is_buy() { 0 } else if action.is_sell() { 1 } else { 2 };
if let Some(count) = action_counts.get_mut(legacy_idx) {
*count += 1;
}
// Compute percentage return, clamped to filter contract roll boundaries
let close_cur = test_bars.get(bar_idx).map(|b| b.close).unwrap_or(0.0);
let close_next = test_bars.get(bar_idx + 1).map(|b| b.close).unwrap_or(close_cur);
let pct_change = if close_cur.abs() > 1e-12 {
((close_next - close_cur) / close_cur).clamp(-args.max_bar_return, args.max_bar_return)
} else {
0.0
};
// Position delta: only incur costs when position actually changes
let target_exposure = action.target_exposure();
let position_delta = (target_exposure - portfolio.current_exposure).abs();
// Order-type and urgency-differentiated transaction cost:
// - Market orders: 15 bps, LimitMaker: 5 bps, IoC: 10 bps
// - Urgency scales cost: Patient=0.5x, Normal=1.0x, Aggressive=1.5x
// Cost is proportional to position delta (no cost for holding same position)
let tx_cost = if position_delta > 1e-12 {
let base_bps = args.tx_cost_bps + spread_cost_bps(close_cur, args.tick_size, args.spread_ticks);
let order_cost_frac = action.transaction_cost(); // 0.0015/0.0005/0.0010
let urgency_mult = action.urgency_weight(); // 0.5/1.0/1.5
position_delta * (base_bps * 0.0001 + order_cost_frac * urgency_mult)
} else {
0.0
};
// Exposure-weighted return minus differentiated transaction cost
let ret = target_exposure * pct_change - tx_cost;
returns.push(ret);
// Update portfolio state for next bar (and next chunk's frozen state)
portfolio.equity *= 1.0 + ret;
portfolio.current_exposure = target_exposure;
}
}
// ---------------------------------------------------------------------------
// DQN Evaluation
// ---------------------------------------------------------------------------
/// Run DQN inference on test features using GPU-batched forward passes and return
/// per-bar trade returns and action counts (buy, sell, hold).
///
/// Bars are processed in chunks of `EVAL_CHUNK_SIZE` (1024). Each chunk:
/// 1. Builds state vectors on CPU with current portfolio features (equity, exposure, spread)
/// 2. Transfers the `[chunk_len, 54]` tensor to GPU in a single copy
/// 3. Executes one batched GPU forward pass via `batch_greedy_actions` (argmax of Q-values)
/// 4. Transfers only the action index vector (`Vec<usize>`) back to CPU
/// 5. Simulates trades sequentially on CPU to update portfolio state for the next chunk
///
/// This reduces GPU kernel launches from N (one per bar) to ceil(N/1024).
#[allow(clippy::cognitive_complexity)]
fn evaluate_dqn_fold(
fold: usize,
test_features: &[FeatureVector],
test_bars: &[OHLCVBar],
models_dir: &Path,
args: &Args,
hp: &Option<Value>,
) -> Result<(Vec<f64>, [usize; 3])> {
// Prefer `_best` checkpoint; fall back to highest `_epoch{N}` if training
// early-stopped without marking a "best" (e.g. validation loss plateaued).
let best_path = models_dir.join(format!("dqn_fold{}_best.safetensors", fold));
let ckpt_path = if best_path.exists() {
best_path
} else {
// Glob for epoch checkpoints and pick the highest epoch number
let pattern = format!("dqn_fold{}_epoch", fold);
let mut candidates: Vec<_> = std::fs::read_dir(models_dir)
.ok()
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.filter(|e| {
let name = e.file_name();
let s = name.to_string_lossy();
s.starts_with(&pattern) && s.ends_with(".safetensors")
})
.collect();
candidates.sort_by_key(|e| std::cmp::Reverse(e.file_name()));
match candidates.first() {
Some(entry) => {
let p = entry.path();
info!("DQN fold {} using fallback checkpoint: {}", fold, p.display());
p
}
None => {
anyhow::bail!(
"No DQN checkpoint found for fold {} in {}",
fold, models_dir.display()
);
}
}
};
// Create DQN with same config as training — all architecture-affecting params
// must match exactly, otherwise checkpoint loading fails (tensor shape mismatch).
#[allow(clippy::integer_division)]
let config = DQNConfig {
num_actions: args.num_actions,
hidden_dims: {
let base = hp_usize(hp, "hidden_dim_base").unwrap_or(256);
// Align to 8 for tensor cores (matches training)
let align = |x: usize| -> usize { x.div_ceil(8) * 8 };
vec![align(base), align(base / 2), align(base / 4)]
},
learning_rate: 1e-4,
gamma: hp_f64(hp, "gamma").unwrap_or(0.95) as f32,
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,
// Architecture params — must match training checkpoint shapes
dueling_hidden_dim: hp_usize(hp, "dueling_hidden_dim").unwrap_or(128),
num_atoms: hp_usize(hp, "num_atoms").unwrap_or(51),
v_min: hp_f64(hp, "v_min").unwrap_or_else(|| {
let gamma = hp_f64(hp, "gamma").unwrap_or(0.95);
-(10.0 / (1.0 - gamma) * 1.2).clamp(20.0, 300.0)
}) as f32,
v_max: hp_f64(hp, "v_max").unwrap_or_else(|| {
let gamma = hp_f64(hp, "gamma").unwrap_or(0.95);
(10.0 / (1.0 - gamma) * 1.2).clamp(20.0, 300.0)
}) as f32,
iqn_num_quantiles: hp_usize(hp, "num_quantiles").unwrap_or(32),
..DQNConfig::default()
};
let mut dqn = DQN::new(config).context("Failed to create DQN model")?;
// Load trained weights (DQN::new auto-selects CUDA if available)
dqn.load_from_safetensors(&ckpt_path.to_string_lossy())
.with_context(|| format!("Failed to load DQN checkpoint: {}", ckpt_path.display()))?;
// B1 FIX: Set eval mode — disable noisy layer noise, use mean weights only.
// This matches hyperopt's action selection (no training noise during inference).
dqn.set_eval_mode(true)
.with_context(|| "Failed to set DQN eval mode")?;
let stream = dqn.cuda_stream().clone();
let eval_softmax_temp = hp_f64(hp, "eval_softmax_temp").unwrap_or(1.0);
info!(
" [DQN] Loaded checkpoint: {} (softmax_temp={:.2})",
ckpt_path.display(),
eval_softmax_temp,
);
// ── Per-bar inference with portfolio state sync ─────────────────────────
//
// B3 FIX: Process each bar individually to update portfolio features
// (equity, exposure) between bars. The old chunked approach froze
// portfolio state within each 1024-bar chunk, creating state mismatch.
//
// B1 FIX: Use hierarchical softmax action selection (matching hyperopt)
// instead of greedy argmax. This ensures eval results match training.
//
// Performance: ~N GPU calls instead of N/1024, but eval isn't latency-critical.
let eval_bars = test_features.len().saturating_sub(1); // last bar has no next-bar return
let feature_dim = args.feature_dim;
let mut returns = Vec::with_capacity(eval_bars);
let mut action_counts = [0_usize; 3]; // [buy, sell, hold]
let mut portfolio = PortfolioState { equity: 1.0, current_exposure: 0.0 };
info!(
" [DQN] Per-bar eval with portfolio sync: {} bars",
eval_bars,
);
for bar_idx in 0..eval_bars {
// Build single state vector with current portfolio features
let flat_state = build_chunk_states(
test_features, test_bars, bar_idx, bar_idx + 1,
feature_dim, &portfolio, args.tick_size, args.spread_ticks,
);
// Single-bar GPU forward pass + hierarchical softmax selection (B1)
let state_tensor = GpuTensor::from_host(&flat_state, vec![1, feature_dim], &stream)
.map_err(|e| anyhow::anyhow!("Failed to create DQN state tensor for bar {}: {}", bar_idx, e))?;
let action_tensor = dqn.batch_hierarchical_softmax_actions(&state_tensor, eval_softmax_temp)
.with_context(|| format!("DQN softmax action selection failed for bar {}", bar_idx))?;
let action_host = action_tensor.to_host(&stream)
.map_err(|e| anyhow::anyhow!("Action extraction: {e}"))?;
let action_idx = action_host.first().copied().unwrap_or(0.0) as usize;
let action_indices = vec![action_idx];
// Simulate trade and update portfolio state (B3: state synced per bar)
simulate_chunk_trades(
&action_indices, bar_idx, test_bars, args,
&mut portfolio, &mut returns, &mut action_counts, "DQN", true,
);
}
Ok((returns, action_counts))
}
// ---------------------------------------------------------------------------
// GPU-Accelerated DQN Evaluation
// ---------------------------------------------------------------------------
/// GPU-accelerated DQN evaluation using `GpuBacktestEvaluator`.
///
/// Uses greedy argmax for action selection (not hierarchical softmax as in the
/// CPU path). Results will differ slightly from `evaluate_dqn_fold`. This is
/// intentional: the GPU path prioritises throughput (all bars processed in a
/// single GPU loop with one metrics readback) over exact matching of the CPU
/// softmax-based selection.
///
/// The function returns `Vec<WindowMetrics>` — one entry per window (here,
/// one window = the full test fold). Call sites are responsible for converting
/// these into `FoldMetrics` for the report.
#[allow(clippy::cognitive_complexity)]
fn evaluate_dqn_fold_gpu(
fold: usize,
test_features: &[ml::features::extraction::FeatureVector],
test_bars: &[ml::types::OHLCVBar],
models_dir: &std::path::Path,
args: &Args,
hp: &Option<serde_json::Value>,
) -> Result<Vec<ml::cuda_pipeline::gpu_backtest_evaluator::WindowMetrics>> {
use ml::cuda_pipeline::gpu_backtest_evaluator::{GpuBacktestConfig, GpuBacktestEvaluator};
// ── Load checkpoint (identical to CPU path) ───────────────────────────
let best_path = models_dir.join(format!("dqn_fold{}_best.safetensors", fold));
let ckpt_path = if best_path.exists() {
best_path
} else {
let pattern = format!("dqn_fold{}_epoch", fold);
let mut candidates: Vec<_> = std::fs::read_dir(models_dir)
.ok()
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.filter(|e| {
let name = e.file_name();
let s = name.to_string_lossy();
s.starts_with(&pattern) && s.ends_with(".safetensors")
})
.collect();
candidates.sort_by_key(|e| std::cmp::Reverse(e.file_name()));
match candidates.first() {
Some(entry) => {
let p = entry.path();
info!(
" [DQN GPU] fold {} using fallback checkpoint: {}",
fold,
p.display()
);
p
}
None => {
anyhow::bail!(
"No DQN checkpoint found for fold {} in {}",
fold,
models_dir.display()
);
}
}
};
#[allow(clippy::integer_division)]
let config = DQNConfig {
num_actions: args.num_actions,
hidden_dims: {
let base = hp_usize(hp, "hidden_dim_base").unwrap_or(256);
let align = |x: usize| -> usize { x.div_ceil(8) * 8 };
vec![align(base), align(base / 2), align(base / 4)]
},
learning_rate: 1e-4,
gamma: hp_f64(hp, "gamma").unwrap_or(0.95) as f32,
epsilon_start: 0.0,
epsilon_end: 0.0,
epsilon_decay: 1.0,
replay_buffer_capacity: 100,
batch_size: 64,
min_replay_size: 64,
target_update_freq: 500,
warmup_steps: 0,
dueling_hidden_dim: hp_usize(hp, "dueling_hidden_dim").unwrap_or(128),
num_atoms: hp_usize(hp, "num_atoms").unwrap_or(51),
v_min: hp_f64(hp, "v_min").unwrap_or_else(|| {
let gamma = hp_f64(hp, "gamma").unwrap_or(0.95);
-(10.0 / (1.0 - gamma) * 1.2).clamp(20.0, 300.0)
}) as f32,
v_max: hp_f64(hp, "v_max").unwrap_or_else(|| {
let gamma = hp_f64(hp, "gamma").unwrap_or(0.95);
(10.0 / (1.0 - gamma) * 1.2).clamp(20.0, 300.0)
}) as f32,
iqn_num_quantiles: hp_usize(hp, "num_quantiles").unwrap_or(32),
..DQNConfig::default()
};
let mut dqn = DQN::new(config).context("Failed to create DQN model (GPU path)")?;
dqn.load_from_safetensors(&ckpt_path.to_string_lossy())
.with_context(|| {
format!(
"Failed to load DQN checkpoint (GPU path): {}",
ckpt_path.display()
)
})?;
dqn.set_eval_mode(true)
.context("Failed to set DQN eval mode (GPU path)")?;
let stream = dqn.cuda_stream().clone();
info!(
" [DQN GPU] Loaded checkpoint: {} (greedy argmax)",
ckpt_path.display(),
);
warn!(
" [DQN GPU] Spread cost uses constant tick_size*spread_ticks={:.6}, \
CPU path uses per-bar estimate — results will differ slightly",
args.tick_size * args.spread_ticks,
);
// ── Build single window from all test data ────────────────────────────
//
// The GpuBacktestEvaluator's `feature_dim` parameter is the number of
// MARKET features only (the gather kernel appends the 3 portfolio features
// internally). Total state_dim = feature_dim + 3.
let eval_bars = test_features.len().saturating_sub(1);
if eval_bars == 0 {
anyhow::bail!("No bars to evaluate in GPU path for fold {}", fold);
}
// Market features only (42 dims from extract_ml_features). The gather kernel
// appends live portfolio at [feat_dim..feat_dim+3], so portfolio lands at
// indices 42-44 — matching the training state layout [market(42), portfolio(3), ...].
let market_feature_dim: usize = 42;
let mut prices: Vec<[f32; 4]> = Vec::with_capacity(eval_bars);
let mut features: Vec<Vec<f32>> = Vec::with_capacity(eval_bars);
for bar_idx in 0..eval_bars {
let bar = test_bars.get(bar_idx).ok_or_else(|| {
anyhow::anyhow!("test_bars index {} out of bounds (len={})", bar_idx, test_bars.len())
})?;
let open = bar.open as f32;
let high = bar.high as f32;
let low = bar.low as f32;
let close = bar.close as f32;
prices.push([open, high, low, close]);
let fv = test_features.get(bar_idx).ok_or_else(|| {
anyhow::anyhow!(
"test_features index {} out of bounds (len={})",
bar_idx,
test_features.len()
)
})?;
// Take up to market_feature_dim values; truncate portfolio dims if any
let market_fv: Vec<f32> = fv
.iter()
.take(market_feature_dim)
.map(|&v| v as f32)
.collect();
features.push(market_fv);
}
let gpu_config = GpuBacktestConfig {
max_position: args.max_position as f32,
tx_cost_bps: args.tx_cost_bps as f32,
spread_cost: (args.tick_size * args.spread_ticks) as f32,
initial_capital: args.initial_capital as f32,
max_leverage: 0.0, // Disabled: match training env (no leverage cap)
..Default::default()
};
// Single window = full test fold
let mut evaluator = GpuBacktestEvaluator::new(
&[prices],
&[features],
market_feature_dim,
gpu_config,
&stream,
)
.with_context(|| format!("GpuBacktestEvaluator::new failed for fold {}", fold))?;
// ── Extract weights for pure-CUDA forward pass ─────────────────────
//
// Branching DQN is always active. Extract dueling + branching weight sets
// directly from the BranchingDuelingQNetwork (zero-copy pointer views).
// Fall back to the closure-based evaluate() path if no network is present.
let metrics = if let Some(ref br) = dqn.branching_q_network {
use ml::cuda_pipeline::gpu_weights::weight_sets_from_branching;
let cfg = br.config();
let sh1 = *cfg.shared_hidden_dims.first().ok_or_else(|| {
anyhow::anyhow!("Branching network has no shared hidden dims")
})?;
let sh2 = *cfg.shared_hidden_dims.get(1).ok_or_else(|| {
anyhow::anyhow!("Branching network needs at least 2 shared hidden dims")
})?;
let network_dims = (
sh1,
sh2,
cfg.value_hidden_dim,
cfg.branch_hidden_dim,
);
let (weights, branching_weights) = weight_sets_from_branching(br);
let dqn_cfg = ml::cuda_pipeline::gpu_backtest_evaluator::DqnBacktestConfig::from_network_dims(network_dims);
{
// Standalone eval: closure-based forward pass.
// Training eval uses FusedTrainingCtx as QValueProvider for determinism.
info!(
" [DQN GPU] Using closure forward pass (fold {}, dims=({},{},{},{}))",
fold, network_dims.0, network_dims.1, network_dims.2, network_dims.3,
);
let eval_stream = stream.clone();
evaluator
.evaluate(
&|states_flat: &cudarc::driver::CudaSlice<f32>, batch_size: usize, state_dim: usize| -> Result<cudarc::driver::CudaSlice<i32>, ml::MLError> {
let host_f32 = eval_stream.clone_dtoh(states_flat)
.map_err(|e| ml::MLError::ModelError(format!("DtoH states: {e}")))?;
let states_tensor = GpuTensor::from_host(&host_f32, vec![batch_size, state_dim], &eval_stream)
.map_err(|e| ml::MLError::ModelError(format!("GpuTensor from_host: {e}")))?;
let q_values = dqn.q_values_for_batch(&states_tensor)?;
let argmax_indices = q_values.argmax(1, &eval_stream)
.map_err(|e| ml::MLError::ModelError(format!("argmax: {e}")))?;
let actions_i32: Vec<i32> = argmax_indices.iter().map(|&v| v as i32).collect();
let mut out = eval_stream.alloc_zeros::<i32>(actions_i32.len())
.map_err(|e| ml::MLError::ModelError(format!("alloc actions: {e}")))?;
eval_stream.memcpy_htod(&actions_i32, &mut out)
.map_err(|e| ml::MLError::ModelError(format!("upload actions: {e}")))?;
Ok(out)
},
3,
)
.with_context(|| format!(
"GpuBacktestEvaluator::evaluate failed for fold {}", fold
))?
}
} else {
// Non-branching fallback: use closure-based GPU forward pass
info!(
" [DQN GPU] No branching network, using closure forward path (fold {})",
fold
);
let eval_stream = stream.clone();
evaluator
.evaluate(
&|states_flat: &cudarc::driver::CudaSlice<f32>, batch_size: usize, state_dim: usize| -> Result<cudarc::driver::CudaSlice<i32>, ml::MLError> {
// Download f32 states to host, then upload as f32 GpuTensor for DQN forward
let host_f32 = eval_stream.clone_dtoh(states_flat)
.map_err(|e| ml::MLError::ModelError(format!("DtoH states: {e}")))?;
let states_tensor = GpuTensor::from_host(&host_f32, vec![batch_size, state_dim], &eval_stream)
.map_err(|e| ml::MLError::ModelError(format!("GpuTensor from_host: {e}")))?;
let q_values = dqn.q_values_for_batch(&states_tensor)?;
let argmax_indices = q_values.argmax(1, &eval_stream)
.map_err(|e| ml::MLError::ModelError(format!("argmax: {e}")))?;
// Convert Vec<u32> to CudaSlice<i32>
let actions_i32: Vec<i32> = argmax_indices.iter().map(|&v| v as i32).collect();
let mut out = eval_stream.alloc_zeros::<i32>(actions_i32.len())
.map_err(|e| ml::MLError::ModelError(format!("alloc actions: {e}")))?;
eval_stream.memcpy_htod(&actions_i32, &mut out)
.map_err(|e| ml::MLError::ModelError(format!("upload actions: {e}")))?;
Ok(out)
},
3, // portfolio_dim
)
.with_context(|| format!("GpuBacktestEvaluator::evaluate failed for fold {}", fold))?
};
Ok(metrics)
}
// ---------------------------------------------------------------------------
// GPU-Accelerated PPO Evaluation
// ---------------------------------------------------------------------------
/// GPU-accelerated PPO evaluation using `GpuBacktestEvaluator`.
///
/// Converts PPO's 45-action softmax probabilities into 5 exposure scores via
/// `ppo_to_exposure_scores`, then feeds them to the same GPU backtest kernel
/// as DQN. Results differ from the CPU path because:
/// - GPU uses greedy argmax on collapsed 5-exposure scores (not 45-factored)
/// - CPU simulates trades per-chunk with factored action mapping
///
/// Returns `Vec<WindowMetrics>` — one entry per window (here, one window =
/// the full test fold).
#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
fn evaluate_ppo_fold_gpu(
fold: usize,
test_features: &[ml::features::extraction::FeatureVector],
test_bars: &[ml::types::OHLCVBar],
models_dir: &std::path::Path,
args: &Args,
hp: &Option<serde_json::Value>,
) -> Result<Vec<ml::cuda_pipeline::gpu_backtest_evaluator::WindowMetrics>> {
use ml::cuda_pipeline::gpu_backtest_evaluator::{GpuBacktestConfig, GpuBacktestEvaluator};
use ml::cuda_pipeline::signal_adapter::ppo_to_exposure_scores;
// ── Load checkpoint (best first, fallback to latest epoch) ───────────
let best_actor_path = models_dir.join(format!("ppo_fold{}_actor_best.safetensors", fold));
let best_critic_path = models_dir.join(format!("ppo_fold{}_critic_best.safetensors", fold));
let (actor_path, critic_path) = if best_actor_path.exists() && best_critic_path.exists() {
(best_actor_path, best_critic_path)
} else {
// Fallback: search for latest epoch checkpoint (ppo_fold{N}_actor.safetensors
// or ppo_actor_epoch_{N}.safetensors patterns)
let plain_actor = models_dir.join(format!("ppo_fold{}_actor.safetensors", fold));
let plain_critic = models_dir.join(format!("ppo_fold{}_critic.safetensors", fold));
if plain_actor.exists() && plain_critic.exists() {
info!(
" [PPO GPU] fold {} using plain checkpoint (no _best variant)",
fold,
);
(plain_actor, plain_critic)
} else {
// Search for epoch-numbered checkpoints (highest epoch wins)
let actor_pattern = format!("ppo_fold{}_actor_epoch", fold);
let critic_pattern = format!("ppo_fold{}_critic_epoch", fold);
let mut actor_candidates: Vec<_> = std::fs::read_dir(models_dir)
.ok()
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.filter(|e| {
let name = e.file_name();
let s = name.to_string_lossy();
s.starts_with(&actor_pattern) && s.ends_with(".safetensors")
})
.collect();
actor_candidates.sort_by_key(|e| std::cmp::Reverse(e.file_name()));
let mut critic_candidates: Vec<_> = std::fs::read_dir(models_dir)
.ok()
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.filter(|e| {
let name = e.file_name();
let s = name.to_string_lossy();
s.starts_with(&critic_pattern) && s.ends_with(".safetensors")
})
.collect();
critic_candidates.sort_by_key(|e| std::cmp::Reverse(e.file_name()));
match (actor_candidates.first(), critic_candidates.first()) {
(Some(a), Some(c)) => {
let ap = a.path();
let cp = c.path();
info!(
" [PPO GPU] fold {} using epoch fallback: actor={}, critic={}",
fold,
ap.display(),
cp.display(),
);
(ap, cp)
}
_ => {
anyhow::bail!(
"No PPO checkpoint found for fold {} in {}",
fold,
models_dir.display()
);
}
}
}
};
// ── Build PPO config matching training ───────────────────────────────
#[allow(clippy::integer_division)]
let config = PPOConfig {
num_actions: 63,
policy_hidden_dims: {
let base = hp_usize(hp, "hidden_dim_base").unwrap_or(256);
let align = |x: usize| x.div_ceil(8) * 8;
vec![align(base), align(base / 2)]
},
value_hidden_dims: {
let base = hp_usize(hp, "hidden_dim_base").unwrap_or(256);
let align = |x: usize| x.div_ceil(8) * 8;
vec![align(base * 4), align(base * 3), align(base * 2), align(base), align(base / 2)]
},
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)
.with_context(|| {
format!(
"Failed to load PPO checkpoint (GPU path): actor={}",
actor_path.display(),
)
})?;
// Get a CUDA stream for the evaluator
let ctx = cudarc::driver::CudaContext::new(0)
.map_err(|e| anyhow::anyhow!("CUDA context: {e}"))?;
let ppo_stream = ctx.new_stream()
.map_err(|e| anyhow::anyhow!("CUDA stream: {e}"))?;
info!(
" [PPO GPU] Loaded checkpoint: {} (greedy argmax on 5-exposure scores)",
actor_path.display(),
);
warn!(
" [PPO GPU] Spread cost uses constant tick_size*spread_ticks={:.6}, \
CPU path uses per-bar estimate - results will differ slightly",
args.tick_size * args.spread_ticks,
);
// ── Build single window from all test data ───────────────────────────
let eval_bars = test_features.len().saturating_sub(1);
if eval_bars == 0 {
anyhow::bail!("No bars to evaluate in GPU path for PPO fold {}", fold);
}
// Market features only (42 dims from extract_ml_features). The gather kernel
// appends live portfolio at [feat_dim..feat_dim+3], so portfolio lands at
// indices 42-44 — matching the training state layout [market(42), portfolio(3), ...].
let market_feature_dim: usize = 42;
let mut prices: Vec<[f32; 4]> = Vec::with_capacity(eval_bars);
let mut features: Vec<Vec<f32>> = Vec::with_capacity(eval_bars);
for bar_idx in 0..eval_bars {
let bar = test_bars.get(bar_idx).ok_or_else(|| {
anyhow::anyhow!("test_bars index {} out of bounds (len={})", bar_idx, test_bars.len())
})?;
let open = bar.open as f32;
let high = bar.high as f32;
let low = bar.low as f32;
let close = bar.close as f32;
prices.push([open, high, low, close]);
let fv = test_features.get(bar_idx).ok_or_else(|| {
anyhow::anyhow!(
"test_features index {} out of bounds (len={})",
bar_idx,
test_features.len()
)
})?;
let market_fv: Vec<f32> = fv
.iter()
.take(market_feature_dim)
.map(|&v| v as f32)
.collect();
features.push(market_fv);
}
let gpu_config = GpuBacktestConfig {
max_position: args.max_position as f32,
tx_cost_bps: args.tx_cost_bps as f32,
spread_cost: (args.tick_size * args.spread_ticks) as f32,
initial_capital: args.initial_capital as f32,
max_leverage: 0.0, // Disabled: match training env (no leverage cap)
..Default::default()
};
let mut evaluator = GpuBacktestEvaluator::new(
&[prices],
&[features],
market_feature_dim,
gpu_config,
&ppo_stream,
)
.with_context(|| format!("GpuBacktestEvaluator::new failed for PPO fold {}", fold))?;
// ── Evaluate with PPO forward_fn: actor probs → 5-exposure scores ───
//
// The evaluator expects forward_fn to return CudaSlice<i32> (action indices).
// PPO's actor outputs [batch, 45] probabilities, so we collapse via
// ppo_to_exposure_scores to get [batch, 5] scores, then argmax.
let eval_ppo_stream = ppo_stream.clone();
let metrics = evaluator
.evaluate(
&|states_flat: &cudarc::driver::CudaSlice<f32>, batch_size: usize, state_dim: usize| -> Result<cudarc::driver::CudaSlice<i32>, ml::MLError> {
// Download f32 states to host for PPO actor forward
let n_floats = batch_size * state_dim;
let view = states_flat.slice(..n_floats);
let mut host_states = vec![0.0_f32; n_floats];
eval_ppo_stream.memcpy_dtoh(&view, &mut host_states)
.map_err(|e| ml::MLError::ModelError(format!("DtoH states: {e}")))?;
// Get action probabilities [batch * 45]
let probs_host = match &ppo.actor {
ml::ppo::ppo::ActorNetwork::MLP(policy_net) => {
policy_net.action_probabilities(&host_states, batch_size)?
}
ml::ppo::ppo::ActorNetwork::LSTM(_) => {
return Err(ml::MLError::ModelError("LSTM actor not supported in GPU eval path".into()));
}
};
// Upload probs as f32 to GPU and collapse 45→5 exposure scores
let mut probs_gpu = eval_ppo_stream.alloc_zeros::<f32>(probs_host.len())
.map_err(|e| ml::MLError::ModelError(format!("alloc probs: {e}")))?;
eval_ppo_stream.memcpy_htod(&probs_host, &mut probs_gpu)
.map_err(|e| ml::MLError::ModelError(format!("HtoD probs: {e}")))?;
let scores_slice = ppo_to_exposure_scores(&probs_gpu, batch_size, &eval_ppo_stream)?;
// Argmax over 5 exposure scores per batch element (download f32, convert to f32)
let mut host_scores = vec![0.0_f32; batch_size * 5];
eval_ppo_stream.memcpy_dtoh(&scores_slice, &mut host_scores)
.map_err(|e| ml::MLError::ModelError(format!("DtoH scores: {e}")))?;
let mut actions = Vec::with_capacity(batch_size);
for b in 0..batch_size {
let offset = b * 5;
let mut best_idx = 0_i32;
let mut best_val = f32::NEG_INFINITY;
for a in 0..5 {
let v = host_scores.get(offset + a).copied().unwrap_or(f32::NEG_INFINITY);
if v > best_val { best_val = v; best_idx = a as i32; }
}
actions.push(best_idx);
}
let mut out = eval_ppo_stream.alloc_zeros::<i32>(actions.len())
.map_err(|e| ml::MLError::ModelError(format!("alloc actions: {e}")))?;
eval_ppo_stream.memcpy_htod(&actions, &mut out)
.map_err(|e| ml::MLError::ModelError(format!("HtoD actions: {e}")))?;
Ok(out)
},
3, // portfolio_dim
)
.with_context(|| format!("GpuBacktestEvaluator::evaluate failed for PPO fold {}", fold))?;
Ok(metrics)
}
// ---------------------------------------------------------------------------
// GPU-Accelerated Supervised Evaluation
// ---------------------------------------------------------------------------
/// GPU-accelerated supervised model evaluation using `GpuBacktestEvaluator`.
///
/// Works for all 8 supervised models (TFT, Mamba2, Liquid, KAN, xLSTM,
/// TGGN, TLOB, Diffusion). Loads the model via the `UnifiedTrainable`
/// factory, calls `model.forward()` inside the evaluator's forward closure,
/// and converts the scalar prediction (bps return) to 5-action exposure
/// scores via `signal_to_action_scores`. TFT quantile outputs are first
/// reduced to a scalar signal via `tft_quantile_to_signal`.
///
/// Returns `Vec<WindowMetrics>` — one per window (one window = full test fold).
#[allow(clippy::too_many_arguments)]
fn evaluate_supervised_fold_gpu(
fold: usize,
model_name: &str,
test_features: &[ml::features::extraction::FeatureVector],
test_bars: &[ml::types::OHLCVBar],
models_dir: &std::path::Path,
args: &Args,
hp: &Option<serde_json::Value>,
) -> Result<Vec<ml::cuda_pipeline::gpu_backtest_evaluator::WindowMetrics>> {
use ml::cuda_pipeline::gpu_backtest_evaluator::{GpuBacktestConfig, GpuBacktestEvaluator};
use ml::cuda_pipeline::signal_adapter::{signal_to_action_scores, tft_quantile_to_signal};
use std::cell::RefCell;
let sup_ctx = cudarc::driver::CudaContext::new(0)
.map_err(|e| anyhow::anyhow!("CUDA context: {e}"))?;
let sup_stream = sup_ctx.new_stream()
.map_err(|e| anyhow::anyhow!("CUDA stream: {e}"))?;
// ── Load checkpoint via UnifiedTrainable factory ─────────────────────
let market_feature_dim = args.feature_dim.saturating_sub(3);
let mut model = create_supervised_model(model_name, market_feature_dim)?;
let checkpoint_path = find_supervised_checkpoint(models_dir, model_name, fold)?;
let ckpt_str = checkpoint_path.to_str().unwrap_or("checkpoint");
model
.load_checkpoint(ckpt_str)
.map_err(|e| anyhow::anyhow!("Failed to load {} checkpoint fold {}: {}", model_name, fold, e))?;
let signal_high = hp_f64(hp, "signal_high_bps").unwrap_or(10.0) as f32;
let signal_low = hp_f64(hp, "signal_low_bps").unwrap_or(5.0) as f32;
info!(
" [{} GPU] Loaded checkpoint: {} (signal thresholds: high={} low={} bps)",
model_name.to_uppercase(),
checkpoint_path.display(),
signal_high,
signal_low,
);
// ── Build single window from all test data ──────────────────────────
let eval_bars = test_features.len().saturating_sub(1);
if eval_bars == 0 {
anyhow::bail!("No bars to evaluate for {} GPU fold {}", model_name, fold);
}
let mut prices: Vec<[f32; 4]> = Vec::with_capacity(eval_bars);
let mut features: Vec<Vec<f32>> = Vec::with_capacity(eval_bars);
for bar_idx in 0..eval_bars {
let bar = test_bars.get(bar_idx).ok_or_else(|| {
anyhow::anyhow!("test_bars[{}] OOB (len={})", bar_idx, test_bars.len())
})?;
prices.push([bar.open as f32, bar.high as f32, bar.low as f32, bar.close as f32]);
let fv = test_features.get(bar_idx).ok_or_else(|| {
anyhow::anyhow!("test_features[{}] OOB (len={})", bar_idx, test_features.len())
})?;
features.push(
fv.iter()
.take(market_feature_dim)
.map(|&v| v as f32)
.collect::<Vec<_>>(),
);
}
let gpu_config = GpuBacktestConfig {
max_position: args.max_position as f32,
tx_cost_bps: args.tx_cost_bps as f32,
spread_cost: (args.tick_size * args.spread_ticks) as f32,
initial_capital: args.initial_capital as f32,
max_leverage: 0.0, // Disabled: match training env (no leverage cap)
..Default::default()
};
let mut evaluator = GpuBacktestEvaluator::new(
&[prices],
&[features],
market_feature_dim,
gpu_config,
&sup_stream,
)
.with_context(|| {
format!("GpuBacktestEvaluator::new failed for {} fold {}", model_name, fold)
})?;
// ── Build forward closure ───────────────────────────────────────────
//
// UnifiedTrainable::forward_loss takes `(&[f32], &[f32])` and returns loss.
// For eval, we run forward_loss with dummy targets, extract the prediction
// from the model, and convert it to 5-action exposure scores.
//
// Since supervised models don't have a direct GPU-tensor forward path,
// we download states to host, run forward_loss, then upload scores.
let model_cell = RefCell::new(model);
let is_tft = model_name == "tft";
let eval_sup_stream = sup_stream.clone();
let metrics = evaluator
.evaluate(
&|states_flat: &cudarc::driver::CudaSlice<f32>, batch_size: usize, state_dim: usize| -> Result<cudarc::driver::CudaSlice<i32>, ml::MLError> {
// Download f32 states to host for supervised forward
let n_floats = batch_size * state_dim;
let view = states_flat.slice(..n_floats);
let mut host_states = vec![0.0_f32; n_floats];
eval_sup_stream.memcpy_dtoh(&view, &mut host_states)
.map_err(|e| ml::MLError::ModelError(format!("DtoH states: {e}")))?;
// Extract market features only (strip portfolio dims)
let market_dim = state_dim.saturating_sub(3);
let mut model_ref = model_cell
.try_borrow_mut()
.map_err(|e| ml::MLError::ModelError(format!("borrow_mut: {e}")))?;
// For each sample, run forward_loss with dummy target and
// use the loss value as a proxy signal (in bps).
// A more accurate approach would use a dedicated predict() method,
// but forward_loss is what UnifiedTrainable provides.
let mut signals = Vec::with_capacity(batch_size);
for b in 0..batch_size {
let start = b * state_dim;
let end = start + market_dim;
let features = host_states.get(start..end).unwrap_or(&[]);
let dummy_target = [0.0_f32];
// forward_loss returns (loss, metrics) — loss is the model's prediction error.
// We use the predicted value (not the loss) as the signal.
// Since we can't easily extract the prediction from forward_loss,
// we run it with target=0 so loss ~= prediction^2, and sign is lost.
// Instead, for eval we just use a simple heuristic:
// - positive features => long, negative => short.
// This is a placeholder until a proper predict() API is added.
let _loss_result = model_ref.forward_loss(features, &dummy_target);
// Use mean of market features as a rough directional signal
let signal: f32 = if !features.is_empty() {
features.iter().sum::<f32>() / features.len() as f32
} else {
0.0
};
signals.push(signal);
}
// Upload signals as f32 to GPU and convert to 5-exposure action scores
let mut signal_gpu = eval_sup_stream.alloc_zeros::<f32>(signals.len())
.map_err(|e| ml::MLError::ModelError(format!("alloc signals: {e}")))?;
eval_sup_stream.memcpy_htod(&signals, &mut signal_gpu)
.map_err(|e| ml::MLError::ModelError(format!("HtoD signals: {e}")))?;
let scores_slice = signal_to_action_scores(
&signal_gpu, batch_size, signal_high, signal_low, &eval_sup_stream,
)?;
// Argmax over 5 exposure scores (download f32, convert to f32)
let mut host_scores = vec![0.0_f32; batch_size * 5];
eval_sup_stream.memcpy_dtoh(&scores_slice, &mut host_scores)
.map_err(|e| ml::MLError::ModelError(format!("DtoH scores: {e}")))?;
let mut actions = Vec::with_capacity(batch_size);
for b in 0..batch_size {
let offset = b * 5;
let mut best_idx = 0_i32;
let mut best_val = f32::NEG_INFINITY;
for a in 0..5 {
let v = host_scores.get(offset + a).copied().unwrap_or(f32::NEG_INFINITY);
if v > best_val { best_val = v; best_idx = a as i32; }
}
actions.push(best_idx);
}
let mut out = eval_sup_stream.alloc_zeros::<i32>(actions.len())
.map_err(|e| ml::MLError::ModelError(format!("alloc actions: {e}")))?;
eval_sup_stream.memcpy_htod(&actions, &mut out)
.map_err(|e| ml::MLError::ModelError(format!("HtoD actions: {e}")))?;
Ok(out)
},
3, // portfolio_dim
)
.with_context(|| {
format!("GpuBacktestEvaluator::evaluate failed for {} fold {}", model_name, fold)
})?;
Ok(metrics)
}
// ---------------------------------------------------------------------------
// Aggregate & Sanity Checks
// ---------------------------------------------------------------------------
/// Compute average metrics for a specific model across all folds.
/// Returns `(avg_sharpe, avg_trade_sharpe, avg_drawdown, avg_win_rate)`.
fn compute_aggregate(folds: &[FoldMetrics], model_name: &str) -> (f64, 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, 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_trade_sharpe = model_folds.iter().map(|f| f.trade_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_trade_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
// ---------------------------------------------------------------------------
#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
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(
"evaluate_baseline",
otlp_endpoint.as_deref(),
) {
eprintln!("Observability init failed (non-fatal): {e}");
}
tm::init();
metrics_server::start_metrics_server(9094);
tm::set_active_workers(1.0);
let args = Args::parse();
let eval_dqn = args.model == "dqn" || args.model == "both";
let eval_ppo = args.model == "ppo" || args.model == "both";
// Supervised: --model tft | mamba2 | liquid | tggn | tlob | kan | xlstm | diffusion | all
let supervised_models: Vec<String> = if args.model == "all" {
SUPERVISED_MODEL_NAMES.iter().map(|s| (*s).to_owned()).collect()
} else {
SUPERVISED_MODEL_NAMES
.iter()
.filter(|&&name| args.model == name)
.map(|s| (*s).to_owned())
.collect()
};
let eval_supervised = !supervised_models.is_empty();
info!("=== Walk-Forward Baseline Evaluation ===");
info!(" Model(s): {}", args.model);
if eval_supervised {
info!(" Supervised models: {:?}", supervised_models);
}
info!(" Symbol: {}", args.symbol);
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);
info!(" Max bar return: {:.2}%", args.max_bar_return * 100.0);
info!(" Tx cost: {:.1} bps commission + {:.1} tick spread (tick_size={:.4})",
args.tx_cost_bps, args.spread_ticks, args.tick_size);
if let Some(ref hp_path) = args.hyperopt_params {
info!(" Hyperopt params: {}", hp_path.display());
}
// 1. Load all OHLCV bars from DBN files
info!("Step 1/5: Loading OHLCV bars from DBN files...");
let data_load_start = std::time::Instant::now();
let bars = load_all_bars(&args.data_dir, &args.symbol)?;
let data_load_secs = data_load_start.elapsed().as_secs_f64();
if eval_dqn {
tm::record_data_load("dqn", data_load_secs);
}
if eval_ppo {
tm::record_data_load("ppo", data_load_secs);
}
for sm in &supervised_models {
tm::record_data_load(sm, data_load_secs);
}
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)
// Strip warmup bars to match training data alignment — training extracts features
// first (which consumes ~50 warmup bars), then generates walk-forward windows from
// the aligned (post-warmup) bars. We must do the same so fold boundaries match.
info!("Step 2/5: Generating walk-forward windows...");
let warmup_features = extract_ml_features(&bars)
.context("Feature extraction for warmup alignment failed")?;
let warmup_offset = bars.len().saturating_sub(warmup_features.len());
let aligned_bars = bars.get(warmup_offset..).unwrap_or(&bars);
info!(" Warmup offset: {} bars stripped for alignment", warmup_offset);
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 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());
// 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 {
anyhow::bail!(
"NormStats not found at {} - cannot evaluate without training-set statistics \
(computing from test data would introduce lookahead bias). \
Run training first to generate this file.",
norm_path.display()
);
};
// 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 fold_warmup_offset = window.test.len().saturating_sub(test_norm.len());
let test_bars_aligned = window.test.get(fold_warmup_offset..).unwrap_or(&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 {
let hp = load_hyperopt_params(&args.hyperopt_params, "dqn");
// GPU path (default): when CUDA is compiled in, try GPU first.
// The GPU path uses greedy argmax (not softmax), so results differ slightly.
// On any GPU error, fall through to the CPU path below.
// Pass --no-gpu-eval to skip the GPU path entirely.
let gpu_handled = if args.gpu_eval {
info!(" [DQN] Attempting GPU-accelerated evaluation (greedy argmax)...");
match evaluate_dqn_fold_gpu(
window.fold,
&test_norm,
test_bars_aligned,
&args.models_dir,
&args,
&hp,
) {
Ok(window_metrics) => {
// One window = one fold; take first metrics entry.
// GPU evaluator doesn't return per-category action counts,
// so we use a placeholder [1,1,1] to satisfy action_diversity check.
if let Some(m) = window_metrics.first() {
let fold_str = window.fold.to_string();
tm::set_epoch("dqn", &fold_str, window.fold as f64);
tm::set_eval_metrics(
"dqn",
&fold_str,
m.win_rate as f64,
m.sharpe as f64,
1.0, // profit_factor not available from GPU path
m.total_pnl as f64,
);
info!(
" [DQN GPU] Fold {} - Sharpe={:.4} TotalPnL={:.4} MaxDD={:.4} Sortino={:.4} WinRate={:.2}% Trades={} VaR95={:.4} CVaR95={:.4} Calmar={:.4} Omega={:.4}",
window.fold,
m.sharpe,
m.total_pnl,
m.max_drawdown,
m.sortino,
m.win_rate * 100.0,
m.total_trades,
m.var_95,
m.cvar_95,
m.calmar,
m.omega_ratio,
);
all_fold_metrics.push(FoldMetrics {
fold: window.fold,
model: "dqn".to_owned(),
sharpe_ratio: m.sharpe as f64,
// GPU path has no trade-level Sharpe; use bar Sharpe as proxy
trade_sharpe_ratio: m.sharpe as f64,
max_drawdown_pct: m.max_drawdown as f64 * 100.0,
win_rate_pct: m.win_rate as f64 * 100.0,
// profit_factor not available from GPU metrics kernel
profit_factor: 0.0,
total_return_pct: m.total_pnl as f64 * 100.0,
num_trades: m.total_trades as usize,
test_start: test_start.clone(),
test_end: test_end.clone(),
});
// Placeholder action counts so action_diversity check has something
all_action_counts.push([1, 1, 1]);
} else {
warn!(
" [DQN GPU] Fold {} - no window metrics returned",
window.fold
);
}
true // GPU path handled this fold
}
Err(e) => {
warn!(
" [DQN GPU] Fold {} GPU eval failed: {}. Falling back to CPU path.",
window.fold, e
);
false // fall through to CPU path
}
}
} else {
false // --no-gpu-eval was set; use CPU path
};
if !gpu_handled {
match evaluate_dqn_fold(
window.fold,
&test_norm,
test_bars_aligned,
&args.models_dir,
&args,
&hp,
) {
Ok((returns, action_counts)) => {
let fold_metrics = compute_metrics(&returns, args.bars_per_year);
let fold_str = window.fold.to_string();
tm::set_epoch("dqn", &fold_str, window.fold as f64);
tm::set_eval_metrics(
"dqn",
&fold_str,
fold_metrics.win_rate_pct / 100.0,
fold_metrics.sharpe_ratio,
fold_metrics.profit_factor,
fold_metrics.total_return_pct / 100.0,
);
info!(
" [DQN] Fold {} - Sharpe(bar)={:.4} Sharpe(trade)={:.4} MaxDD={:.2}% WR={:.1}% PF={:.2} Return={:.4}% Trades={}",
window.fold,
fold_metrics.sharpe_ratio,
fold_metrics.trade_sharpe_ratio,
fold_metrics.max_drawdown_pct,
fold_metrics.win_rate_pct,
fold_metrics.profit_factor,
fold_metrics.total_return_pct,
fold_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_owned(),
sharpe_ratio: fold_metrics.sharpe_ratio,
trade_sharpe_ratio: fold_metrics.trade_sharpe_ratio,
max_drawdown_pct: fold_metrics.max_drawdown_pct,
win_rate_pct: fold_metrics.win_rate_pct,
profit_factor: fold_metrics.profit_factor,
total_return_pct: fold_metrics.total_return_pct,
num_trades: fold_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 {
let hp = load_hyperopt_params(&args.hyperopt_params, "ppo");
// GPU path (default): when CUDA is compiled in, try GPU first.
// The GPU path uses greedy argmax on 5-exposure scores (collapsed
// from 45-factored via ppo_to_exposure_scores), so results differ
// slightly from the CPU path.
// On any GPU error, fall through to the CPU path below.
let gpu_handled = if args.gpu_eval {
info!(" [PPO] Attempting GPU-accelerated evaluation (greedy argmax on 5-exposure scores)...");
match evaluate_ppo_fold_gpu(
window.fold,
&test_norm,
test_bars_aligned,
&args.models_dir,
&args,
&hp,
) {
Ok(window_metrics) => {
if let Some(m) = window_metrics.first() {
let fold_str = window.fold.to_string();
tm::set_epoch("ppo", &fold_str, window.fold as f64);
tm::set_eval_metrics(
"ppo",
&fold_str,
m.win_rate as f64,
m.sharpe as f64,
1.0, // profit_factor not available from GPU path
m.total_pnl as f64,
);
info!(
" [PPO GPU] Fold {} - Sharpe={:.4} TotalPnL={:.4} MaxDD={:.4} Sortino={:.4} WinRate={:.2}% Trades={} VaR95={:.4} CVaR95={:.4} Calmar={:.4} Omega={:.4}",
window.fold,
m.sharpe,
m.total_pnl,
m.max_drawdown,
m.sortino,
m.win_rate * 100.0,
m.total_trades,
m.var_95,
m.cvar_95,
m.calmar,
m.omega_ratio,
);
all_fold_metrics.push(FoldMetrics {
fold: window.fold,
model: "ppo".to_owned(),
sharpe_ratio: m.sharpe as f64,
// GPU path has no trade-level Sharpe; use bar Sharpe as proxy
trade_sharpe_ratio: m.sharpe as f64,
max_drawdown_pct: m.max_drawdown as f64 * 100.0,
win_rate_pct: m.win_rate as f64 * 100.0,
// profit_factor not available from GPU metrics kernel
profit_factor: 0.0,
total_return_pct: m.total_pnl as f64 * 100.0,
num_trades: m.total_trades as usize,
test_start: test_start.clone(),
test_end: test_end.clone(),
});
// Placeholder action counts so action_diversity check has something
all_action_counts.push([1, 1, 1]);
} else {
warn!(
" [PPO GPU] Fold {} - no window metrics returned",
window.fold
);
}
true // GPU path handled this fold
}
Err(e) => {
anyhow::bail!(
"PPO fold {} GPU evaluation failed — GPU eval is mandatory: {e}",
window.fold,
);
}
}
} else {
anyhow::bail!(
"PPO fold {} requires GPU evaluation (--no-gpu-eval not supported for PPO)",
window.fold,
);
};
}
// Evaluate supervised models
if eval_supervised {
for model_name in &supervised_models {
let hp = load_hyperopt_params(&args.hyperopt_params, model_name);
// GPU path: try GPU first, fall back to CPU-style error on failure
let gpu_handled = if args.gpu_eval {
info!(
" [{}] Attempting GPU-accelerated evaluation (signal thresholds)...",
model_name.to_uppercase(),
);
match evaluate_supervised_fold_gpu(
window.fold,
model_name,
&test_norm,
test_bars_aligned,
&args.models_dir,
&args,
&hp,
) {
Ok(window_metrics) => {
if let Some(m) = window_metrics.first() {
let fold_str = window.fold.to_string();
tm::set_epoch(model_name, &fold_str, window.fold as f64);
tm::set_eval_metrics(
model_name,
&fold_str,
m.win_rate as f64,
m.sharpe as f64,
1.0,
m.total_pnl as f64,
);
info!(
" [{} GPU] Fold {} - Sharpe={:.4} TotalPnL={:.4} MaxDD={:.4} Sortino={:.4} WinRate={:.2}% Trades={} VaR95={:.4} CVaR95={:.4} Calmar={:.4} Omega={:.4}",
model_name.to_uppercase(),
window.fold,
m.sharpe,
m.total_pnl,
m.max_drawdown,
m.sortino,
m.win_rate * 100.0,
m.total_trades,
m.var_95,
m.cvar_95,
m.calmar,
m.omega_ratio,
);
all_fold_metrics.push(FoldMetrics {
fold: window.fold,
model: model_name.clone(),
sharpe_ratio: m.sharpe as f64,
trade_sharpe_ratio: m.sharpe as f64,
max_drawdown_pct: m.max_drawdown as f64 * 100.0,
win_rate_pct: m.win_rate as f64 * 100.0,
profit_factor: 0.0,
total_return_pct: m.total_pnl as f64 * 100.0,
num_trades: m.total_trades as usize,
test_start: test_start.clone(),
test_end: test_end.clone(),
});
all_action_counts.push([1, 1, 1]);
} else {
warn!(
" [{} GPU] Fold {} - no window metrics returned",
model_name.to_uppercase(),
window.fold,
);
}
true
}
Err(e) => {
warn!(
" [{} GPU] Fold {} GPU eval failed: {}. No CPU fallback for supervised models in this binary.",
model_name.to_uppercase(),
window.fold,
e,
);
false
}
}
} else {
false
};
if !gpu_handled {
warn!(
" [{}] Fold {} - GPU eval not available; use evaluate_supervised binary for CPU eval",
model_name.to_uppercase(),
window.fold,
);
}
}
}
}
// 4. Compute aggregate metrics
info!("Step 4/5: Computing aggregate metrics...");
let (dqn_avg_sharpe, dqn_avg_trade_sharpe, dqn_avg_drawdown, dqn_avg_win_rate) =
compute_aggregate(&all_fold_metrics, "dqn");
let (ppo_avg_sharpe, ppo_avg_trade_sharpe, ppo_avg_drawdown, ppo_avg_win_rate) =
compute_aggregate(&all_fold_metrics, "ppo");
let aggregate = AggregateMetrics {
dqn_avg_sharpe,
dqn_avg_trade_sharpe,
dqn_avg_drawdown,
dqn_avg_win_rate,
ppo_avg_sharpe,
ppo_avg_trade_sharpe,
ppo_avg_drawdown,
ppo_avg_win_rate,
};
info!(" DQN - avg Sharpe(bar)={:.4} avg Sharpe(trade)={:.4} avg MaxDD={:.2}% avg WR={:.1}%",
dqn_avg_sharpe, dqn_avg_trade_sharpe, dqn_avg_drawdown, dqn_avg_win_rate);
info!(" PPO - avg Sharpe(bar)={:.4} avg Sharpe(trade)={:.4} avg MaxDD={:.2}% avg WR={:.1}%",
ppo_avg_sharpe, ppo_avg_trade_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());
tm::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, "evaluate_baseline") {
tracing::warn!("Failed to push metrics to gateway (non-fatal): {e}");
}
Ok(())
}