refactor(ml-backtesting): drive forward via PerceptionTrainer.forward_only

BacktestHarness now owns a PerceptionTrainer (in inference role) instead
of a raw CfcTrunk. The sliding K-window of recent snapshots accumulates
in the harness; at each decision-stride boundary (and only once the
window has reached cfg.seq_len), the harness calls
trainer.forward_only(&window) and broadcasts the last K position's
per-horizon probs to the LobSim.

fxt-backtest's main.rs constructs the trainer via
PerceptionTrainer::from_checkpoint when --checkpoint is supplied (else
random init for noise baseline).

Why this shape: PerceptionTrainer's evaluate_batched already runs the
full inference chain (snap → vsn → mamba2 → ln → mamba2 → ln →
attn_pool → cfc K-loop → grn heads) correctly. Duplicating that 400-line
forward chain on CfcTrunk would double the surface area for the same
result — the trunk's role is weight-source-of-truth (achieved in X1-X9),
not kernel-launch orchestration.

End-to-end status: alpha_train emits Checkpoint files via X14 wiring;
fxt-backtest now loads those Checkpoints via from_checkpoint and drives
forward via forward_only. Phase 2 (Argo runtime: training → smoke →
threshold pre-reg → 560-cell deployability sweep → verdict) is unblocked.

Adds PerceptionTrainer::config() accessor so the harness can read seq_len.

Verification: ml-alpha + ml-backtesting + fxt-backtest all build clean.
This commit is contained in:
jgrusewski
2026-05-19 09:06:26 +02:00
parent 4f888abbf0
commit 395e0d3000
3 changed files with 89 additions and 32 deletions

View File

