feat(ml): add evaluate_supervised binary + PPO eval in CI

New evaluate_supervised binary runs walk-forward inference on supervised
model checkpoints (TFT, Mamba2, etc.), converts directional predictions
to trading signals, and computes Sharpe/MaxDD/WinRate/DirAccuracy.

CI changes:
- train-validate-dqn → train-validate-rl (trains+evals DQN+PPO)
- train-validate-tft now runs evaluate_supervised after training
- web/api fallback rules added to train-validate and deploy stages
- evaluate_supervised added to compile-services and Docker images

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-02-26 21:31:45 +01:00
parent fc4f55999b
commit 0c1fe12645
4 changed files with 841 additions and 11 deletions

View File

@@ -284,6 +284,7 @@ compile-services:
--example train_baseline_rl
--example train_baseline_supervised
--example evaluate_baseline
--example evaluate_supervised
--example hyperopt_baseline_rl
--example hyperopt_baseline_supervised
# 3) Build training uploader sidecar (no CUDA needed)
@@ -295,7 +296,7 @@ compile-services:
cp target/release/$bin build-out/
strip build-out/$bin
done
for bin in train_baseline_rl train_baseline_supervised evaluate_baseline hyperopt_baseline_rl hyperopt_baseline_supervised; do
for bin in train_baseline_rl train_baseline_supervised evaluate_baseline evaluate_supervised hyperopt_baseline_rl hyperopt_baseline_supervised; do
cp target/release/examples/$bin build-out/
strip build-out/$bin
done
@@ -451,33 +452,36 @@ build-training:
when: manual
allow_failure: true
- if: $CI_PIPELINE_SOURCE == "schedule" && $TRAIN_VALIDATE == "true"
- if: $CI_PIPELINE_SOURCE == "web" || $CI_PIPELINE_SOURCE == "api"
when: manual
allow_failure: true
before_script:
- export LD_LIBRARY_PATH=$(echo "$LD_LIBRARY_PATH" | tr ':' '\n' | grep -v stubs | tr '\n' ':' | sed 's/:$//')
- nvidia-smi
- mkdir -p ${CI_PROJECT_DIR}/output
train-validate-dqn:
train-validate-rl:
extends: .train-validate-base
needs: [build-training]
script:
- /usr/local/bin/train_baseline_rl
--model dqn
--model both
--symbol ES.FUT
--data-dir /mnt/training-data/futures-baseline
--output-dir ${CI_PROJECT_DIR}/output
--max-steps-per-epoch 50
- echo "=== DQN training complete, running evaluation ==="
- echo "=== RL training complete, running evaluation ==="
- /usr/local/bin/evaluate_baseline
--model dqn
--model both
--models-dir ${CI_PROJECT_DIR}/output
--data-dir /mnt/training-data/futures-baseline
--output ${CI_PROJECT_DIR}/output/dqn_eval_report.json
--output ${CI_PROJECT_DIR}/output/rl_eval_report.json
--symbol ES.FUT
- echo "DQN validation + evaluation passed"
- cat ${CI_PROJECT_DIR}/output/dqn_eval_report.json
- echo "RL validation + evaluation passed"
- cat ${CI_PROJECT_DIR}/output/rl_eval_report.json
artifacts:
paths:
- output/dqn_eval_report.json
- output/rl_eval_report.json
when: always
expire_in: 30 days
@@ -491,7 +495,20 @@ train-validate-tft:
--data-dir /mnt/training-data/futures-baseline
--output-dir ${CI_PROJECT_DIR}/output
--max-steps-per-epoch 50
- echo "TFT validation passed"
- echo "=== TFT training complete, running evaluation ==="
- /usr/local/bin/evaluate_supervised
--model tft
--models-dir ${CI_PROJECT_DIR}/output
--data-dir /mnt/training-data/futures-baseline
--output ${CI_PROJECT_DIR}/output/tft_eval_report.json
--symbol ES.FUT
- echo "TFT validation + evaluation passed"
- cat ${CI_PROJECT_DIR}/output/tft_eval_report.json
artifacts:
paths:
- output/tft_eval_report.json
when: always
expire_in: 30 days
# --------------------------------------------------------------------------
# IaC: Terragrunt plan on MR (runs on gitlab pool)
@@ -606,6 +623,7 @@ deploy:
- build-training
rules:
- if: $CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"
- if: $CI_PIPELINE_SOURCE == "web" || $CI_PIPELINE_SOURCE == "api"
before_script:
# Install kubectl if not in image yet (removed once infra-runner is rebuilt)
- which kubectl || (curl -fsSL "https://dl.k8s.io/release/v1.31.4/bin/linux/amd64/kubectl" -o /usr/local/bin/kubectl && chmod +x /usr/local/bin/kubectl)

