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:
@@ -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")?;
|
||||
|
||||
|
||||
@@ -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]`
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user