@@ -14,7 +14,9 @@
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use ml_alpha::cfc::trunk::{CfcConfig, CfcTrunk};
use ml_alpha::cfc::trunk::CfcConfig;
use ml_alpha::heads::N_HORIZONS;
use ml_alpha::trainer::perception::{PerceptionTrainer, PerceptionTrainerConfig};
use ml_backtesting::aggregate::aggregate_sweep_dir;
use ml_backtesting::harness::{BacktestHarness, BacktestHarnessConfig};
use ml_backtesting::policy::Strategy;
@@ -267,17 +269,30 @@ fn run(args: RunArgs) -> Result<()> {
};
let dev = MlDevice::cuda(0).map_err(|e| anyhow::anyhow!("MlDevice::cuda(0): {e}"))?;
let trunk = if let Some(ckpt_path) = &args.checkpoint {
tracing::info!(checkpoint = %ckpt_path.display(), "loading CfcTrunk checkpoint");
CfcTrunk::load_checkpoint(&dev, &CfcConfig::default(), ckpt_path)
// X11 inference path: backtester drives forward via PerceptionTrainer
// (in inference role). The trainer wraps a CfcTrunk loaded from a
// Checkpoint file; forward kernels live on the trainer and read
// weights from `self.trunk` (the post-X1-X9 source of truth).
let trainer_cfg = PerceptionTrainerConfig {
n_batch: 1,
seq_len: 32,
..Default::default()
};
let trainer = if let Some(ckpt_path) = &args.checkpoint {
tracing::info!(checkpoint = %ckpt_path.display(), "loading PerceptionTrainer from checkpoint");
PerceptionTrainer::from_checkpoint(&dev, &trainer_cfg, ckpt_path)
.with_context(|| format!("load checkpoint {}", ckpt_path.display()))?
} else {
tracing::warn!(
seed = args.seed,
"no --checkpoint provided; using new_random trunk (random init — backtest results are NOISE)"
"no --checkpoint provided; using random-init PerceptionTrainer (backtest results are NOISE)"
);
CfcTrunk::new_random(&dev, &CfcConfig::default(), args.seed)
.context("CfcTrunk::new_random")?
let _ = CfcConfig::default; // CfcConfig used transitively via trunk_cfg
let _ = N_HORIZONS;
let mut cfg2 = trainer_cfg.clone();
cfg2.seed = args.seed;
PerceptionTrainer::new(&dev, &cfg2)
.context("PerceptionTrainer::new")?
};
let cfg = BacktestHarnessConfig {
@@ -296,7 +311,7 @@ fn run(args: RunArgs) -> Result<()> {
std::fs::create_dir_all(&args.out)
.with_context(|| format!("create out dir {}", args.out.display()))?;
let mut harness = BacktestHarness::new(cfg, &dev, trunk).context("BacktestHarness::new")?;
let mut harness = BacktestHarness::new(cfg, &dev, trainer).context("BacktestHarness::new")?;
let stats = harness.run().context("BacktestHarness::run")?;
harness.write_artifacts(&args.out).context("write_artifacts")?;

View File

@@ -2316,6 +2316,13 @@ impl PerceptionTrainer {
self.trunk.save_checkpoint(path).context("trunk save_checkpoint")
}
/// X11: accessor for the trainer's `cfg` field — read-only view of
/// the construction-time configuration. Used by BacktestHarness to
/// size its snapshot window from `seq_len`.
pub fn config(&self) -> &PerceptionTrainerConfig {
&self.cfg
}
/// X11 inference entry point: forward-only pass over `snapshots`
/// (length `cfg.n_batch * cfg.seq_len` per the trainer's batching).
/// Returns per-horizon probabilities flattened as `[K, B, N_HORIZONS]`

View File

@@ -1,20 +1,24 @@
//! BacktestHarness — orchestrator wiring ml-alpha's MultiHorizonLoader
//! and CfcTrunk into the LobSimCuda. See spec §7.
//! and PerceptionTrainer (in inference role) into the LobSimCuda.
//!
//! Design: the harness reuses the trainer's loader + captured graph
//! verbatim. Train-vs-deploy skew is structurally impossible because the
//! input struct (`Mbp10RawInput`), feature-assembly cubin
//! (`snap_feature_assemble`), and graph capture (`capture_graph_a` →
//! `perception_forward_captured`) are the same code paths used in
//! training. The harness's only job is to walk the chronological
//! snapshot stream and drive the sim's decision loop.
//! Design: the harness reuses the trainer's loader + forward chain
//! (PerceptionTrainer::evaluate_batched, accessed via `forward_only`)
//! so train-vs-deploy skew is structurally impossible. The input struct
//! (`Mbp10RawInput`), feature-assembly cubin, and full perception
//! forward are the same code paths used in training. The harness's
//! only job is to walk the chronological snapshot stream, maintain a
//! sliding K-window for the recurrent Mamba2 context, drive the sim's
//! decision loop with the trainer's per-horizon probability output.
use anyhow::{Context, Result};
use ml_alpha::cfc::trunk::CfcTrunk;
use ml_alpha::cfc::snap_features::Mbp10RawInput;
use ml_alpha::data::loader::{
discover_mbp10_files_sorted, MultiHorizonLoader, MultiHorizonLoaderConfig,
};
use ml_alpha::heads::N_HORIZONS;
use ml_alpha::trainer::perception::PerceptionTrainer;
use ml_core::device::MlDevice;
use std::collections::VecDeque;
use std::path::PathBuf;
use crate::sim::LobSimCuda;
@@ -46,7 +50,17 @@ pub struct BacktestHarnessConfig {
pub struct BacktestHarness {
cfg: BacktestHarnessConfig,
loader: MultiHorizonLoader,
trunk: CfcTrunk,
/// PerceptionTrainer in inference role — owns the trunk (loaded from
/// Checkpoint) and the kernel-launch scratches. Forward driven via
/// `forward_only` at decision-stride boundaries.
trainer: PerceptionTrainer,
/// Sliding K-window of recent snapshots for the recurrent forward.
/// At every decision-stride boundary, when the window has reached
/// `seq_len` entries, we call `trainer.forward_only(&window)` and
/// take the last K position's probs.
snapshot_window: VecDeque<Mbp10RawInput>,
/// Window capacity = trainer's seq_len, captured at construction.
seq_len: usize,
sim: LobSimCuda,
decision_count: u64,
event_count: u64,
@@ -56,13 +70,14 @@ pub struct BacktestHarness {
}
impl BacktestHarness {
/// Construct with an externally-built MlDevice + trunk. v1 ships
/// with random-init trunk; loading from a checkpoint file is a
/// follow-up (no checkpoint format pinned in ml-alpha yet).
/// Construct with an externally-built MlDevice + PerceptionTrainer.
/// Caller loads the trainer from a Checkpoint via
/// `PerceptionTrainer::from_checkpoint`. The harness reads
/// `trainer.config().seq_len` to size its sliding snapshot window.
pub fn new(
cfg: BacktestHarnessConfig,
dev: &MlDevice,
mut trunk: CfcTrunk,
trainer: PerceptionTrainer,
) -> Result<Self> {
if !cfg.strategies.is_empty() {
anyhow::ensure!(
@@ -85,10 +100,12 @@ impl BacktestHarness {
};
let loader = MultiHorizonLoader::new(&loader_cfg)?;
// Capture the trunk's perception graph once using the first snapshot
// as a template — required before any perception_forward_captured call.
let first = loader.peek_first().context("peek_first")?;
trunk.capture_graph_a(&first).context("capture_graph_a")?;
let seq_len = trainer.config().seq_len;
anyhow::ensure!(
trainer.config().n_batch == 1,
"BacktestHarness requires PerceptionTrainer with n_batch=1 (got {})",
trainer.config().n_batch
);
let mut sim = LobSimCuda::new(cfg.n_parallel, dev)?;
@@ -103,7 +120,9 @@ impl BacktestHarness {
Ok(Self {
cfg,
loader,
trunk,
trainer,
snapshot_window: VecDeque::with_capacity(seq_len),
seq_len,
sim,
decision_count: 0,
event_count: 0,
@@ -129,11 +148,27 @@ impl BacktestHarness {
// buyer-initiated, negative = seller-initiated.
self.sim.step_resting_orders(raw.ts_ns, raw.trade_signed_vol)?;
// At decision-stride boundaries: run trunk inference + sim decision.
if self.event_count % stride == 0 {
self.trunk.update_input_buffers(&raw)?;
let (probs, _proj) = self.trunk.perception_forward_captured()?;
self.sim.broadcast_alpha(&probs)?;
// Maintain the sliding K-window for the recurrent forward.
// Push the current snapshot, evict the oldest if at capacity.
if self.snapshot_window.len() == self.seq_len {
self.snapshot_window.pop_front();
}
self.snapshot_window.push_back(raw.clone());
// At decision-stride boundaries: run forward inference + sim
// decision. Skip until the window is full (insufficient
// context for Mamba2's recurrent state).
if self.event_count % stride == 0 && self.snapshot_window.len() == self.seq_len {
let window: Vec<Mbp10RawInput> = self.snapshot_window.iter().cloned().collect();
let probs_all = self.trainer.forward_only(&window)
.context("trainer.forward_only")?;
// probs_all is [K * B * N_HORIZONS] with B=1; take the
// LAST K position's probs as the decision signal.
let last_probs_start = (self.seq_len - 1) * N_HORIZONS;
let last_probs: [f32; N_HORIZONS] = probs_all[last_probs_start..]
.try_into()
.context("slice last K probs")?;
self.sim.broadcast_alpha(&last_probs)?;
self.sim.step_decision_with_latency(
raw.ts_ns,
self.cfg.target_annual_vol_units,