View File

@@ -0,0 +1,809 @@
//! Walk-forward evaluation binary for supervised baseline models.
//!
//! Loads trained model checkpoints, runs inference on walk-forward test data,
//! converts directional predictions to trading signals, computes financial
//! metrics (Sharpe, drawdown, win rate, profit factor), and generates a JSON
//! report.
//!
//! # Usage
//!
//! ```bash
//! SQLX_OFFLINE=true cargo run -p ml --example evaluate_supervised -- \
//! --model tft --models-dir ml/trained_models \
//! --data-dir test_data/futures-baseline \
//! --output ml/trained_models/supervised_eval_report.json
//! ```
#![allow(unused_crate_dependencies)]
#![deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
use std::path::PathBuf;
use anyhow::{Context, Result};
use candle_core::{Device, Tensor};
use clap::Parser;
use serde::Serialize;
use tracing::{error, info, warn};
use ml::features::extraction::extract_ml_features;
use ml::training::unified_trainer::UnifiedTrainable;
use ml::types::OHLCVBar;
use ml::walk_forward::{generate_walk_forward_windows, NormStats, WalkForwardConfig};
#[allow(unreachable_pub)]
mod baseline_common;
use baseline_common::{load_all_bars, spread_cost_bps};
// Model adapter imports (same as train_baseline_supervised)
use ml::diffusion::{DiffusionConfig, DiffusionTrainableAdapter};
use ml::kan::{KANConfig, KANTrainableAdapter};
use ml::liquid::{CfCTrainConfig, DeviceConfig, LiquidTrainableAdapter};
use ml::mamba::{Mamba2Config, Mamba2SSM};
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};
// ---------------------------------------------------------------------------
// CLI Arguments
// ---------------------------------------------------------------------------
/// Walk-forward evaluation binary for supervised baseline models.
#[derive(Parser, Debug)]
#[command(
name = "evaluate_supervised",
about = "Evaluate trained supervised model checkpoints with walk-forward test data"
)]
struct Args {
/// Directory containing trained model checkpoints
#[arg(long, default_value = "ml/trained_models")]
models_dir: PathBuf,
/// Path to directory containing .dbn.zst files
#[arg(long, default_value = "test_data/futures-baseline")]
data_dir: PathBuf,
/// Output path for evaluation report JSON
#[arg(long, default_value = "ml/trained_models/supervised_eval_report.json")]
output: PathBuf,
/// Which model to evaluate: tft, mamba2, liquid, tggn, tlob, kan, xlstm, diffusion
#[arg(long)]
model: String,
/// Feature dimension (must match extract_ml_features output)
#[arg(long, default_value_t = 51)]
feature_dim: 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%)
#[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)
#[arg(long, default_value_t = 1.0)]
spread_ticks: f64,
/// Minimum prediction magnitude (in bps) to trigger a trade; below this → HOLD
#[arg(long, default_value_t = 0.5)]
signal_threshold_bps: f64,
}
// ---------------------------------------------------------------------------
// Report Data Types
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize)]
struct FoldMetrics {
fold: usize,
model: String,
sharpe_ratio: f64,
max_drawdown_pct: f64,
win_rate_pct: f64,
profit_factor: f64,
total_return_pct: f64,
num_trades: usize,
directional_accuracy_pct: f64,
test_start: String,
test_end: String,
}
#[derive(Debug, Serialize)]
struct AggregateMetrics {
avg_sharpe: f64,
avg_drawdown: f64,
avg_win_rate: f64,
avg_directional_accuracy: f64,
avg_profit_factor: f64,
}
#[derive(Debug, Serialize)]
struct SanityChecks {
beats_random: bool,
action_diversity: bool,
fold_consistency: bool,
}
#[derive(Debug, Serialize)]
struct EvaluationReport {
model: String,
folds: Vec<FoldMetrics>,
aggregate: AggregateMetrics,
sanity_checks: SanityChecks,
}
// ---------------------------------------------------------------------------
// Financial Metrics (same as evaluate_baseline)
// ---------------------------------------------------------------------------
struct ComputedMetrics {
sharpe_ratio: f64,
max_drawdown_pct: f64,
win_rate_pct: f64,
profit_factor: f64,
total_return_pct: f64,
num_trades: usize,
}
fn compute_metrics(returns: &[f64]) -> ComputedMetrics {
let n = returns.len();
if n == 0 {
return ComputedMetrics {
sharpe_ratio: 0.0,
max_drawdown_pct: 0.0,
win_rate_pct: 0.0,
profit_factor: 0.0,
total_return_pct: 0.0,
num_trades: 0,
};
}
let num_trades = returns.iter().filter(|&&r| r.abs() > 1e-12).count();
let sum: f64 = returns.iter().sum();
let mean = sum / n as f64;
let variance: f64 = returns.iter().map(|&r| (r - mean).powi(2)).sum::<f64>() / n as f64;
let std = variance.sqrt();
// Annualized Sharpe: 1-min bars, ~1380 bars/day × 252 days
let bars_per_year: f64 = 252.0 * 1380.0;
let sharpe_ratio = if std > 1e-12 {
(mean / std) * bars_per_year.sqrt()
} else {
0.0
};
let mut equity = 1.0_f64;
let mut peak = 1.0_f64;
let mut max_drawdown = 0.0_f64;
for &ret in returns {
equity += ret;
if equity > peak {
peak = equity;
}
let drawdown = if peak > 1e-12 {
(peak - equity) / peak
} else {
0.0
};
if drawdown > max_drawdown {
max_drawdown = drawdown;
}
}
let 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
};
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
};
ComputedMetrics {
sharpe_ratio,
max_drawdown_pct: max_drawdown * 100.0,
win_rate_pct,
profit_factor,
total_return_pct: sum * 100.0,
num_trades,
}
}
// ---------------------------------------------------------------------------
// Model Factory (same defaults as train_baseline_supervised)
// ---------------------------------------------------------------------------
fn create_model(
name: &str,
feature_dim: usize,
device: &Device,
) -> Result<Box<dyn UnifiedTrainable>> {
let lr = 1e-3; // Default; doesn't matter for eval (no training)
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 mut adapter = Mamba2SSM::new(config, device)
.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, device)
.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, device)
.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, device)
.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, device)
.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 adapter = DiffusionTrainableAdapter::new(config, device.clone())
.map_err(|e| anyhow::anyhow!("Failed to create Diffusion: {}", e))?;
Ok(Box::new(adapter))
}
_ => anyhow::bail!("Unknown model: {}", name),
}
}
// ---------------------------------------------------------------------------
// Supervised Evaluation
// ---------------------------------------------------------------------------
/// Run supervised inference on test features and return per-bar trade returns,
/// action counts, and directional accuracy.
fn evaluate_fold(
fold: usize,
model_name: &str,
test_features: &[[f64; 51]],
test_bars: &[OHLCVBar],
args: &Args,
device: &Device,
) -> Result<(Vec<f64>, [usize; 3], f64)> {
let ckpt_path = args
.models_dir
.join(format!("{}_fold{}_best", model_name, fold));
// Check if checkpoint metadata exists
let meta_path = format!("{}.json", ckpt_path.display());
if !std::path::Path::new(&meta_path).exists() {
anyhow::bail!(
"Checkpoint not found: {} (looked for {})",
ckpt_path.display(),
meta_path
);
}
// Create model with same config as training, then load checkpoint
let mut model = create_model(model_name, args.feature_dim, device)?;
let ckpt_str = ckpt_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))?;
info!(
" [{}] Loaded checkpoint: {}",
model_name.to_uppercase(),
ckpt_path.display()
);
let n = test_features.len();
let mut returns = Vec::with_capacity(n);
let mut action_counts = [0_usize; 3]; // [buy, sell, hold]
let mut correct_direction = 0_usize;
let mut total_predictions = 0_usize;
let threshold = args.signal_threshold_bps;
for i in 0..n.saturating_sub(1) {
let Some(feat) = test_features.get(i) else {
continue;
};
// Create input tensor [1, feature_dim]
let input_f32: Vec<f32> = feat.iter().map(|&v| v as f32).collect();
let input_tensor = match Tensor::from_vec(input_f32, &[1, args.feature_dim], device) {
Ok(t) => t,
Err(e) => {
warn!(" [{}] tensor creation error at step {}: {}", model_name, i, e);
continue;
}
};
// Run forward pass to get prediction (expected shape [1, 1], value in bps)
let prediction = match model.forward(&input_tensor) {
Ok(p) => p,
Err(e) => {
warn!(" [{}] forward error at step {}: {}", model_name, i, e);
continue;
}
};
// Extract scalar prediction
let pred_value = match prediction
.to_dtype(candle_core::DType::F64)
.and_then(|t| t.flatten_all())
.and_then(|t| t.to_scalar::<f64>())
{
Ok(v) => v,
Err(e) => {
warn!(" [{}] scalar extraction error at step {}: {}", model_name, i, e);
continue;
}
};
// Convert prediction to action: positive → buy, negative → sell, small → hold
let action: u8 = if pred_value > threshold {
0 // BUY
} else if pred_value < -threshold {
1 // SELL
} else {
2 // HOLD
};
if let Some(count) = action_counts.get_mut(action as usize) {
*count += 1;
}
// Compute actual return
let close_cur = test_bars.get(i).map(|b| b.close).unwrap_or(0.0);
let close_next = test_bars.get(i + 1).map(|b| b.close).unwrap_or(close_cur);
let 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
};
// Directional accuracy: did the sign of prediction match the sign of actual return?
let actual_bps = pct_change * 10_000.0;
if actual_bps.abs() > 1e-6 {
total_predictions += 1;
if (pred_value > 0.0 && actual_bps > 0.0) || (pred_value < 0.0 && actual_bps < 0.0) {
correct_direction += 1;
}
}
// Apply transaction costs
let total_cost_bps =
args.tx_cost_bps + spread_cost_bps(close_cur, args.tick_size, args.spread_ticks);
let total_cost = total_cost_bps * 0.0001;
let ret = match action {
0 => pct_change - total_cost, // BUY
1 => -pct_change - total_cost, // SELL
_ => 0.0, // HOLD
};
returns.push(ret);
}
let directional_accuracy = if total_predictions > 0 {
(correct_direction as f64 / total_predictions as f64) * 100.0
} else {
0.0
};
Ok((returns, action_counts, directional_accuracy))
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let args = Args::parse();
info!("=== Walk-Forward Supervised Evaluation ===");
info!(" Model: {}", args.model);
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!(" Signal threshold: {:.1} bps", args.signal_threshold_bps);
info!(
" Tx cost: {:.1} bps commission + {:.1} tick spread (tick_size={:.4})",
args.tx_cost_bps, args.spread_ticks, args.tick_size
);
let device = Device::Cpu;
// 1. Load all OHLCV bars
info!("Step 1/4: 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(),
);
// 2. Generate walk-forward windows
info!("Step 2/4: Generating walk-forward windows...");
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(&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/4: Evaluating {} on test data...", args.model);
let mut all_fold_metrics: Vec<FoldMetrics> = Vec::new();
let mut all_action_counts: Vec<[usize; 3]> = Vec::new();
for window in &windows {
info!(
"--- Fold {} --- Test: {} bars ({} to {})",
window.fold,
window.test.len(),
window.test
.first()
.map(|b| b.timestamp.to_string())
.unwrap_or_default(),
window.test
.last()
.map(|b| b.timestamp.to_string())
.unwrap_or_default(),
);
// Load NormStats from training
let norm_path = args
.models_dir
.join(format!("norm_stats_fold{}.json", window.fold));
let norm_stats: NormStats = if norm_path.exists() {
let norm_json = std::fs::read_to_string(&norm_path)
.with_context(|| format!("Failed to read {}", norm_path.display()))?;
serde_json::from_str(&norm_json)
.with_context(|| format!("Failed to parse {}", norm_path.display()))?
} else {
warn!(
" NormStats not found at {}, computing from test data (degraded)",
norm_path.display()
);
let test_feat = extract_ml_features(&window.test)
.context("Feature extraction failed for test bars")?;
NormStats::from_features(&test_feat)
};
// Extract and normalize test features
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;
}
let test_norm = norm_stats.normalize_batch(&test_features);
// Align bars to features
let warmup_offset = window.test.len().saturating_sub(test_norm.len());
let test_bars_aligned = if warmup_offset < window.test.len() {
&window.test[warmup_offset..]
} else {
&window.test
};
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();
match evaluate_fold(
window.fold,
&args.model,
&test_norm,
test_bars_aligned,
&args,
&device,
) {
Ok((returns, action_counts, directional_accuracy)) => {
let metrics = compute_metrics(&returns);
info!(
" [{}] Fold {} — Sharpe={:.4} MaxDD={:.2}% WinRate={:.1}% PF={:.2} Return={:.4}% Trades={} DirAcc={:.1}%",
args.model.to_uppercase(),
window.fold,
metrics.sharpe_ratio,
metrics.max_drawdown_pct,
metrics.win_rate_pct,
metrics.profit_factor,
metrics.total_return_pct,
metrics.num_trades,
directional_accuracy,
);
info!(
" [{}] Actions — BUY={} SELL={} HOLD={}",
args.model.to_uppercase(),
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: args.model.clone(),
sharpe_ratio: metrics.sharpe_ratio,
max_drawdown_pct: metrics.max_drawdown_pct,
win_rate_pct: metrics.win_rate_pct,
profit_factor: metrics.profit_factor,
total_return_pct: metrics.total_return_pct,
num_trades: metrics.num_trades,
directional_accuracy_pct: directional_accuracy,
test_start: test_start.clone(),
test_end: test_end.clone(),
});
all_action_counts.push(action_counts);
}
Err(e) => {
error!(
" [{}] Fold {} evaluation failed: {}",
args.model.to_uppercase(),
window.fold,
e
);
}
}
}
// 4. Compute aggregate and write report
info!("Step 4/4: Computing aggregate metrics...");
let n_folds = all_fold_metrics.len() as f64;
let aggregate = if n_folds > 0.0 {
AggregateMetrics {
avg_sharpe: all_fold_metrics.iter().map(|f| f.sharpe_ratio).sum::<f64>() / n_folds,
avg_drawdown: all_fold_metrics
.iter()
.map(|f| f.max_drawdown_pct)
.sum::<f64>()
/ n_folds,
avg_win_rate: all_fold_metrics.iter().map(|f| f.win_rate_pct).sum::<f64>() / n_folds,
avg_directional_accuracy: all_fold_metrics
.iter()
.map(|f| f.directional_accuracy_pct)
.sum::<f64>()
/ n_folds,
avg_profit_factor: all_fold_metrics
.iter()
.map(|f| if f.profit_factor.is_finite() { f.profit_factor } else { 0.0 })
.sum::<f64>()
/ n_folds,
}
} else {
AggregateMetrics {
avg_sharpe: 0.0,
avg_drawdown: 0.0,
avg_win_rate: 0.0,
avg_directional_accuracy: 0.0,
avg_profit_factor: 0.0,
}
};
info!(
" {} — avg Sharpe={:.4} avg MaxDD={:.2}% avg WinRate={:.1}% avg DirAcc={:.1}%",
args.model.to_uppercase(),
aggregate.avg_sharpe,
aggregate.avg_drawdown,
aggregate.avg_win_rate,
aggregate.avg_directional_accuracy,
);
// Sanity checks
let beats_random = all_fold_metrics.iter().any(|f| f.sharpe_ratio > 0.0);
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);
let sharpe_values: Vec<f64> = all_fold_metrics.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()
};
let report = EvaluationReport {
model: args.model.clone(),
folds: all_fold_metrics,
aggregate,
sanity_checks: SanityChecks {
beats_random,
action_diversity,
fold_consistency,
},
};
// Save report
if let Some(parent) = args.output.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create output dir: {}", parent.display()))?;
}
let report_json = serde_json::to_string_pretty(&report)
.context("Failed to serialize evaluation report")?;
std::fs::write(&args.output, &report_json)
.with_context(|| format!("Failed to write report to {}", args.output.display()))?;
info!("=== Evaluation Complete ===");
info!(" Report saved to: {}", args.output.display());
info!(" Total fold evaluations: {}", report.folds.len());
Ok(())
}

