The hardcoded WeightedByRealizedSharpe path from C7 is now joined by
a stack-based bytecode interpreter that consumes Strategy::flatten()
output, unlocking RegimeSwitch / Portfolio / non-default Ensemble /
single-Leaf compositions specified via policy-grid YAML.
cuda/decision_policy.cu — new kernel `decision_policy_program`:
Stack-based VM with parallel (value, attribution_mask) stacks.
Opcodes mirror src/policy/mod.rs::OpCode exactly:
NoOp / PushScalar / EvalRegime / BranchIfRegime
EmitPerHorizonSize (computes sized intent from alpha[h] +
IsvKellyState[h] using same Kelly + ISV-cap formula as the
hardcoded default)
AggMean / AggWeightedSharpe / AggMaxConfidence (pop n values,
push aggregated, OR attribution masks)
ApplyConflict (v1 no-op, reserved for Portfolio)
WriteOrder (terminal — converts top-of-stack to market_target +
open_horizon_masks attribution if currently flat)
AggWeightedSharpe recovers the source horizon from a single-bit
attribution mask to look up recent_sharpe; multi-bit masks (nested
aggregators that collapsed horizons) fall back to uniform weight.
decision_policy_default extended with a program_lens param: skips any
backtest whose plen > 0 (the program kernel handled it). The two
kernels run sequentially in step_decision_with_latency with mutual
exclusivity on each backtest slot.
LobSimCuda gains:
upload_program(b, &Program) — uploads a Strategy::flatten() output
to backtest b's slot in program_table_d, updates program_lens_d.
set_regime(b, regime_id) — writes regimes_d for OP_EVAL_REGIME /
OP_BRANCH_IF_REGIME consumption.
BacktestHarnessConfig.strategies (Vec<Strategy>) — empty means every
cell uses the hardcoded default; non-empty len must equal n_parallel
and each strategy is flattened + uploaded at construction.
bin/fxt-backtest --policy-grid <yaml> path now actually plumbs through
to the kernel (was parsed-but-ignored in C9). Empty grid keeps the
default behaviour.
New fixture decision_program_h4_only: uploads Strategy::Leaf(h4_only)
flattened to (EmitPerHorizonSize, WriteOrder) — 2 instructions, 16
bytes — and verifies the bytecode kernel produces equivalent end-state
to the hardcoded default (3 lot buy at vwap=5500). Proves the VM
dispatcher works end-to-end.
12/12 GPU fixtures green. 33 lib unit tests + 3 Ring 2 fuzz tests
still green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
195 lines
7.7 KiB
Rust
195 lines
7.7 KiB
Rust
//! BacktestHarness — orchestrator wiring ml-alpha's MultiHorizonLoader
|
|
//! and CfcTrunk into the LobSimCuda. See spec §7.
|
|
//!
|
|
//! 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.
|
|
|
|
use anyhow::{Context, Result};
|
|
use ml_alpha::cfc::trunk::CfcTrunk;
|
|
use ml_alpha::data::loader::{
|
|
discover_mbp10_files_sorted, MultiHorizonLoader, MultiHorizonLoaderConfig,
|
|
};
|
|
use ml_core::device::MlDevice;
|
|
use std::path::PathBuf;
|
|
|
|
use crate::sim::LobSimCuda;
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct BacktestHarnessConfig {
|
|
pub data_root: PathBuf,
|
|
pub predecoded_dir: PathBuf,
|
|
pub n_parallel: usize,
|
|
pub decision_stride: usize,
|
|
pub target_annual_vol_units: f32,
|
|
pub annualisation_factor: f32,
|
|
pub max_lots: u16,
|
|
/// Max number of inference inputs to consume. 0 = exhaust loader.
|
|
pub max_events: u64,
|
|
/// Per-backtest Strategy compositions. Empty = every cell uses the
|
|
/// hardcoded default policy (WeightedByRealizedSharpe across all 5
|
|
/// horizons). Non-empty: len MUST equal n_parallel; each entry is
|
|
/// flattened to bytecode and uploaded to its backtest slot.
|
|
pub strategies: Vec<crate::policy::Strategy>,
|
|
/// Total submission→fill latency in nanoseconds. 0 = immediate
|
|
/// (legacy path). 100_000_000 = 100ms IBKR + Scaleway baseline.
|
|
/// When non-zero, the orchestrator submits aggressive IOC limits
|
|
/// with `active=2`/`arrival_ts_ns = current + latency_ns` so the
|
|
/// book can move under them during the in-flight window.
|
|
pub latency_ns: u32,
|
|
}
|
|
|
|
pub struct BacktestHarness {
|
|
cfg: BacktestHarnessConfig,
|
|
loader: MultiHorizonLoader,
|
|
trunk: CfcTrunk,
|
|
sim: LobSimCuda,
|
|
decision_count: u64,
|
|
event_count: u64,
|
|
/// Per-cell cumulative P&L curve in USD, sampled once per event.
|
|
/// `pnl_curves[b][i]` = realized_pnl USD for backtest b after event i.
|
|
pnl_curves: Vec<Vec<f32>>,
|
|
}
|
|
|
|
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).
|
|
pub fn new(
|
|
cfg: BacktestHarnessConfig,
|
|
dev: &MlDevice,
|
|
mut trunk: CfcTrunk,
|
|
) -> Result<Self> {
|
|
if !cfg.strategies.is_empty() {
|
|
anyhow::ensure!(
|
|
cfg.strategies.len() == cfg.n_parallel,
|
|
"strategies len {} ≠ n_parallel {}",
|
|
cfg.strategies.len(),
|
|
cfg.n_parallel
|
|
);
|
|
}
|
|
let files = discover_mbp10_files_sorted(&cfg.data_root)?;
|
|
let loader_cfg = MultiHorizonLoaderConfig {
|
|
files,
|
|
predecoded_dir: cfg.predecoded_dir.clone(),
|
|
seq_len: 1,
|
|
horizons: [30, 100, 300, 1000, 6000],
|
|
n_max_sequences: 0,
|
|
seed: 0,
|
|
decision_stride: cfg.decision_stride,
|
|
inference_only: true,
|
|
};
|
|
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 mut sim = LobSimCuda::new(cfg.n_parallel, dev)?;
|
|
|
|
// Upload per-cell bytecode programs if the caller provided any.
|
|
// Empty Vec means every cell uses the hardcoded default policy.
|
|
for (b, strat) in cfg.strategies.iter().enumerate() {
|
|
let prog = strat.flatten();
|
|
sim.upload_program(b, &prog)?;
|
|
}
|
|
|
|
let pnl_curves = (0..cfg.n_parallel).map(|_| Vec::with_capacity(1024)).collect();
|
|
Ok(Self {
|
|
cfg,
|
|
loader,
|
|
trunk,
|
|
sim,
|
|
decision_count: 0,
|
|
event_count: 0,
|
|
pnl_curves,
|
|
})
|
|
}
|
|
|
|
/// Drive the chronological event stream → trunk inference → sim
|
|
/// decision loop until either the stream is exhausted or
|
|
/// cfg.max_events is reached. See spec §7 orchestrator code.
|
|
pub fn run(&mut self) -> Result<RunStats> {
|
|
let stride = self.cfg.decision_stride.max(1) as u64;
|
|
let mut total_decisions = 0u64;
|
|
|
|
while let Some(raw) = self.loader.next_inference_input()? {
|
|
// Apply snapshot to every backtest's book.
|
|
self.sim.apply_snapshot(&raw.bid_px, &raw.bid_sz, &raw.ask_px, &raw.ask_sz)?;
|
|
|
|
// Process resting orders (in-flight promotion, queue decay,
|
|
// marketability check, stop triggers, OCO). Trade-flow signal
|
|
// is currently unavailable from Mbp10RawInput (deferred to a
|
|
// trades-feed integration); pass 0.0 so only the price-cross
|
|
// marketability branch fires.
|
|
self.sim.step_resting_orders(raw.ts_ns, 0.0)?;
|
|
|
|
// 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)?;
|
|
self.sim.step_decision_with_latency(
|
|
raw.ts_ns,
|
|
self.cfg.target_annual_vol_units,
|
|
self.cfg.annualisation_factor,
|
|
self.cfg.max_lots,
|
|
self.cfg.latency_ns,
|
|
)?;
|
|
self.decision_count += 1;
|
|
total_decisions += 1;
|
|
}
|
|
|
|
// Sample per-cell realised P&L (price-units → USD via $50/index-pt).
|
|
for b in 0..self.cfg.n_parallel {
|
|
let pos = self.sim.read_pos(b)?;
|
|
self.pnl_curves[b].push(pos.realized_pnl * 50.0);
|
|
}
|
|
|
|
self.event_count += 1;
|
|
if self.cfg.max_events > 0 && self.event_count >= self.cfg.max_events {
|
|
break;
|
|
}
|
|
}
|
|
|
|
Ok(RunStats {
|
|
events_processed: self.event_count,
|
|
decisions_taken: total_decisions,
|
|
})
|
|
}
|
|
|
|
/// After `run()` completes, write per-cell artifacts to
|
|
/// `<out_dir>/cell_<b>/{summary.json,trades.csv,pnl_curve.bin}`.
|
|
pub fn write_artifacts(&self, out_dir: &std::path::Path) -> Result<()> {
|
|
std::fs::create_dir_all(out_dir)
|
|
.with_context(|| format!("create out dir {}", out_dir.display()))?;
|
|
for b in 0..self.cfg.n_parallel {
|
|
let cell_dir = out_dir.join(format!("cell_{b:04}"));
|
|
std::fs::create_dir_all(&cell_dir)
|
|
.with_context(|| format!("create cell dir {}", cell_dir.display()))?;
|
|
let records = self.sim.read_trade_records(b)?;
|
|
let curve = &self.pnl_curves[b];
|
|
let summary = crate::artifacts::compute_summary(&records, curve);
|
|
crate::artifacts::write_summary(&cell_dir.join("summary.json"), &summary)?;
|
|
crate::artifacts::write_trades_csv(&cell_dir.join("trades.csv"), &records)?;
|
|
crate::artifacts::write_pnl_curve_bin(&cell_dir.join("pnl_curve.bin"), curve)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn sim(&self) -> &LobSimCuda { &self.sim }
|
|
pub fn event_count(&self) -> u64 { self.event_count }
|
|
pub fn decision_count(&self) -> u64 { self.decision_count }
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct RunStats {
|
|
pub events_processed: u64,
|
|
pub decisions_taken: u64,
|
|
}
|