feat(sp15-p1.6.b+1.7.b): wire dev-eval (Q8 final-fold) + test-eval (per-fold) into trainer
Phase 1.6 (CLI flags + dev_features/holdout_features stash) and Phase
1.7 (set_test_data_from_slices observer + test_features stash) landed
the data-flow scaffolding; both deferred the actual eval consumer.
This task wires both atomically as parallel evaluator instances:
- dev_evaluator: Option<GpuBacktestEvaluator> -- lazy-init after final
fold, fires once against Q8 dev_features (when dev_quarters > 0)
- test_evaluator: Option<GpuBacktestEvaluator> -- lazy-init per fold,
fires inside the fold loop against the WF test slice (when
fold.test_end > fold.test_start)
Architectural choice: parallel evaluator instances (NOT window-swap on
val_evaluator). Window-swap would require invalidating the CUDA graph
between val and dev/test runs -- fragile, and a direct violation of
pearl_no_host_branches_in_captured_graph. Parallel instances mirror
val_evaluator's lazy-init pattern (TLOB sync, ISV signal pointer,
training_mode = false toggle). Implementation lives behind a single
shared helper `launch_extra_eval` keyed on an `ExtraEvalKind` enum so
Dev / Test share TLOB / ISV / config setup verbatim.
HEALTH_DIAG additions:
HEALTH_DIAG[N]: dev_eval dev_sharpe_net=... dev_calmar=... dev_max_dd=... dev_trades=...
HEALTH_DIAG[N]: test_slice fold=K test_sharpe_net=... test_calmar=... test_max_dd=... test_trades=...
The *_sharpe_net key uses the fused-metrics-kernel cost-aware Sharpe
(post-Phase-1.1.b split -- already includes tx_cost_bps + spread_cost
via the env-step PnL feed); when Phase 1.2.b cost-net sharpe lands, the
key name is preserved so the aggregator-script contract holds.
Atomic per feedback_no_partial_refactor: both eval calls + both
evaluator fields + both HEALTH_DIAG lines + audit doc all in this
commit. No parallel paths, no feature flags. Dev_eval runs synchronously
via evaluate_dqn_graphed (one-shot, no async pipelining benefit since
it doesn't fire per-epoch); val path stays async.
Sealed Q9 holdout remains untouched -- Phase 4.3 will load Q9 via a
separate eval-only entry point (NOT train_walk_forward). The Phase 1.6
debug_assert sealed-slice guard catches accidental future refactors.
Verified: cargo check -p ml --features cuda clean; ml lib suite holds
946 pass / 13 fail baseline; the existing Phase 1.7 oracle test
set_test_data_from_slices_fires_observer_and_stashes still passes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -765,6 +765,14 @@ impl DQNTrainer {
|
||||
|
||||
gpu_evaluator: None,
|
||||
|
||||
// SP15 Phase 1.6.b / 1.7.b: parallel evaluators for dev (Q8) and
|
||||
// per-fold walk-forward test slices. Lazy-init in `launch_extra_eval`
|
||||
// when their data slices are populated; both stay None on the
|
||||
// CPU-only construction path.
|
||||
dev_evaluator: None,
|
||||
dev_ofi_offset: 0,
|
||||
test_evaluator: None,
|
||||
|
||||
// Phase C: Fill simulation and smart order routing
|
||||
fill_simulator: FillSimulator::default(),
|
||||
vol_ema: 0.01, // Initial volatility estimate (1% daily)
|
||||
|
||||
@@ -7,6 +7,18 @@ use crate::TrainingMetrics;
|
||||
use crate::fxcache::TARGET_RAW_CLOSE;
|
||||
use super::super::config::DQNAgentType;
|
||||
|
||||
/// SP15 Phase 1.6.b / 1.7.b — discriminator for `launch_extra_eval`. Routes the
|
||||
/// shared eval helper between the Q8 dev slice (consumed once after the final
|
||||
/// fold) and the per-fold walk-forward test slice (consumed once per fold).
|
||||
/// Each variant has its own parallel `Option<GpuBacktestEvaluator>` slot on
|
||||
/// `DQNTrainer` (`dev_evaluator` / `test_evaluator`) — NOT a window-swap on
|
||||
/// `gpu_evaluator`, per `pearl_no_host_branches_in_captured_graph`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ExtraEvalKind {
|
||||
Dev,
|
||||
Test,
|
||||
}
|
||||
|
||||
/// Rolling EMA values for LearningHealth inputs.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct HealthEmaTrackers {
|
||||
@@ -976,6 +988,301 @@ impl DQNTrainer {
|
||||
Ok(-val_sharpe)
|
||||
}
|
||||
|
||||
/// SP15 Phase 1.6.b / 1.7.b helper: run a synchronous one-shot backtest
|
||||
/// against a stashed dev (Q8 final-fold) or per-fold walk-forward test
|
||||
/// slice, mirroring the val-path lazy-init in `launch_validation_loss`
|
||||
/// (TLOB sync, ISV signal pointer, identical `GpuBacktestConfig`) but
|
||||
/// running synchronously via `evaluate_dqn_graphed` since dev/test eval
|
||||
/// is one-shot at end-of-fold or end-of-training, not per-epoch.
|
||||
///
|
||||
/// `kind == ExtraEvalKind::Dev` consumes `dev_features`/`dev_targets`,
|
||||
/// `kind == ExtraEvalKind::Test` consumes `test_features`/`test_targets`.
|
||||
/// The respective `Option<GpuBacktestEvaluator>` slot is lazy-built on
|
||||
/// first call (Dev) or rebuilt on every call (Test, because the slice
|
||||
/// length can vary fold-to-fold and the evaluator allocates step buffers
|
||||
/// sized for `max_len` at construction). Parallel evaluator instances —
|
||||
/// NOT a window-swap on `gpu_evaluator` — per
|
||||
/// `pearl_no_host_branches_in_captured_graph`: window-swap would force
|
||||
/// invalidating the val CUDA Graph between val and dev/test runs.
|
||||
///
|
||||
/// Emits a single HEALTH_DIAG line per call:
|
||||
/// `HEALTH_DIAG[N]: dev_eval dev_sharpe_net=... dev_calmar=... ...`
|
||||
/// `HEALTH_DIAG[N]: test_slice fold=K test_sharpe_net=... ...`
|
||||
/// where N is `self.current_epoch` (last epoch of the fold for both
|
||||
/// kinds). Returns Ok(()) on a missing/empty slice (no-op fall-through).
|
||||
pub(crate) async fn launch_extra_eval(
|
||||
&mut self,
|
||||
kind: ExtraEvalKind,
|
||||
fold_idx: Option<usize>,
|
||||
) -> Result<()> {
|
||||
use crate::cuda_pipeline::gpu_backtest_evaluator::{
|
||||
DqnBacktestConfig, GpuBacktestConfig, GpuBacktestEvaluator,
|
||||
};
|
||||
|
||||
// Snapshot the slice + ofi offset under the slot enum. We snapshot
|
||||
// (rather than borrow) so the rest of the body can take `&mut self`
|
||||
// for `fused_ctx` / `agent` / TLOB sync without aliasing.
|
||||
let (features, targets, ofi_offset) = match kind {
|
||||
ExtraEvalKind::Dev => {
|
||||
let f = match self.dev_features.as_ref() {
|
||||
Some(f) if !f.is_empty() => f.clone(),
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let t = match self.dev_targets.as_ref() {
|
||||
Some(t) if !t.is_empty() => t.clone(),
|
||||
_ => return Ok(()),
|
||||
};
|
||||
(f, t, self.dev_ofi_offset)
|
||||
}
|
||||
ExtraEvalKind::Test => {
|
||||
let f = match self.test_features.as_ref() {
|
||||
Some(f) if !f.is_empty() => f.clone(),
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let t = match self.test_targets.as_ref() {
|
||||
Some(t) if !t.is_empty() => t.clone(),
|
||||
_ => return Ok(()),
|
||||
};
|
||||
(f, t, self.test_start_bar)
|
||||
}
|
||||
};
|
||||
|
||||
// Use the dedicated validation_stream when present so the new run
|
||||
// serialises against the val_evaluator on the same stream (avoids
|
||||
// cross-stream weight races on the shared FusedTrainingCtx). Falls
|
||||
// back to cuda_stream on the legacy single-stream path. CPU-only
|
||||
// builds skip silently — same fall-through as `launch_validation_loss`.
|
||||
let stream = match self.validation_stream.as_ref().or(self.cuda_stream.as_ref()) {
|
||||
Some(s) => s.clone(),
|
||||
None => return Ok(()),
|
||||
};
|
||||
// Cross-stream barrier: have the eval-stream wait on cuda_stream's
|
||||
// just-recorded event so the most-recent training kernel's weight
|
||||
// and ISV writes are globally visible before we read them. Skipped
|
||||
// on the single-stream path. Mirrors the val path lines 532-547.
|
||||
if let (Some(ref vs), Some(ref ts)) =
|
||||
(self.validation_stream.as_ref(), self.cuda_stream.as_ref())
|
||||
{
|
||||
let train_done = ts.record_event(None).map_err(|e| anyhow::anyhow!(
|
||||
"training_done event record (extra_eval): {e}"
|
||||
))?;
|
||||
vs.wait(&train_done).map_err(|e| anyhow::anyhow!(
|
||||
"extra_eval stream wait training_done: {e}"
|
||||
))?;
|
||||
}
|
||||
|
||||
// Always rebuild Test (slice length varies per fold). Build Dev once
|
||||
// and keep it across the trainer's lifetime — there's only one Q8
|
||||
// dev slice per training run and it never changes mid-run.
|
||||
let needs_build = match kind {
|
||||
ExtraEvalKind::Dev => self.dev_evaluator.is_none(),
|
||||
ExtraEvalKind::Test => true,
|
||||
};
|
||||
|
||||
if needs_build {
|
||||
// Drop any prior evaluator BEFORE allocating the new one so VRAM
|
||||
// is freed for the new build (relevant for Test where the slice
|
||||
// length and thus buffer sizes change per fold).
|
||||
match kind {
|
||||
ExtraEvalKind::Dev => self.dev_evaluator = None,
|
||||
ExtraEvalKind::Test => self.test_evaluator = None,
|
||||
}
|
||||
|
||||
let market_dim: usize = ml_core::state_layout::MARKET_DIM;
|
||||
let ofi_dim: usize = ml_core::state_layout::OFI_DIM;
|
||||
let feature_dim: usize = market_dim + ofi_dim;
|
||||
|
||||
// Single window from the entire slice. No subsample stride —
|
||||
// dev/test eval is one-shot, not per-epoch, so the val-path's
|
||||
// VAL_SUBSAMPLE_STRIDE=4 cost optimisation doesn't apply. Setting
|
||||
// val_subsample_stride=1 keeps the kernel's annualisation factor
|
||||
// honest (it scales by sqrt(bars_per_day * trading_days_per_year /
|
||||
// val_subsample_stride²)).
|
||||
let mut prices: Vec<[f32; 4]> = Vec::with_capacity(features.len());
|
||||
let mut feats: Vec<Vec<f32>> = Vec::with_capacity(features.len());
|
||||
for (i, (fv, target)) in features.iter().zip(targets.iter()).enumerate() {
|
||||
let close = target[TARGET_RAW_CLOSE] as f32;
|
||||
prices.push([close, close, close, close]);
|
||||
let fv_slice = &fv[..market_dim.min(fv.len())];
|
||||
let mut fv_f32: Vec<f32> = fv_slice.iter().map(|&v| v as f32).collect();
|
||||
let ofi_idx = ofi_offset + i;
|
||||
let ofi_row = self.ofi_features.as_ref()
|
||||
.and_then(|o| o.get(ofi_idx))
|
||||
.ok_or_else(|| anyhow::anyhow!(
|
||||
"extra_eval ({:?}): OFI features missing or index out of bounds at {}",
|
||||
kind, ofi_idx
|
||||
))?;
|
||||
for &v in ofi_row.iter() {
|
||||
fv_f32.push(v as f32);
|
||||
}
|
||||
feats.push(fv_f32);
|
||||
}
|
||||
|
||||
let window_prices = vec![prices];
|
||||
let window_features = vec![feats];
|
||||
|
||||
let hp = &self.hyperparams;
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let config = GpuBacktestConfig {
|
||||
max_position: hp.max_position_absolute as f32,
|
||||
tx_cost_bps: hp.transaction_cost_multiplier as f32,
|
||||
spread_cost: (hp.tick_size * hp.fill_spread_cost_frac) as f32,
|
||||
initial_capital: hp.initial_capital as f32,
|
||||
contract_multiplier: hp.contract_multiplier as f32,
|
||||
margin_pct: hp.margin_pct as f32,
|
||||
max_leverage: hp.max_leverage as f32,
|
||||
ofi_dim,
|
||||
holding_cost_rate: hp.holding_cost_rate as f32,
|
||||
churn_threshold: hp.churn_threshold_bars as f32,
|
||||
churn_penalty_scale: hp.churn_penalty_scale as f32,
|
||||
opportunity_cost_scale: 0.0,
|
||||
bars_per_day: hp.bars_per_day as f32,
|
||||
trading_days_per_year: hp.trading_days_per_year as f32,
|
||||
val_subsample_stride: 1, // no subsample for one-shot eval
|
||||
};
|
||||
|
||||
let evaluator = GpuBacktestEvaluator::new(
|
||||
&window_prices,
|
||||
&window_features,
|
||||
feature_dim,
|
||||
config,
|
||||
&stream,
|
||||
).map_err(|e| anyhow::anyhow!(
|
||||
"GpuBacktestEvaluator init (extra_eval {:?}): {e}", kind
|
||||
))?;
|
||||
|
||||
match kind {
|
||||
ExtraEvalKind::Dev => self.dev_evaluator = Some(evaluator),
|
||||
ExtraEvalKind::Test => self.test_evaluator = Some(evaluator),
|
||||
}
|
||||
}
|
||||
|
||||
// ── ISV signal pointer + TLOB weight sync (mirrors val_evaluator) ──
|
||||
// Wire ISV signals on the chosen evaluator so adaptive eval-time
|
||||
// controllers (Kelly cap, hold floor, etc.) read live values.
|
||||
if let Some(ref fused) = self.fused_ctx {
|
||||
let isv_ptr = fused.isv_signals_dev_ptr();
|
||||
let evaluator_opt: &mut Option<GpuBacktestEvaluator> = match kind {
|
||||
ExtraEvalKind::Dev => &mut self.dev_evaluator,
|
||||
ExtraEvalKind::Test => &mut self.test_evaluator,
|
||||
};
|
||||
if let Some(ref mut ev) = *evaluator_opt {
|
||||
ev.set_isv_signals_ptr(isv_ptr);
|
||||
}
|
||||
}
|
||||
|
||||
// TLOB weight sync from the live training instance into the eval-
|
||||
// path TLOB. Required for state-distribution parity between train
|
||||
// and eval. Same code path as val (`metrics.rs:648-665`).
|
||||
if let Some(ref fused) = self.fused_ctx {
|
||||
use crate::cuda_pipeline::gpu_tlob::GpuTlob;
|
||||
use crate::cuda_pipeline::shared_cublas_handle::PerStreamCublasHandles;
|
||||
use std::sync::Arc;
|
||||
let train_tlob = &fused.gpu_tlob;
|
||||
let evaluator_opt: &mut Option<GpuBacktestEvaluator> = match kind {
|
||||
ExtraEvalKind::Dev => &mut self.dev_evaluator,
|
||||
ExtraEvalKind::Test => &mut self.test_evaluator,
|
||||
};
|
||||
if let Some(ref mut ev) = *evaluator_opt {
|
||||
let eval_stream = Arc::clone(ev.stream());
|
||||
let val_tlob_batch = ev.val_tlob_batch_size();
|
||||
let h = PerStreamCublasHandles::new(&eval_stream)
|
||||
.map_err(|e| anyhow::anyhow!("Extra eval TLOB shared_cublas: {e:?}"))?;
|
||||
let eval_tlob = GpuTlob::new(Arc::new(h), val_tlob_batch)
|
||||
.map_err(|e| anyhow::anyhow!("Extra eval TLOB init: {e:?}"))?;
|
||||
ev.set_tlob_from_training(eval_tlob, train_tlob)
|
||||
.map_err(|e| anyhow::anyhow!("Extra eval TLOB weight sync: {e:?}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Extract pointers + DqnBacktestConfig (same as val path) ──
|
||||
let fused_ptr = self.fused_ctx.as_mut()
|
||||
.ok_or_else(|| anyhow::anyhow!("fused_ctx required for extra eval"))?
|
||||
as *mut super::super::fused_training::FusedTrainingCtx;
|
||||
let (online_weights_ptr, branching_weights_ptr) = unsafe {
|
||||
let f = &*fused_ptr;
|
||||
(f.online_dueling_ref() as *const _, f.online_branching_ref() as *const _)
|
||||
};
|
||||
let fused_ref = unsafe { &*fused_ptr };
|
||||
let vr = fused_ref.eval_v_range();
|
||||
|
||||
let agent = self.agent.read().await;
|
||||
let network_dims = agent.network_dims();
|
||||
let (b0, b1, b2, b3) = agent.branch_sizes();
|
||||
let is_branching = agent.is_using_branching();
|
||||
drop(agent);
|
||||
|
||||
let evaluator_opt: &mut Option<GpuBacktestEvaluator> = match kind {
|
||||
ExtraEvalKind::Dev => &mut self.dev_evaluator,
|
||||
ExtraEvalKind::Test => &mut self.test_evaluator,
|
||||
};
|
||||
let evaluator = evaluator_opt.as_mut()
|
||||
.ok_or_else(|| anyhow::anyhow!(
|
||||
"extra_eval evaluator must be initialised by this point ({:?})", kind
|
||||
))?;
|
||||
evaluator.invalidate_dqn_graph();
|
||||
|
||||
let hp = &self.hyperparams;
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let dqn_cfg = DqnBacktestConfig {
|
||||
shared_h1: network_dims.0,
|
||||
shared_h2: network_dims.1,
|
||||
value_h: network_dims.2,
|
||||
adv_h: network_dims.3,
|
||||
num_atoms: hp.num_atoms,
|
||||
branch_0_size: b0,
|
||||
branch_1_size: if is_branching { b1 } else { b0 },
|
||||
branch_2_size: if is_branching { b2 } else { b0 },
|
||||
branch_3_size: if is_branching { b3 } else { b0 },
|
||||
v_min: vr[0],
|
||||
v_max: vr[1],
|
||||
};
|
||||
|
||||
// Synchronous launch + consume — one-shot, no async pipelining
|
||||
// benefit since dev/test runs don't fire per-epoch. Returns the
|
||||
// single-window metrics directly.
|
||||
let metrics = unsafe {
|
||||
evaluator.evaluate_dqn_graphed(
|
||||
&*online_weights_ptr,
|
||||
Some(&*branching_weights_ptr),
|
||||
&dqn_cfg,
|
||||
Some(&mut *fused_ptr),
|
||||
)
|
||||
}.map_err(|e| anyhow::anyhow!(
|
||||
"GPU backtest evaluate_dqn_graphed (extra_eval {:?}): {e}", kind
|
||||
))?;
|
||||
|
||||
// ── HEALTH_DIAG emission ──────────────────────────────────────────
|
||||
let epoch = self.current_epoch;
|
||||
if let Some(m) = metrics.first() {
|
||||
// Phase 1.2.b cost-net sharpe is BLOCKED (LobBar dependency); we
|
||||
// emit the existing kernel's cost-aware Sharpe (from the fused
|
||||
// metrics kernel post-Phase-1.1.b split — `m.sharpe` already
|
||||
// includes tx_cost_bps + spread_cost via the env-step PnL feed)
|
||||
// under the `*_sharpe_net` key. When 1.2.b lands, the consumer
|
||||
// will swap to the dedicated cost-net kernel output without
|
||||
// changing the HEALTH_DIAG key name.
|
||||
match kind {
|
||||
ExtraEvalKind::Dev => tracing::info!(
|
||||
"HEALTH_DIAG[{}]: dev_eval dev_sharpe_net={:.2} dev_calmar={:.2} dev_max_dd={:.2} dev_trades={:.0}",
|
||||
epoch, m.sharpe, m.calmar, m.max_drawdown, m.total_trades,
|
||||
),
|
||||
ExtraEvalKind::Test => tracing::info!(
|
||||
"HEALTH_DIAG[{}]: test_slice fold={} test_sharpe_net={:.2} test_calmar={:.2} test_max_dd={:.2} test_trades={:.0}",
|
||||
epoch,
|
||||
fold_idx.map_or(-1_i64, |f| f as i64),
|
||||
m.sharpe, m.calmar, m.max_drawdown, m.total_trades,
|
||||
),
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"extra_eval ({:?}): no metrics returned (empty window)", kind
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Estimate average Q-value from replay buffer samples for monitoring.
|
||||
///
|
||||
/// Uses the cuBLAS forward path (`fused_ctx.compute_q_values`) — zero Candle involvement.
|
||||
|
||||
@@ -717,6 +717,30 @@ pub struct DQNTrainer {
|
||||
/// Invalidated (set to None) when val_data changes (fold transitions).
|
||||
pub(crate) gpu_evaluator: Option<crate::cuda_pipeline::gpu_backtest_evaluator::GpuBacktestEvaluator>,
|
||||
|
||||
/// SP15 Phase 1.6.b — parallel evaluator for the Q8 dev slice. Lazy-init in
|
||||
/// `launch_extra_eval` after the final fold; consumes `dev_features` /
|
||||
/// `dev_targets`. Independent CUDA-Graph capture from `gpu_evaluator` per
|
||||
/// `pearl_no_host_branches_in_captured_graph` — window-swap on the val
|
||||
/// evaluator would require invalidating the val graph between val and dev
|
||||
/// eval calls. Once-per-training-run lifetime: built right before the
|
||||
/// dev_eval HEALTH_DIAG line is emitted, dropped with the trainer.
|
||||
pub(crate) dev_evaluator: Option<crate::cuda_pipeline::gpu_backtest_evaluator::GpuBacktestEvaluator>,
|
||||
/// SP15 Phase 1.6.b — global OFI-features offset for `dev_features[0]`,
|
||||
/// equal to `dev_start` in `train_walk_forward`. The eval loop computes
|
||||
/// `ofi_features[dev_ofi_offset + i]` for sample `i` of the dev slice,
|
||||
/// mirroring the val path's `ofi_val_offset + i`.
|
||||
pub(crate) dev_ofi_offset: usize,
|
||||
|
||||
/// SP15 Phase 1.7.b — parallel evaluator for the per-fold walk-forward
|
||||
/// test slice. Lazy-init in `launch_extra_eval` once per fold; the previous
|
||||
/// fold's evaluator is dropped at the start of the next fold's test_eval
|
||||
/// call because the slice length can differ between folds and the
|
||||
/// `GpuBacktestEvaluator` allocates step buffers sized for the
|
||||
/// construction-time `max_len`. Independent CUDA-Graph capture from
|
||||
/// `gpu_evaluator` per the same parallel-instances rationale as
|
||||
/// `dev_evaluator` (window-swap on val would force val-graph invalidation).
|
||||
pub(crate) test_evaluator: Option<crate::cuda_pipeline::gpu_backtest_evaluator::GpuBacktestEvaluator>,
|
||||
|
||||
// Phase C: Fill simulation and smart order routing
|
||||
/// Fill simulator for order type-dependent execution modeling
|
||||
pub(crate) fill_simulator: FillSimulator,
|
||||
@@ -1162,6 +1186,12 @@ impl DQNTrainer {
|
||||
.collect();
|
||||
self.dev_features = Some(dev_features_v);
|
||||
self.dev_targets = Some(dev_targets_v);
|
||||
// SP15 Phase 1.6.b: global OFI-features offset for the dev slice.
|
||||
// `init_from_fxcache` (called below) stashes the FULL ofi array on
|
||||
// `self.ofi_features`, so the dev eval reads
|
||||
// `ofi_features[dev_ofi_offset + i]` for sample `i` of the dev
|
||||
// slice — same scheme as `ofi_val_offset` for the val path.
|
||||
self.dev_ofi_offset = dev_start;
|
||||
}
|
||||
if holdout_end > holdout_start {
|
||||
let holdout_features_v: Vec<[f64; 42]> = training_data[holdout_start..holdout_end]
|
||||
@@ -1396,6 +1426,35 @@ impl DQNTrainer {
|
||||
.train_with_data_full_loop_slices(&fold_training_data, Arc::clone(&cb_handle))
|
||||
.await?;
|
||||
|
||||
// SP15 Phase 1.7.b -- fire one-shot eval against this fold's
|
||||
// walk-forward test slice. The set_test_data_from_slices call
|
||||
// above gates on fold.test_end > fold.test_start, so when
|
||||
// test_features is non-empty we know the slice is real. The
|
||||
// helper itself is a no-op fall-through if either slice or the
|
||||
// CUDA stream is missing (CPU-only build path). HEALTH_DIAG line:
|
||||
// HEALTH_DIAG[N]: test_slice fold=K test_sharpe_net=... ...
|
||||
// Phase 1.7 deferred consumer is closed by this call site.
|
||||
if fold.test_end > fold.test_start && self.test_features.is_some() {
|
||||
if let Err(e) = self
|
||||
.launch_extra_eval(
|
||||
metrics::ExtraEvalKind::Test,
|
||||
Some(fold_idx),
|
||||
)
|
||||
.await
|
||||
{
|
||||
// Non-fatal: a failed test_slice run shouldn't abort the
|
||||
// fold loop. Surface the error for diagnostic visibility
|
||||
// and let training continue. Per feedback_no_hiding:
|
||||
// log + continue is allowed for diagnostic emissions
|
||||
// that don't feed downstream consumers (test slice is
|
||||
// observation-only -- no Q-targets, no replay writes).
|
||||
tracing::warn!(
|
||||
"SP15 Phase 1.7.b test_slice eval-call failed for fold {}: {}",
|
||||
fold_idx + 1, e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Store regime distribution in metrics
|
||||
last_metrics.add_metric("regime_trending_pct", trending_pct);
|
||||
last_metrics.add_metric("regime_ranging_pct", ranging_pct);
|
||||
@@ -1435,6 +1494,28 @@ impl DQNTrainer {
|
||||
self.regime_replay_decay_override = 1.0;
|
||||
self.fold_dominant_regime = 1;
|
||||
|
||||
// SP15 Phase 1.6.b -- after the final fold's training+val converge,
|
||||
// fire one-shot dev eval against the Q8 dev slice stashed at the top
|
||||
// of train_walk_forward (gated on dev_quarters > 0). Mirrors
|
||||
// the test_slice call above but consumes dev_features/dev_targets and
|
||||
// emits the dev_eval HEALTH_DIAG line. Phase 1.6 deferred consumer is
|
||||
// closed by this call site. Sealed Q9 holdout is NOT touched -- the
|
||||
// debug_assert sealed-slice guard above catches accidental refactors,
|
||||
// and Phase 4.3 argo-eval-final.sh is the only legitimate Q9 consumer
|
||||
// via a separate eval-only entry point that does NOT call this fn.
|
||||
if self.dev_features.is_some() {
|
||||
if let Err(e) = self
|
||||
.launch_extra_eval(metrics::ExtraEvalKind::Dev, None)
|
||||
.await
|
||||
{
|
||||
// Non-fatal: a failed dev run shouldn't fail train_walk_forward
|
||||
// overall (val and test_slice metrics already emitted, fold
|
||||
// checkpoints already saved). Surface the error and continue
|
||||
// to the gap-analysis block below.
|
||||
tracing::warn!("SP15 Phase 1.6.b dev_eval call failed: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Task 14: IS->OOS gap analysis (R^2 between volatile% and Sharpe)
|
||||
let n = fold_volatile_pcts.len() as f64;
|
||||
if n >= 3.0 {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user