View File

@@ -64,10 +64,11 @@ RUN cargo build --release -p ml --features ml/cuda \
--example train_baseline_rl \
--example train_baseline_supervised \
--example evaluate_baseline \
--example evaluate_supervised \
--example hyperopt_baseline_rl \
--example hyperopt_baseline_supervised \
&& mkdir -p /build/out \
&& for bin in train_baseline_rl train_baseline_supervised evaluate_baseline hyperopt_baseline_rl hyperopt_baseline_supervised; do \
&& for bin in train_baseline_rl train_baseline_supervised evaluate_baseline evaluate_supervised hyperopt_baseline_rl hyperopt_baseline_supervised; do \
cp target/release/examples/${bin} /build/out/ && strip /build/out/${bin}; \
done

View File

@@ -25,6 +25,7 @@ RUN groupadd -g 1000 foxhunt \
COPY train_baseline_rl \
train_baseline_supervised \
evaluate_baseline \
evaluate_supervised \
hyperopt_baseline_rl \
hyperopt_baseline_supervised \
training_uploader \
@@ -33,6 +34,7 @@ COPY train_baseline_rl \
RUN chmod +x /usr/local/bin/train_baseline_rl \
/usr/local/bin/train_baseline_supervised \
/usr/local/bin/evaluate_baseline \
/usr/local/bin/evaluate_supervised \
/usr/local/bin/hyperopt_baseline_rl \
/usr/local/bin/hyperopt_baseline_supervised \
/usr/local/bin/training_uploader