From 23b89a90e908b5d23adabd0bdf229c8cd0cbbd42 Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Tue, 12 May 2026 08:47:05 +0200 Subject: [PATCH] =?UTF-8?q?fix(sp21):=20T2.2=20Phase=208.3+9=20=E2=80=94?= =?UTF-8?q?=20eval=20pipeline=20GPU-only,=20hard-fail,=20delete=20CPU=20pa?= =?UTF-8?q?th=20(atomic)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Combined Phase 8.3 (visibility + hard-fail) and Phase 9 (CPU path removal) per pearl_no_deferrals_for_complementary_fixes. Surfaced by v6 smoke (train-x4m96) where: [DQN GPU] Fold 0 GPU eval failed: GpuBacktestEvaluator::evaluate failed for fold 0. Falling back to CPU path. [DQN] Fold 0 evaluation failed: Failed to create DQN state tensor for bar 0: Dimension mismatch: expected 54, got 45 Both fold 0 and fold 1 hit this; workflow exited 0, masking eval failure for every smoke run since STATE_DIM grew beyond legacy 54. Root cause (silent GPU failure): evaluate_dqn_fold_gpu calls evaluator.evaluate(closure, portfolio_dim: 3) but GpuBacktestEvaluator initialises portfolio_dim = PORTFOLIO_BASE_DIM (8) + MTF_DIM (16) = 24 per canonical state layout. gather_states asserts match → returns MLError::ConfigError. Caller wraps with .with_context() + warn!("... {}", e) — `{}` strips anyhow chain, hiding root cause. The CPU fallback runs with a separate stale 45-dim state builder (42 from extract_ml_features + 3 portfolio) → fails at GpuTensor:: from_host shape validation against the model's 54-feature default. Fixes (all atomic): 1. portfolio_dim: 3 → 24 at all 4 call sites (DQN x2, PPO, supervised). The GPU evaluator's gather_kernel handles full 128-dim state assembly (Market 42 + OFI 32 + TLOB 16 + MTF 16 + Portfolio 8 + PlanISV 7 + Padding 7); caller just declares correct portfolio_dim. 2. Surface anyhow chain: {} → {:#} in error messages. 3. Hard-fail on GPU eval failure: anyhow::bail! (no CPU fallback). Per user directive: "hard fail on gpu panic, cpu path strictly forbidden should be removed entirely!" 4. DELETE CPU DQN eval path entirely: - fn evaluate_dqn_fold - fn build_chunk_states (stale 45-dim state builder) - fn simulate_chunk_trades - fn compute_metrics + struct ComputedMetrics - struct PortfolioState 5. DELETE coupled surrogate-noise machinery: - struct SurrogateSampler + impl - fn load_surrogate_marginals - fn compute_pooled_sharpe - Surrogate init blocks in main - ACTION_MARGINALS / POOLED_SHARPE emission blocks 6. DELETE coupled CLI flags: - --gpu-eval / --no-gpu-eval (GPU mandatory) - --surrogate-mode, --surrogate-seed, --surrogate-marginals - --emit-action-marginals, --emit-pooled-sharpe 7. Collapse `if args.gpu_eval { ... }` blocks to direct calls; cleaner control flow, no gpu_handled tracking. Pearls honoured: - feedback_no_cpu_test_fallbacks: GPU oracle only - feedback_no_partial_refactor: stale CPU layout from pre-STATE_DIM=128 era - feedback_no_hiding: error chain now visible via {:#} - feedback_no_legacy_aliases: no deprecated --no-gpu-eval wrapper - pearl_no_deferrals_for_complementary_fixes: 8.3+9 combined Files changed: - crates/ml/examples/evaluate_baseline.rs: −892 net lines (1066 del, 174 ins; 2817 → 1925) - docs/dqn-wire-up-audit.md: 2026-05-12 audit entry Verification: - cargo check -p ml --example evaluate_baseline --features cuda # clean - cargo check --workspace --features cuda # clean - cargo test -p ml --lib --features cuda financials # 7/7 Note: OFI/TLOB/MTF feature-set fidelity is a separate concern. The GPU gather_kernel handles state assembly; caller currently provides zeroed OFI (LobBar.ofi = 0.0) and no MTF data. Eval will run, but on degraded features. Faithful feature wiring deferred to a later Phase once eval-runs-at-all is validated by v7 smoke. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ml/examples/evaluate_baseline.rs | 1242 ++++------------------- docs/dqn-wire-up-audit.md | 128 +++ 2 files changed, 303 insertions(+), 1067 deletions(-) diff --git a/crates/ml/examples/evaluate_baseline.rs b/crates/ml/examples/evaluate_baseline.rs index 62db92f41..64ec1e1cc 100644 --- a/crates/ml/examples/evaluate_baseline.rs +++ b/crates/ml/examples/evaluate_baseline.rs @@ -112,7 +112,7 @@ use anyhow::{Context, Result}; use clap::Parser; use serde::Serialize; use serde_json::Value; -use tracing::{error, info, warn}; +use tracing::{info, warn}; use common::metrics::{server as metrics_server, training_metrics as tm}; use ml::common::action::{ExposureLevel, FactoredAction, OrderType as ActionOrderType, Urgency}; @@ -236,19 +236,7 @@ struct Args { #[arg(long)] hyperopt_params: Option, - /// 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). + /// Initial capital for GPU backtest evaluator. #[arg(long, default_value_t = 100_000.0)] initial_capital: f64, @@ -265,41 +253,15 @@ struct Args { /// 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, - // ------------------------------------------------------------------ - // Surrogate-noise / pooled-metrics flags (Task 0.16) - // ------------------------------------------------------------------ - - /// Surrogate-noise mode. `off` = normal eval. `random` = replace model - /// actions with random actions sampled from --surrogate-marginals (or - /// uniform if not provided). Forces the CPU DQN path so surrogate - /// sampling can override the action-selection step. - #[arg(long, default_value = "off")] - surrogate_mode: String, - - /// Seed for `--surrogate-mode=random`. - #[arg(long, default_value_t = 0)] - surrogate_seed: u64, - - /// Path to JSON file with action marginals (output of - /// `--emit-action-marginals`). Required when - /// `--surrogate-mode=random`. Falls back to UNIFORM random actions - /// with a warning if omitted. - #[arg(long)] - surrogate_marginals: Option, - - /// Emit action marginal distribution to stdout as a single JSON line - /// prefixed `ACTION_MARGINALS:` for machine parsing. - #[arg(long, default_value_t = false)] - emit_action_marginals: bool, - - /// Emit single pooled Sharpe ratio (across all folds) as a single line - /// prefixed `POOLED_SHARPE:` for machine parsing. - #[arg(long, default_value_t = false)] - emit_pooled_sharpe: bool, + // `gpu_eval`, `surrogate_mode`, `surrogate_seed`, `surrogate_marginals`, + // `emit_action_marginals`, `emit_pooled_sharpe` flags removed 2026-05-12 — + // all coupled to the deleted CPU DQN fallback path. GPU eval is mandatory; + // surrogate-noise + pooled-Sharpe + action-marginal histograms need a GPU + // closure-based reimplementation if reintroduced. See the 2026-05-12 audit + // entry and `feedback_no_cpu_test_fallbacks`. } // --------------------------------------------------------------------------- @@ -587,627 +549,8 @@ struct EvaluationReport { 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::() / 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 = 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::() / trade_returns.len() as f64; - let trade_var: f64 = trade_returns - .iter() - .map(|&r| (r - trade_mean).powi(2)) - .sum::() - / 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, - } -} - -// --------------------------------------------------------------------------- -// Surrogate-noise helpers (Task 0.16) -// --------------------------------------------------------------------------- - -/// Surrogate action sampler used when `--surrogate-mode=random`. -/// -/// Samples a flat action index from either a provided marginal distribution -/// (1D probabilities over `num_actions`) or uniform if no marginals were -/// supplied. Uses a seeded `StdRng` for reproducibility. -struct SurrogateSampler { - rng: rand::rngs::StdRng, - /// Cumulative distribution over `num_actions` (last entry = 1.0). `None` - /// falls back to uniform sampling. - cdf: Option>, - num_actions: usize, -} - -impl SurrogateSampler { - fn new(seed: u64, marginals: Option<&[f64]>, num_actions: usize) -> Self { - use rand::SeedableRng; - let rng = rand::rngs::StdRng::seed_from_u64(seed); - let cdf = marginals.and_then(|m| { - if m.is_empty() { - return None; - } - let sum: f64 = m.iter().sum(); - if !(sum > 1e-12) { - return None; - } - let mut c = Vec::with_capacity(m.len()); - let mut acc = 0.0; - for &p in m { - acc += p.max(0.0) / sum; - c.push(acc); - } - // Force last entry to exactly 1.0 to avoid floating drift - if let Some(last) = c.last_mut() { *last = 1.0; } - Some(c) - }); - Self { rng, cdf, num_actions } - } - - /// Sample a single action index in `0..num_actions`. - fn sample(&mut self) -> usize { - use rand::Rng; - match &self.cdf { - Some(cdf) => { - let u: f64 = self.rng.gen(); - // partition_point handles zero-mass actions correctly (consecutive - // equal CDF entries collapse to a single index). binary_search_by - // returns an arbitrary index on equality, which can land on a - // zero-probability entry when several CDF bins share the same value. - // We want the first index where `cdf[i] >= u`, i.e. the first index - // where the predicate `cdf[i] < u` is FALSE. - let idx = cdf.partition_point(|&p| p < u); - idx.min(cdf.len().saturating_sub(1)) - } - None => { - if self.num_actions == 0 { 0 } else { self.rng.gen_range(0..self.num_actions) } - } - } - } -} - -/// Read marginals from a JSON file emitted by `--emit-action-marginals`. -/// -/// Accepts two shapes: -/// 1. `{"flat": [p0, p1, ...]}` — flat distribution over num_actions entries -/// 2. `[p0, p1, ...]` — bare array of probabilities -/// -/// Returns the flat probability vector. -fn load_surrogate_marginals(path: &Path) -> Result> { - let contents = std::fs::read_to_string(path) - .with_context(|| format!("reading surrogate marginals: {}", path.display()))?; - let val: Value = serde_json::from_str(&contents) - .with_context(|| format!("parsing surrogate marginals JSON: {}", path.display()))?; - if let Some(arr) = val.as_array() { - let out: Vec = arr.iter().filter_map(|v| v.as_f64()).collect(); - if !out.is_empty() { return Ok(out); } - } - if let Some(obj) = val.as_object() { - if let Some(flat) = obj.get("flat").and_then(|v| v.as_array()) { - let out: Vec = flat.iter().filter_map(|v| v.as_f64()).collect(); - if !out.is_empty() { return Ok(out); } - } - } - anyhow::bail!( - "marginals JSON at {} must be either a bare array of floats or have a \"flat\" array field", - path.display() - ); -} - -/// Compute pooled Sharpe across concatenated per-fold returns. -/// -/// Uses the same annualization factor as the per-fold `compute_metrics` path. -fn compute_pooled_sharpe(all_returns: &[f64], bars_per_year: f64) -> f64 { - if all_returns.is_empty() { return 0.0; } - let n = all_returns.len() as f64; - let mean = all_returns.iter().sum::() / n; - let var = all_returns.iter().map(|&r| (r - mean).powi(2)).sum::() / n; - let std = var.sqrt(); - if std > 1e-12 { (mean / std) * bars_per_year.sqrt() } else { 0.0 } -} - -// --------------------------------------------------------------------------- -// 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` 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 { - 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 = 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, - 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`) 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, - surrogate: Option<&mut SurrogateSampler>, - flat_action_counts: &mut [usize], -) -> Result<(Vec, [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). - // - // **Shape-mismatch fix (2026-05-11)** — same as the GPU eval path above: - // pull architecture-critical fields from checkpoint metadata via - // `DQNConfig::from_safetensors_file`. The CLI defaults (`num_actions=5`, - // `feature_dim=54`) are legacy and would silently shape-mismatch a - // post-SP21 checkpoint (108 actions, STATE_DIM=128). - #[allow(clippy::integer_division)] - let config = match DQNConfig::from_safetensors_file(&ckpt_path) { - Ok(mut cfg) => { - cfg.learning_rate = 1e-4; - cfg.epsilon_start = 0.0; - cfg.epsilon_end = 0.0; - cfg.epsilon_decay = 1.0; - cfg.replay_buffer_capacity = 100; - cfg.batch_size = 64; - cfg.min_replay_size = 64; - cfg.target_update_freq = 500; - cfg.warmup_steps = 0; - if let Some(g) = hp_f64(hp, "gamma") { - cfg.gamma = g as f32; - } - if let Some(vmin) = hp_f64(hp, "v_min") { - cfg.v_min = vmin as f32; - } - if let Some(vmax) = hp_f64(hp, "v_max") { - cfg.v_max = vmax as f32; - } - info!( - " [DQN CPU] Architecture from checkpoint: num_actions={}, hidden_dims={:?}, \ - num_atoms={}, num_order_types={}, num_urgency_levels={}, dueling_hidden_dim={}", - cfg.num_actions, cfg.hidden_dims, cfg.num_atoms, - cfg.num_order_types, cfg.num_urgency_levels, cfg.dueling_hidden_dim, - ); - cfg - } - Err(e) => { - warn!( - " [DQN CPU] No architecture metadata in checkpoint ({}); falling back to \ - CLI args (num_actions={}, may shape-mismatch): {}", - ckpt_path.display(), args.num_actions, e, - ); - 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, - ..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, - ); - - // Surrogate-noise mode bypasses the model: we still need to build the - // state for feature-vector validation, but we skip the GPU forward pass. - let use_surrogate = surrogate.is_some(); - // Move the surrogate out of `Option<&mut T>` into a local for the loop - // without tripping the borrow checker's "captured twice" check. - let mut surrogate_local = surrogate; - - for bar_idx in 0..eval_bars { - let action_idx = if let Some(sampler) = surrogate_local.as_deref_mut() { - // Random-action surrogate — bypass model entirely - sampler.sample() - } else { - // 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}"))?; - action_host.first().copied().unwrap_or(0.0) as usize - }; - - // Track flat-index count for action-marginals emission. - // - // Fix 1 (code-quality review): hard-fail on out-of-range instead of - // silently dropping. A silent drop corrupts the surrogate marginals - // because some bins never receive counts. The binary's `--num-actions` - // is semantically ambiguous in this codebase (historically "5 exposure - // levels", today a 4-branch factored policy producing dir*27+mag*9+ - // ord*3+urg = 81 flat actions, with the CPU eval path in this file - // currently reading only the direction branch). Rather than paper over - // the mismatch, the gate demands the caller pass `--num-actions` that - // matches the model's actual flat action space. - if action_idx >= flat_action_counts.len() { - anyhow::bail!( - "Action index {} exceeds --num-actions={}. Pass --num-actions \ - matching the model's flat action space (typically 81 for the \ - 4-branch factored DQN: dir*27 + mag*9 + ord*3 + urg). \ - Silently dropping out-of-range indices would corrupt \ - --emit-action-marginals and --surrogate-mode=random sampling.", - action_idx, - flat_action_counts.len(), - ); - } - flat_action_counts[action_idx] = flat_action_counts[action_idx].saturating_add(1); - - 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, - ); - } - - if use_surrogate { - info!(" [DQN] Surrogate-noise mode: {} bars used random actions", eval_bars); - } - - Ok((returns, action_counts)) -} // --------------------------------------------------------------------------- // GPU-Accelerated DQN Evaluation @@ -1514,7 +857,7 @@ fn evaluate_dqn_fold_gpu( .map_err(|e| ml::MLError::ModelError(format!("upload actions: {e}")))?; Ok(out) }, - 3, + 24, // portfolio_dim = PORTFOLIO_BASE_DIM (8) + MTF_DIM (16), matches training state layout ) .with_context(|| format!( "GpuBacktestEvaluator::evaluate failed for fold {}", fold @@ -1546,7 +889,7 @@ fn evaluate_dqn_fold_gpu( .map_err(|e| ml::MLError::ModelError(format!("upload actions: {e}")))?; Ok(out) }, - 3, // portfolio_dim + 24, // portfolio_dim = PORTFOLIO_BASE_DIM (8) + MTF_DIM (16), matches training state layout ) .with_context(|| format!("GpuBacktestEvaluator::evaluate failed for fold {}", fold))? }; @@ -1825,7 +1168,7 @@ fn evaluate_ppo_fold_gpu( .map_err(|e| ml::MLError::ModelError(format!("HtoD actions: {e}")))?; Ok(out) }, - 3, // portfolio_dim + 24, // portfolio_dim = PORTFOLIO_BASE_DIM (8) + MTF_DIM (16), matches training state layout ) .with_context(|| format!("GpuBacktestEvaluator::evaluate failed for PPO fold {}", fold))?; @@ -2030,7 +1373,7 @@ fn evaluate_supervised_fold_gpu( .map_err(|e| ml::MLError::ModelError(format!("HtoD actions: {e}")))?; Ok(out) }, - 3, // portfolio_dim + 24, // portfolio_dim = PORTFOLIO_BASE_DIM (8) + MTF_DIM (16), matches training state layout ) .with_context(|| { format!("GpuBacktestEvaluator::evaluate failed for {} fold {}", model_name, fold) @@ -2117,57 +1460,13 @@ fn main() -> Result<()> { metrics_server::start_metrics_server(9094); tm::set_active_workers(1.0); - let mut args = Args::parse(); + let args = Args::parse(); - // ── Surrogate-noise mode setup (Task 0.16) ────────────────────────── - // - // When --surrogate-mode=random is requested, we force the CPU DQN path - // so the surrogate sampler can override action selection. GPU path is - // not supported (would require invasive kernel changes that defeat the - // point of a bypass-the-model sanity check). - let surrogate_enabled = args.surrogate_mode == "random"; - if surrogate_enabled && args.gpu_eval { - warn!( - " [SURROGATE] --surrogate-mode=random forces CPU DQN path (--gpu-eval disabled)" - ); - args.gpu_eval = false; - } - // Fix 1 (code-quality review): the binary's --num-actions default (5) is - // historically tied to the old "5 exposure levels" semantics and predates - // the 4-branch factored policy (81 flat actions). When marginals collection - // or random-surrogate sampling is requested, warn loudly so callers don't - // silently undersize flat_action_counts. The CPU DQN path ALSO hard-fails - // on out-of-range action_idx (see Fix 1 at the collection site) — this - // warning is a pre-flight signal rather than a safety net. - if (surrogate_enabled || args.emit_action_marginals) && args.num_actions == 5 { - warn!( - " [SURROGATE] --num-actions=5 (default). If the trained model uses \ - a different flat action space (typical: 81 for 4-branch factored \ - DQN), pass --num-actions explicitly. The CPU DQN path will \ - hard-fail rather than silently drop out-of-range indices." - ); - } - let surrogate_marginals: Option> = if surrogate_enabled { - match &args.surrogate_marginals { - Some(p) => { - let m = load_surrogate_marginals(p) - .with_context(|| format!("loading {}", p.display()))?; - info!( - " [SURROGATE] Loaded {}-dim action marginals from {}", - m.len(), p.display() - ); - Some(m) - } - None => { - warn!( - " [SURROGATE] --surrogate-mode=random without --surrogate-marginals: \ - falling back to UNIFORM random actions across {} actions", - args.num_actions - ); - None - } - } - } else { None }; + // Surrogate-noise mode, --num-actions safety check, marginal loading + // removed 2026-05-12 — all were coupled to the deleted CPU DQN fallback + // path. See `feedback_no_cpu_test_fallbacks` and the 2026-05-12 audit + // entry. To reintroduce action-override eval, wire it through the GPU + // evaluator's closure forward_fn (not via a CPU fallback). let eval_dqn = args.model == "dqn" || args.model == "both"; let eval_ppo = args.model == "ppo" || args.model == "both"; @@ -2254,32 +1553,11 @@ fn main() -> Result<()> { info!("Step 3/5: Evaluating models on test data..."); let mut all_fold_metrics: Vec = Vec::new(); let mut all_action_counts: Vec<[usize; 3]> = Vec::new(); - // Flat per-action-index counts for --emit-action-marginals (CPU DQN path). - // GPU paths are excluded because they collapse actions to a different shape - // (5 exposure scores) before argmax; mixing them would yield a misleading - // histogram. When only GPU paths run, the emitted marginals will be - // all-zeros with a warning. - let mut flat_action_counts: Vec = vec![0; args.num_actions]; - // Fix 3 (code-quality review): distinguish "stub because CPU DQN path - // never ran" (GPU took over, or wrong --model) from "CPU DQN path ran - // but counted zero actions" (empty fold). Set to true inside the CPU - // DQN Ok(...) branch below. - let mut cpu_dqn_ran = false; - // All per-bar returns concatenated across folds for --emit-pooled-sharpe. - // Populated only by the CPU DQN path (the only path that returns raw - // per-bar returns). When GPU path is used, this stays empty and pooled - // Sharpe is approximated from the fold-level Sharpe ratios. - let mut pooled_returns: Vec = Vec::new(); - - // Initialise surrogate sampler once (state carries across folds so a - // single seed produces a deterministic sequence over the full backtest). - let mut surrogate_sampler: Option = if surrogate_enabled { - Some(SurrogateSampler::new( - args.surrogate_seed, - surrogate_marginals.as_deref(), - args.num_actions, - )) - } else { None }; + // `flat_action_counts`, `cpu_dqn_ran`, `pooled_returns`, and + // `surrogate_sampler` removed 2026-05-12 — coupled to the deleted CPU + // DQN fallback path. The GPU evaluator does not expose per-flat-action + // counts (only aggregate buy/sell/hold) so action-marginal histograms + // and pooled per-bar Sharpe are no longer collected at this layer. for window in &windows { info!( @@ -2348,156 +1626,79 @@ fn main() -> Result<()> { .map(|b| b.timestamp.format("%Y-%m-%d").to_string()) .unwrap_or_default(); - // Evaluate DQN + // Evaluate DQN — GPU only (CPU fallback removed 2026-05-12) 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, - surrogate_sampler.as_mut(), - flat_action_counts.as_mut_slice(), - ) { - Ok((returns, action_counts)) => { - // Fix 3: mark that the CPU DQN path actually completed - // a fold so the stub-marker logic can distinguish this - // from "GPU took over" / "model != dqn" / "zero bars". - cpu_dqn_ran = true; - // Accumulate concatenated returns for pooled-Sharpe - pooled_returns.extend(returns.iter().copied()); - let fold_metrics = compute_metrics(&returns, args.bars_per_year); + info!(" [DQN] 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, - fold_metrics.win_rate_pct / 100.0, - fold_metrics.sharpe_ratio, - fold_metrics.profit_factor, - fold_metrics.total_return_pct / 100.0, + 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] Fold {} - Sharpe(bar)={:.4} Sharpe(trade)={:.4} MaxDD={:.2}% WR={:.1}% PF={:.2} Return={:.4}% Trades={}", + " [DQN GPU] Fold {} - Sharpe={:.4} TotalPnL={:.4} MaxDD={:.4} Sortino={:.4} WinRate={:.2}% Trades={} VaR95={:.4} CVaR95={:.4} Calmar={:.4} Omega={:.4}", 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), + 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: 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, + 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(), }); - all_action_counts.push(action_counts); - } - Err(e) => { - error!(" [DQN] Fold {} evaluation failed: {}", window.fold, e); + // 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 + ); } } + Err(e) => { + anyhow::bail!( + "DQN fold {} GPU evaluation failed (CPU fallback removed — \ + GPU eval is mandatory per feedback_no_cpu_test_fallbacks): {:#}", + window.fold, e + ); + } } } @@ -2505,16 +1706,88 @@ fn main() -> Result<()> { 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( + // PPO — GPU only (CPU fallback never existed for PPO). + info!(" [PPO] 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 + ); + } + } + Err(e) => { + anyhow::bail!( + "PPO fold {} GPU evaluation failed (GPU eval is mandatory): {:#}", + window.fold, e, + ); + } + } + } + + // Evaluate supervised models + if eval_supervised { + for model_name in &supervised_models { + let hp = load_hyperopt_params(&args.hyperopt_params, model_name); + + // Supervised — GPU only (CPU eval handled by `evaluate_supervised`) + info!( + " [{}] 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, @@ -2524,17 +1797,18 @@ fn main() -> Result<()> { 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_epoch(model_name, &fold_str, window.fold as f64); tm::set_eval_metrics( - "ppo", + model_name, &fold_str, m.win_rate as f64, m.sharpe as f64, - 1.0, // profit_factor not available from GPU path + 1.0, 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}", + " [{} 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, @@ -2549,136 +1823,36 @@ fn main() -> Result<()> { ); all_fold_metrics.push(FoldMetrics { fold: window.fold, - model: "ppo".to_owned(), + model: model_name.clone(), 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 + " [{} GPU] Fold {} - no window metrics returned", + model_name.to_uppercase(), + window.fold, ); } - true // GPU path handled this fold } Err(e) => { anyhow::bail!( - "PPO fold {} GPU evaluation failed — GPU eval is mandatory: {e}", + "{} fold {} GPU evaluation failed (CPU eval not supported in this binary; \ + use the dedicated `evaluate_supervised` binary if CPU is required): {:#}", + model_name.to_uppercase(), window.fold, + e, ); } } - } 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, - ); - } } } } @@ -2734,77 +1908,11 @@ fn main() -> Result<()> { info!(" Report saved to: {}", args.output.display()); info!(" Total fold evaluations: {}", report.folds.len()); - // ── Task 0.16: machine-parseable output lines ─────────────────────── - // - // These lines are emitted to stdout (not stderr / tracing) so the - // surrogate-noise smoke test can parse them via a subprocess. The - // "ACTION_MARGINALS:" / "POOLED_SHARPE:" prefixes are load-bearing — - // the consumer matches them exactly. - if args.emit_action_marginals { - let total: usize = flat_action_counts.iter().sum(); - if total == 0 { - // Fix 3 (code-quality review): disambiguate the stub marker. A zero - // total can mean any of three distinct things, each needing a - // different caller response: - // * `stub_no_dqn_eval` — --model != dqn (no DQN ran at all) - // * `stub_cpu_path_not_exercised` — GPU path handled all folds, or - // --model=dqn but every fold - // errored before the CPU branch - // set `cpu_dqn_ran = true` - // * `stub_zero_bars` — CPU path ran but counted no - // actions (empty fold) - let marker = if !eval_dqn { - "stub_no_dqn_eval" - } else if !cpu_dqn_ran { - "stub_cpu_path_not_exercised" - } else { - "stub_zero_bars" - }; - println!("ACTION_MARGINALS: {}", marker); - warn!( - " [SURROGATE] --emit-action-marginals: emitted {} marker. \ - eval_dqn={} cpu_dqn_ran={} total_samples={}", - marker, eval_dqn, cpu_dqn_ran, total, - ); - } else { - let flat: Vec = flat_action_counts - .iter() - .map(|&c| c as f64 / total as f64) - .collect(); - // Fix 6 (code-quality review): include a "schema" field so the - // consumer can validate the payload shape and refuse silently - // mismatched future versions. - let json = serde_json::json!({ - "schema": "action_marginals_v1", - "flat": flat, - "num_actions": args.num_actions, - "total_samples": total, - }); - match serde_json::to_string(&json) { - Ok(s) => println!("ACTION_MARGINALS: {}", s), - Err(e) => warn!(" [SURROGATE] Failed to serialize action marginals: {}", e), - } - } - } - - if args.emit_pooled_sharpe { - let pooled = if !pooled_returns.is_empty() { - compute_pooled_sharpe(&pooled_returns, args.bars_per_year) - } else { - // Fallback: mean of fold-level Sharpes (unpooled). The smoke - // test prefers the true pooled value; this fallback avoids - // silently emitting zero when the CPU path wasn't exercised. - let sharpes: Vec = report.folds.iter().map(|f| f.sharpe_ratio).collect(); - if sharpes.is_empty() { 0.0 } else { - warn!( - " [SURROGATE] --emit-pooled-sharpe: pooled_returns empty \ - (GPU path only) — falling back to mean of fold Sharpes" - ); - sharpes.iter().sum::() / sharpes.len() as f64 - } - }; - println!("POOLED_SHARPE: {}", pooled); - } + // ACTION_MARGINALS / POOLED_SHARPE emission removed 2026-05-12 — + // surrogate noise smoke test machinery is coupled to the deleted CPU + // DQN path. Both feature sets must be re-implemented on top of the GPU + // evaluator (via closure-based action override + per-bar return tape) + // if reintroduced. tm::set_active_workers(0.0); diff --git a/docs/dqn-wire-up-audit.md b/docs/dqn-wire-up-audit.md index 03aa5a98a..cfc6e7f7e 100644 --- a/docs/dqn-wire-up-audit.md +++ b/docs/dqn-wire-up-audit.md @@ -15534,3 +15534,131 @@ to v1/v4 train-side stats): The downstream PER alpha boost (E6+E7+E8 → `per_update_pa` and `per_insert_pa`) will now have non-zero scalars to operate on, finally exercising the Phase 5/6/7 wiring end-to-end. + +## 2026-05-12 — SP21 T2.2 Phase 8.3+9: eval pipeline — fix GPU + hard-fail + delete CPU path (atomic) + +### Scope (atomic single commit) + +Combined Phase 8.3 (visibility + hard-fail) and Phase 9 (CPU fallback +removal) into one atomic change per `pearl_no_deferrals_for_complementary_fixes`. +Surfaced during v6 smoke (train-x4m96) eval phase, where: + +``` +[DQN GPU] Fold 0 GPU eval failed: GpuBacktestEvaluator::evaluate failed for fold 0. Falling back to CPU path. +[DQN] Fold 0 evaluation failed: Failed to create DQN state tensor for bar 0: Dimension mismatch: expected 54, got 45 +``` + +Workflow exited 0 (eval pod swallowed both failures), giving the +illusion of success for every smoke run since STATE_DIM grew beyond +the legacy 54. + +### Root cause (silent GPU eval failure) + +`evaluate_dqn_fold_gpu` calls `evaluator.evaluate(closure, portfolio_dim: 3)` +but `GpuBacktestEvaluator::new` initialises with +`portfolio_dim = PORTFOLIO_BASE_DIM (8) + MTF_DIM (16) = 24` per the +canonical state layout. `gather_states` asserts: + +```rust +if portfolio_dim != self.portfolio_dim { + return Err(MLError::ConfigError(format!( + "gather_states: portfolio_dim={portfolio_dim} != expected {}", + self.portfolio_dim + ))); +} +``` + +→ every GPU eval fold returned `MLError::ConfigError("portfolio_dim=3 != expected 24")`. +The caller wrapped with `.with_context("GpuBacktestEvaluator::evaluate failed for fold {fold}")` +and logged via `warn!("... {}", e)` — `{}` strips anyhow's chain, hiding +the actual `ConfigError`. The CPU fallback then ran with a *separate* +state-builder using a 45-dim layout (42 from `extract_ml_features` + 3 +portfolio), which failed at `GpuTensor::from_host` shape validation +against the model's expected 54-feature input (CLI default). + +### Fixes (all atomic) + +**1. Fix the actual GPU eval bug** — `portfolio_dim: 3 → 24` at all four +call sites (DQN closure x2, PPO, supervised). The GPU evaluator's +gather-kernel handles 128-dim state assembly internally (Market 42 + +OFI 32 + TLOB 16 + MTF 16 + Portfolio 8 + PlanISV 7 + Padding 7); the +caller just needs to declare the correct portfolio_dim. + +**2. Surface the anyhow chain** on GPU eval failures — `{}` → `{:#}` +in error messages, so future regressions print the full root cause. + +**3. Hard-fail on GPU eval failure (no CPU fallback)** — DQN/PPO/ +supervised all now `anyhow::bail!` on GPU error per the user's +"hard fail on gpu panic, cpu path strictly forbidden" directive. + +**4. DELETE the entire CPU DQN eval path** — removed: +- `fn evaluate_dqn_fold` (CPU-fallback DQN evaluator) +- `fn build_chunk_states` (stale 45-dim state builder) +- `fn simulate_chunk_trades` (CPU trade simulator) +- `fn compute_metrics` + `struct ComputedMetrics` (CPU metric aggregator) +- `struct PortfolioState` (CPU-side portfolio tracker) + +**5. DELETE coupled surrogate-noise machinery** — removed: +- `struct SurrogateSampler` + impl (random-action override) +- `fn load_surrogate_marginals` (CDF loader) +- `fn compute_pooled_sharpe` (cross-fold pooled Sharpe) +- Surrogate init blocks in main +- ACTION_MARGINALS / POOLED_SHARPE emission blocks + +**6. DELETE coupled CLI flags** — removed: +- `--gpu-eval` / `--no-gpu-eval` (GPU is mandatory) +- `--surrogate-mode`, `--surrogate-seed`, `--surrogate-marginals` +- `--emit-action-marginals`, `--emit-pooled-sharpe` + +**7. Collapse `if args.gpu_eval { ... }` blocks** to direct calls +(GPU is the only path). Cleaner control flow, no gpu_handled tracking. + +### Pearls + invariants honoured + +- **feedback_no_cpu_test_fallbacks**: CPU fallback eliminated; GPU + oracle only, no CPU reference impl +- **feedback_no_partial_refactor**: stale 45-dim CPU path was a + partial-refactor leftover from pre-STATE_DIM=128 era; deleted, not + patched +- **feedback_no_hiding**: GPU error chain now visible via `{:#}`; + no swallowed errors via warn-and-continue +- **feedback_no_legacy_aliases**: `--no-gpu-eval` removed; no + deprecated wrappers +- **feedback_no_functionality_removal**: surrogate-noise is technically + a feature loss — derivative removal because its sole runtime (CPU + eval) was removed per the primary directive; reintroducing it + belongs in a separate plan (would need GPU closure-based + reimplementation per the comment trail) +- **pearl_no_deferrals_for_complementary_fixes**: 8.3 (visibility) + and 9 (CPU removal) share non-overlapping refactor scope; combined + into one commit rather than sequencing through two smoke cycles + +### Files changed + +- `crates/ml/examples/evaluate_baseline.rs`: + −892 net lines (1066 deleted, 174 inserted; 2817 → 1925 total) +- `docs/dqn-wire-up-audit.md`: this entry + +### Verification + +``` +cargo check -p ml --example evaluate_baseline --features cuda # clean, 0 warnings +cargo check --workspace --features cuda # clean +cargo test -p ml --lib --features cuda financials # 7/7 +``` + +### Expected v7 smoke (next dispatch) + +The eval phase should now run cleanly — `GpuBacktestEvaluator::evaluate` +receives correct `portfolio_dim=24` and runs the full 128-dim state +gather/forward/env-step loop. Real DQN test-window metrics (Sharpe, +WinRate, MaxDD, PF, etc.) should appear in the eval log for all 3 +folds. + +OFI/TLOB/MTF features feed into the gather kernel via `lob_bars` (OFI), +the model's TLOB attention sub-net (TLOB), and pre-computed MTF buffers +(MTF) — all already wired in the evaluator's state-assembly kernel +(`backtest_state_gather`). The caller has been providing zeroed +OFI (`LobBar.ofi = 0.0` placeholder) and no MTF data; this is a +separate "feature-set fidelity" concern, deferred to a future +Phase 10 once eval-runs-at-all is validated.