refactor(per-horizon): N_HORIZONS 5→3 — ml-backtesting full propagation

Source migration:
- crates/ml-backtesting/src/lob/mod.rs:5 + policy/mod.rs:18: local
  const N_HORIZONS 5→3 via re-export from ml_alpha::heads::N_HORIZONS
  (single source of truth across crates)
- crates/ml-backtesting/src/sim/mod.rs:1751,1773,1784: [IsvKellyStateHost; 5]
  array literals → ; N_HORIZONS]
- crates/ml-backtesting/cuda/lob_state.cuh:9: #define N_HORIZONS 5→3
  (this triggers cubin rebuild of decision_policy + 4 other ml-backtesting
  kernels that #include this header)

Test migration (8 test files + 2 JSON fixtures):
- threshold_and_cost.rs, decision_floor_coldstart.rs, parallel_sim_
  correctness.rs, stop_controller.rs (37+10+1 broadcast_alpha calls),
  lob_sim_integrated_fuzz.rs, lob_sim_fixtures.rs: hardcoded [f32; 5]
  alpha-probs and [IsvKellyStateHost; 5] arrays → N_HORIZONS-sized via
  std::array::from_fn or [v; N_HORIZONS] literals
- trainer_parity.rs:34 + ring3_replay.rs:47: horizons literal
  [30,100,300,1000,6000] → ml_alpha::heads::HORIZONS
- fixtures/decision_alpha_buy_close.json + decision_program_h4_only.json:
  5-element warm_start_isv_kelly / alpha_probs / expected_isv_kelly_after
  trimmed to 3 elements; active horizon relocated to N_HORIZONS-1

Library lib-test rewrite (per pearl_tests_must_prove_not_lock_observations):
- crates/ml-backtesting/src/policy/mod.rs:197-233: lib tests
  default_strategy_has_5_horizon_leaves + ..._flattens_to_5_emits...
  renamed to N_HORIZONS-parametric form (observed-value 5 and 7
  were bug-locks).

cargo check -p ml-backtesting --all-targets: clean.
cargo test -p ml-backtesting --lib: 33 passed.
cargo test -p ml-backtesting --tests (non-CUDA, non-fixture-data): 2 passed,
53 ignored (CUDA-gated or FOXHUNT_TEST_DATA-gated).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-22 01:37:23 +02:00
parent afb6d73396
commit 0c8b4b5608
14 changed files with 95 additions and 100 deletions

View File

@@ -6,7 +6,9 @@
#ifndef LOB_STATE_CUH
#define LOB_STATE_CUH
#define N_HORIZONS 5
// Must stay in sync with crates/ml-alpha/src/heads.rs::N_HORIZONS and
// crates/ml-backtesting/src/lob/mod.rs::N_HORIZONS.
#define N_HORIZONS 3
#define MAX_LIMITS 32
#define MAX_STOPS 16

View File

@@ -2,7 +2,8 @@
//! See spec §2 (CUDA data layout) and §5b (state ownership).
pub const BOOK_LEVELS: usize = 10;
pub const N_HORIZONS: usize = 5;
/// Re-export of `ml_alpha::heads::N_HORIZONS` — single source of truth.
pub const N_HORIZONS: usize = ml_alpha::heads::N_HORIZONS;
pub const MAX_LIMITS: usize = 32;
pub const MAX_STOPS: usize = 16;
/// Bytes per LimitSlot — matches cuda/lob_state.cuh::LimitSlot.

View File

@@ -14,8 +14,8 @@ pub use sizing::{IsvKellyStateHost, SizingPolicyId};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Mirrors `crates/ml-alpha/src/heads.rs::N_HORIZONS`.
pub const N_HORIZONS: usize = 5;
/// Re-export of `ml_alpha::heads::N_HORIZONS` — single source of truth.
pub const N_HORIZONS: usize = ml_alpha::heads::N_HORIZONS;
/// One horizon-anchored leaf strategy. See spec §5 (per-horizon ISV-Kelly) + §6.
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -194,11 +194,11 @@ mod tests {
use super::*;
#[test]
fn default_strategy_has_5_horizon_leaves() {
fn default_strategy_has_one_leaf_per_horizon() {
let s = Strategy::default_for(3);
match s {
Strategy::Ensemble { children, aggregator } => {
assert_eq!(children.len(), 5);
assert_eq!(children.len(), N_HORIZONS);
assert!(matches!(
aggregator,
EnsembleAggregator::WeightedByRealizedSharpe
@@ -218,18 +218,19 @@ mod tests {
}
#[test]
fn default_strategy_flattens_to_5_emits_plus_agg_plus_write() {
fn default_strategy_flattens_to_emits_plus_agg_plus_write() {
let s = Strategy::default_for(3);
let p = s.flatten();
assert_eq!(p.instructions.len(), 7);
for i in 0..5 {
// N_HORIZONS EmitPerHorizonSize + 1 AggWeightedSharpe + 1 WriteOrder.
assert_eq!(p.instructions.len(), N_HORIZONS + 2);
for i in 0..N_HORIZONS {
assert_eq!(p.instructions[i].op, OpCode::EmitPerHorizonSize as u8);
assert_eq!(p.instructions[i].arg0, i as u8);
assert_eq!(p.instructions[i].arg1, 3);
}
assert_eq!(p.instructions[5].op, OpCode::AggWeightedSharpe as u8);
assert_eq!(p.instructions[5].arg0, 5);
assert_eq!(p.instructions[6].op, OpCode::WriteOrder as u8);
assert_eq!(p.instructions[N_HORIZONS].op, OpCode::AggWeightedSharpe as u8);
assert_eq!(p.instructions[N_HORIZONS].arg0, N_HORIZONS as u8);
assert_eq!(p.instructions[N_HORIZONS + 1].op, OpCode::WriteOrder as u8);
}
#[test]

View File

@@ -1748,7 +1748,7 @@ impl LobSimCuda {
pub fn write_isv_kelly(
&mut self,
backtest_idx: usize,
states: &[crate::policy::IsvKellyStateHost; 5],
states: &[crate::policy::IsvKellyStateHost; N_HORIZONS],
) -> Result<()> {
anyhow::ensure!(
backtest_idx < self.n_backtests,
@@ -1770,7 +1770,7 @@ impl LobSimCuda {
pub fn read_isv_kelly(
&self,
backtest_idx: usize,
) -> Result<[crate::policy::IsvKellyStateHost; 5]> {
) -> Result<[crate::policy::IsvKellyStateHost; N_HORIZONS]> {
anyhow::ensure!(
backtest_idx < self.n_backtests,
"backtest_idx {} >= n_backtests {}",
@@ -1781,7 +1781,7 @@ impl LobSimCuda {
self.stream.memcpy_dtoh(&self.isv_kelly_d, raw.as_mut_slice())?;
let off = backtest_idx * N_HORIZONS * ISV_KELLY_STATE_BYTES;
let slice = &raw[off..off + N_HORIZONS * ISV_KELLY_STATE_BYTES];
let mut out = [crate::policy::IsvKellyStateHost::default(); 5];
let mut out = [crate::policy::IsvKellyStateHost::default(); N_HORIZONS];
let bytes_out: &mut [u8] = bytemuck::cast_slice_mut(&mut out);
bytes_out.copy_from_slice(slice);
Ok(out)

View File

@@ -64,7 +64,7 @@ fn cold_start_sentinel_state_still_fires_a_trade() -> Result<()> {
// Do NOT seed isv_kelly — leave at zeros (alloc_zeros' sentinel).
// Strong directional alpha across all horizons → conviction-driven
// sig_mag = 0.6 for every horizon, dir = +1.
sim.broadcast_alpha(&[0.8, 0.8, 0.8, 0.8, 0.8])?;
sim.broadcast_alpha(&[0.8; N_HORIZONS])?;
sim.step_decision_with_latency(0, &cfg_uniform(1, 0.20, 0.10))?;
let (side, size) = sim.read_market_target(0)?;
assert_eq!(side, 0, "cold-start with p_h=0.8 must produce a long; got side={side}");
@@ -96,7 +96,7 @@ fn post_first_loss_state_does_not_lock_out_further_trades() -> Result<()> {
// Seed isv_kelly_d with the exact state pattern the smoke produced:
// one closed-loss trade, large realised_return_var. Pre-fix, the
// variance-derived cap collapses to ~0.
let post_loss: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
let post_loss: [IsvKellyStateHost; N_HORIZONS] = std::array::from_fn(|_| IsvKellyStateHost {
pnl_ema_win: 0.0,
pnl_ema_loss: 10.18, // magnitude of the lone loss return
win_rate_ema: 0.0,
@@ -105,7 +105,7 @@ fn post_first_loss_state_does_not_lock_out_further_trades() -> Result<()> {
recent_sharpe: -1.0, // very negative — would have starved the weight side too
});
sim.write_isv_kelly(0, &post_loss)?;
sim.broadcast_alpha(&[0.8, 0.8, 0.8, 0.8, 0.8])?;
sim.broadcast_alpha(&[0.8; N_HORIZONS])?;
sim.step_decision_with_latency(0, &cfg_uniform(1, 0.20, 0.10))?;
let (side, size) = sim.read_market_target(0)?;
assert_eq!(side, 0, "post-loss state must still fire a long with strong alpha (got side={side})");
@@ -151,7 +151,7 @@ fn cold_start_stopgap_bytecode_vm_fires_a_trade() -> Result<()> {
let prog = max_conf.flatten();
sim.upload_program(0, &prog)?;
sim.broadcast_alpha(&[0.8, 0.8, 0.8, 0.8, 0.8])?;
sim.broadcast_alpha(&[0.8; N_HORIZONS])?;
sim.step_decision_with_latency(0, &cfg_uniform(1, 0.20, 0.10))?;
let (side, size) = sim.read_market_target(0)?;
eprintln!("stopgap cold-start single-step: side={side} size={size}");

View File

@@ -3,8 +3,6 @@
"n_backtests": 1,
"warm_start_isv_kelly": [
[
{ "pnl_ema_win": 0.0, "pnl_ema_loss": 0.0, "win_rate_ema": 0.0, "n_trades_seen": 0, "realised_return_var": 0.0, "recent_sharpe": 0.0 },
{ "pnl_ema_win": 0.0, "pnl_ema_loss": 0.0, "win_rate_ema": 0.0, "n_trades_seen": 0, "realised_return_var": 0.0, "recent_sharpe": 0.0 },
{ "pnl_ema_win": 0.0, "pnl_ema_loss": 0.0, "win_rate_ema": 0.0, "n_trades_seen": 0, "realised_return_var": 0.0, "recent_sharpe": 0.0 },
{ "pnl_ema_win": 0.0, "pnl_ema_loss": 0.0, "win_rate_ema": 0.0, "n_trades_seen": 0, "realised_return_var": 0.0, "recent_sharpe": 0.0 },
{ "pnl_ema_win": 2.0, "pnl_ema_loss": 0.5, "win_rate_ema": 0.8, "n_trades_seen": 50, "realised_return_var": 0.25, "recent_sharpe": 1.0 }
@@ -20,7 +18,7 @@
},
{
"type": "decision",
"alpha_probs": [0.5, 0.5, 0.5, 0.5, 0.9],
"alpha_probs": [0.5, 0.5, 0.9],
"target_annual_vol_units": 50.0,
"annualisation_factor": 1.0,
"max_lots": 5,
@@ -35,7 +33,7 @@
},
{
"type": "decision",
"alpha_probs": [0.5, 0.5, 0.5, 0.5, 0.1],
"alpha_probs": [0.5, 0.5, 0.1],
"target_annual_vol_units": 50.0,
"annualisation_factor": 1.0,
"max_lots": 5,
@@ -51,8 +49,6 @@
"expected_min_trade_records": 1,
"expected_isv_kelly_after": [
[
{ "n_trades_seen_min": 0, "n_trades_seen_max": 0 },
{ "n_trades_seen_min": 0, "n_trades_seen_max": 0 },
{ "n_trades_seen_min": 0, "n_trades_seen_max": 0 },
{ "n_trades_seen_min": 0, "n_trades_seen_max": 0 },
{ "n_trades_seen_min": 51, "n_trades_seen_max": 51 }

View File

@@ -1,11 +1,9 @@
{
"name": "decision_program_h4_only",
"n_backtests": 1,
"comment": "Uploads a custom Strategy bytecode program (single Leaf at horizon 4) that bypasses the hardcoded WeightedByRealizedSharpe default. Should produce equivalent end-state when h4 is the only weighted horizon.",
"comment": "Uploads a custom Strategy bytecode program (single Leaf at horizon N_HORIZONS-1) that bypasses the hardcoded WeightedByRealizedSharpe default. Should produce equivalent end-state when only the last horizon is weighted.",
"warm_start_isv_kelly": [
[
{ "pnl_ema_win": 0.0, "pnl_ema_loss": 0.0, "win_rate_ema": 0.0, "n_trades_seen": 0, "realised_return_var": 0.0, "recent_sharpe": 0.0 },
{ "pnl_ema_win": 0.0, "pnl_ema_loss": 0.0, "win_rate_ema": 0.0, "n_trades_seen": 0, "realised_return_var": 0.0, "recent_sharpe": 0.0 },
{ "pnl_ema_win": 0.0, "pnl_ema_loss": 0.0, "win_rate_ema": 0.0, "n_trades_seen": 0, "realised_return_var": 0.0, "recent_sharpe": 0.0 },
{ "pnl_ema_win": 0.0, "pnl_ema_loss": 0.0, "win_rate_ema": 0.0, "n_trades_seen": 0, "realised_return_var": 0.0, "recent_sharpe": 0.0 },
{ "pnl_ema_win": 2.0, "pnl_ema_loss": 0.5, "win_rate_ema": 0.8, "n_trades_seen": 50, "realised_return_var": 0.25, "recent_sharpe": 1.0 }
@@ -22,7 +20,7 @@
},
{
"type": "decision",
"alpha_probs": [0.5, 0.5, 0.5, 0.5, 0.9],
"alpha_probs": [0.5, 0.5, 0.9],
"target_annual_vol_units": 50.0,
"annualisation_factor": 1.0,
"max_lots": 5,

View File

@@ -5,7 +5,7 @@
use anyhow::{Context, Result};
use ml_backtesting::lob::BOOK_LEVELS;
use ml_backtesting::policy::IsvKellyStateHost;
use ml_backtesting::policy::{IsvKellyStateHost, N_HORIZONS};
use ml_backtesting::sim::LobSimCuda;
use ml_core::device::MlDevice;
use serde::Deserialize;
@@ -28,7 +28,7 @@ enum FixtureEvent {
ts_ns: u64,
},
Decision {
alpha_probs: [f32; 5],
alpha_probs: [f32; N_HORIZONS],
target_annual_vol_units: f32,
annualisation_factor: f32,
max_lots: u16,
@@ -152,11 +152,11 @@ struct Fixture {
#[serde(default)]
expected_trade_records: Vec<ExpectedTradeRecord>,
#[serde(default)]
warm_start_isv_kelly: Vec<[IsvKellyFixture; 5]>,
warm_start_isv_kelly: Vec<[IsvKellyFixture; N_HORIZONS]>,
#[serde(default)]
expected_min_trade_records: usize,
#[serde(default)]
expected_isv_kelly_after: Vec<[ExpectedIsvKelly; 5]>,
expected_isv_kelly_after: Vec<[ExpectedIsvKelly; N_HORIZONS]>,
#[serde(default)]
expected_limit_slot_states: Vec<ExpectedSlotState>,
#[serde(default)]
@@ -202,11 +202,12 @@ fn run_book_fixture(path: &Path) -> Result<()> {
.map_err(|e| anyhow::anyhow!("cuda device: {e}"))?;
let mut sim = LobSimCuda::new(fx.n_backtests, &dev)?;
// Upload a single-leaf h4 Strategy program for backtest 0 if requested.
// Upload a single-leaf longest-horizon Strategy program for backtest 0
// if requested. Horizon idx = N_HORIZONS-1 (longest available horizon).
if fx.upload_h4_leaf_program {
use ml_backtesting::policy::{SizingPolicyId, Strategy, StrategyConfig};
let leaf = Strategy::Leaf(StrategyConfig {
horizon_idx: 4,
horizon_idx: (N_HORIZONS - 1) as u8,
sizing_policy: SizingPolicyId::IsvKelly,
max_concurrent_lots: 5,
});
@@ -216,13 +217,8 @@ fn run_book_fixture(path: &Path) -> Result<()> {
// Apply ISV-Kelly warm-start if provided (one row per backtest).
for (b, states) in fx.warm_start_isv_kelly.iter().enumerate() {
let host_states: [IsvKellyStateHost; 5] = [
states[0].to_host(),
states[1].to_host(),
states[2].to_host(),
states[3].to_host(),
states[4].to_host(),
];
let host_states: [IsvKellyStateHost; N_HORIZONS] =
std::array::from_fn(|h| states[h].to_host());
sim.write_isv_kelly(b, &host_states)?;
}
@@ -345,7 +341,7 @@ fn run_book_fixture(path: &Path) -> Result<()> {
if !fx.expected_isv_kelly_after.is_empty() {
for (b, expected) in fx.expected_isv_kelly_after.iter().enumerate() {
let got = sim.read_isv_kelly(b)?;
for h in 0..5 {
for h in 0..N_HORIZONS {
let n = got[h].n_trades_seen;
assert!(
n >= expected[h].n_trades_seen_min && n <= expected[h].n_trades_seen_max,

View File

@@ -13,7 +13,7 @@
//! resting-order pressure + trade-flow simulation.
use anyhow::Result;
use ml_backtesting::policy::IsvKellyStateHost;
use ml_backtesting::policy::{IsvKellyStateHost, N_HORIZONS};
use ml_backtesting::sim::LobSimCuda;
use ml_core::device::MlDevice;
use rand::{Rng, SeedableRng};
@@ -37,12 +37,12 @@ fn make_initial_book() -> ([f32; BOOK_LEVELS], [f32; BOOK_LEVELS], [f32; BOOK_LE
(bid_px, bid_sz, ask_px, ask_sz)
}
fn random_warm_start_isv(rng: &mut ChaCha8Rng) -> [IsvKellyStateHost; 5] {
let mut out: [IsvKellyStateHost; 5] = Default::default();
for h in 0..5 {
fn random_warm_start_isv(rng: &mut ChaCha8Rng) -> [IsvKellyStateHost; N_HORIZONS] {
let mut out: [IsvKellyStateHost; N_HORIZONS] = Default::default();
for h in 0..N_HORIZONS {
// Make at least one horizon credibly profitable so the decision
// kernel actually opens positions; others get random warm-starts.
let positive_h = h == 4;
let positive_h = h == N_HORIZONS - 1;
let win_rate: f32 = if positive_h { 0.7 } else { rng.gen_range(0.3..0.6) };
let pnl_win: f32 = if positive_h { 2.0 } else { rng.gen_range(0.5..1.5) };
let pnl_loss: f32 = rng.gen_range(0.3..1.0);
@@ -113,8 +113,8 @@ fn run_integrated_fuzz(n_backtests: usize, n_events: usize, seed: u64) -> Result
// Decide every 4th event.
if event_idx % 4 == 0 {
let mut probs = [0.5_f32; 5];
for h in 0..5 {
let mut probs = [0.5_f32; N_HORIZONS];
for h in 0..N_HORIZONS {
probs[h] = rng.gen_range(0.1..0.9);
}
sim.broadcast_alpha(&probs)?;

View File

@@ -8,6 +8,7 @@
//! kernel where the read_first_inflight_arrival_ts helper exists.
use anyhow::Result;
use ml_backtesting::policy::N_HORIZONS;
use ml_backtesting::sim::{BatchedSimConfig, LobSimCuda, UniformSimParams};
use ml_core::device::MlDevice;
@@ -22,7 +23,7 @@ fn parallel_sim_equivalence_with_uniform_config() -> Result<()> {
}
};
let mut sim = LobSimCuda::new(8, &dev)?;
sim.broadcast_alpha(&[0.8, 0.8, 0.8, 0.8, 0.8])?;
sim.broadcast_alpha(&[0.8; N_HORIZONS])?;
let cfg = BatchedSimConfig::from_uniform(
8,
&UniformSimParams {

View File

@@ -44,7 +44,7 @@ fn try_loader() -> Option<MultiHorizonLoader> {
files,
predecoded_dir: mbp10.clone(),
seq_len: 1,
horizons: [30, 100, 300, 1000, 6000],
horizons: ml_alpha::heads::HORIZONS,
n_max_sequences: 0,
seed: 0xCAFE_F00D,
inference_only: true,

View File

@@ -92,7 +92,7 @@ fn stop_check_skipped_when_flat() -> Result<()> {
// Position is flat (just allocated, zeros). Stop check must not fire;
// alpha decides the action.
sim.broadcast_alpha(&[0.8, 0.8, 0.8, 0.8, 0.8])?;
sim.broadcast_alpha(&[0.8; N_HORIZONS])?;
sim.step_decision_with_latency(0, &cfg_default(1))?;
let (side, size) = sim.read_market_target(0)?;
@@ -118,7 +118,7 @@ fn sl_fires_when_unrealized_breaks_distance() -> Result<()> {
// pnl_ema_win=0.0 forces cold-start branch in decision kernel (kelly_frac →
// floor=0.20, cap_lots → max_lots=5). With alpha=0.95 this gives
// lots=round(0.9*0.20*5)=round(0.9)=1 — position opens.
let seeded: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
let seeded: [IsvKellyStateHost; N_HORIZONS] = std::array::from_fn(|_| IsvKellyStateHost {
pnl_ema_win: 0.0,
pnl_ema_loss: 2.0,
win_rate_ema: 0.5,
@@ -135,7 +135,7 @@ fn sl_fires_when_unrealized_breaks_distance() -> Result<()> {
sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?;
// Open a long position via strong-alpha + step_decision + manual fill.
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
sim.broadcast_alpha(&[0.95; N_HORIZONS])?;
sim.step_decision_with_latency(0, &cfg_default(1))?;
let mut ts: u64 = 1_000_000;
for _ in 0..3 {
@@ -149,7 +149,7 @@ fn sl_fires_when_unrealized_breaks_distance() -> Result<()> {
// Drive mid down by 3.0 (>= sl_distance=2.0). Stop must fire force-flat.
let (bp3, bs3, ap3, az3) = level_book(5497.0, 0.25);
sim.apply_snapshot(&bp3, &bs3, &ap3, &az3)?;
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
sim.broadcast_alpha(&[0.95; N_HORIZONS])?;
sim.step_decision_with_latency(ts, &cfg_default(1))?;
let (side, size) = sim.read_market_target(0)?;
@@ -169,7 +169,7 @@ fn trail_arms_then_fires() -> Result<()> {
// Cold-start ISV (forces kelly_frac_floor + max_lots cap) so position opens.
// pnl_ema_loss large enough that SL won't fire during the +5pt walk.
let cold_start: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
let cold_start: [IsvKellyStateHost; N_HORIZONS] = std::array::from_fn(|_| IsvKellyStateHost {
pnl_ema_win: 0.0,
pnl_ema_loss: 10.0, // big SL so trail fires first
win_rate_ema: 0.0,
@@ -185,7 +185,7 @@ fn trail_arms_then_fires() -> Result<()> {
sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?;
// Open long.
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
sim.broadcast_alpha(&[0.95; N_HORIZONS])?;
sim.step_decision_with_latency(0, &cfg_default(1))?;
let mut ts: u64 = 1_000_000;
for _ in 0..3 { sim.step_resting_orders(ts, 0.0)?; ts += 1_000_000; }
@@ -193,7 +193,7 @@ fn trail_arms_then_fires() -> Result<()> {
// After opening, switch ISV to one with pnl_ema_win=1.5 (trail_distance target).
// The trail check reads this fresh state on subsequent decision steps.
let trail_state: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
let trail_state: [IsvKellyStateHost; N_HORIZONS] = std::array::from_fn(|_| IsvKellyStateHost {
pnl_ema_win: 1.5,
pnl_ema_loss: 10.0,
win_rate_ema: 0.5,
@@ -207,7 +207,7 @@ fn trail_arms_then_fires() -> Result<()> {
for &m in &[5501.0_f32, 5502.0, 5503.0, 5504.0, 5505.0] {
let (b, bs, a, asz) = level_book(m, 0.25);
sim.apply_snapshot(&b, &bs, &a, &asz)?;
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
sim.broadcast_alpha(&[0.95; N_HORIZONS])?;
sim.step_decision_with_latency(ts, &cfg_default(1))?;
ts += 1_000_000;
}
@@ -217,7 +217,7 @@ fn trail_arms_then_fires() -> Result<()> {
// Drop mid by trail_distance+ε from peak to fire trail.
let (b, bs, a, asz) = level_book(5502.0, 0.25); // drop from peak 5505 to 5502 = 3.0
sim.apply_snapshot(&b, &bs, &a, &asz)?;
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
sim.broadcast_alpha(&[0.95; N_HORIZONS])?;
sim.step_decision_with_latency(ts, &cfg_default(1))?;
let (side, size) = sim.read_market_target(0)?;
@@ -244,7 +244,7 @@ fn multi_horizon_mask_averages_emas() -> Result<()> {
// Bit-pick-first (h0=2.0) would fire at Δ_entry=2.5 → test catches it.
// Bit-pick-max (h1=4.0) would not fire at Δ_entry=4.5 → test catches it.
// Correct mean (3.0) → no fire at Δ_entry=2.5, fire at Δ_entry=4.5.
let cold_start: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
let cold_start: [IsvKellyStateHost; N_HORIZONS] = std::array::from_fn(|_| IsvKellyStateHost {
pnl_ema_win: 0.0,
pnl_ema_loss: 10.0,
win_rate_ema: 0.0,
@@ -259,7 +259,7 @@ fn multi_horizon_mask_averages_emas() -> Result<()> {
let (bp2, bs2, ap2, az2) = level_book(5500.1, 0.25);
sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?;
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
sim.broadcast_alpha(&[0.95; N_HORIZONS])?;
sim.step_decision_with_latency(0, &cfg_default(1))?;
let mut ts: u64 = 1_000_000;
for _ in 0..3 { sim.step_resting_orders(ts, 0.0)?; ts += 1_000_000; }
@@ -274,7 +274,7 @@ fn multi_horizon_mask_averages_emas() -> Result<()> {
// Seed multi-horizon ISV state. pnl_ema_win=100.0 keeps trail_distance huge
// so the trail never fires during the test.
// See setup comment above: mean=3.0, regression discriminators at Δ=2.5/4.5.
let mut seeded: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
let mut seeded: [IsvKellyStateHost; N_HORIZONS] = std::array::from_fn(|_| IsvKellyStateHost {
pnl_ema_win: 100.0,
pnl_ema_loss: 3.0,
win_rate_ema: 0.5,
@@ -299,7 +299,7 @@ fn multi_horizon_mask_averages_emas() -> Result<()> {
let mid_no_fire = entry_px - 2.25;
let (bp3, bs3, ap3, az3) = level_book(mid_no_fire, 0.25);
sim.apply_snapshot(&bp3, &bs3, &ap3, &az3)?;
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
sim.broadcast_alpha(&[0.95; N_HORIZONS])?;
sim.step_decision_with_latency(ts, &cfg_default(1))?;
let (side_at_25, _) = sim.read_market_target(0)?;
assert_ne!(side_at_25, 3,
@@ -317,7 +317,7 @@ fn multi_horizon_mask_averages_emas() -> Result<()> {
let mid_fire = entry_px - 4.25;
let (bp4, bs4, ap4, az4) = level_book(mid_fire, 0.25);
sim.apply_snapshot(&bp4, &bs4, &ap4, &az4)?;
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
sim.broadcast_alpha(&[0.95; N_HORIZONS])?;
sim.step_decision_with_latency(ts, &cfg_default(1))?;
let (side_at_45, size_at_45) = sim.read_market_target(0)?;
assert_eq!(side_at_45, 3, "Δ=4.5 > mean_sl=3.0 must fire force-flat; got side={side_at_45}");
@@ -358,7 +358,7 @@ fn bytecode_and_default_kernel_agree_on_stops() -> Result<()> {
// Cold-start ISV: pnl_ema_win=0.0, n_trades_seen=0, realised_return_var=0.0
// forces kelly_frac_floor + max_lots path so position opens.
// pnl_ema_loss=10.0 keeps SL far enough that it won't fire during open setup.
let cold_start: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
let cold_start: [IsvKellyStateHost; N_HORIZONS] = std::array::from_fn(|_| IsvKellyStateHost {
pnl_ema_win: 0.0,
pnl_ema_loss: 10.0,
win_rate_ema: 0.0,
@@ -374,7 +374,7 @@ fn bytecode_and_default_kernel_agree_on_stops() -> Result<()> {
sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?;
// Open long.
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
sim.broadcast_alpha(&[0.95; N_HORIZONS])?;
sim.step_decision_with_latency(0, &cfg_default(1))?;
let mut ts: u64 = 1_000_000;
for _ in 0..3 {
@@ -391,7 +391,7 @@ fn bytecode_and_default_kernel_agree_on_stops() -> Result<()> {
// Set SL trigger setup: pnl_ema_loss=2.0 → sl_distance=2.0 (assuming ATR < 2.0).
// pnl_ema_win=10.0 keeps trail_distance=10.0 so trail won't fire before SL.
let triggered: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
let triggered: [IsvKellyStateHost; N_HORIZONS] = std::array::from_fn(|_| IsvKellyStateHost {
pnl_ema_win: 10.0,
pnl_ema_loss: 2.0,
win_rate_ema: 0.5,
@@ -407,7 +407,7 @@ fn bytecode_and_default_kernel_agree_on_stops() -> Result<()> {
let trigger_mid = pos_open.vwap_entry - 4.0;
let (bp3, bs3, ap3, az3) = level_book(trigger_mid, 0.25);
sim.apply_snapshot(&bp3, &bs3, &ap3, &az3)?;
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
sim.broadcast_alpha(&[0.95; N_HORIZONS])?;
sim.step_decision_with_latency(ts, &cfg_default(1))?;
sim.read_market_target(0)
}
@@ -444,7 +444,7 @@ fn position_target_not_additive() -> Result<()> {
let cfg = cfg_default(1);
let mut ts: u64 = 0;
for _ in 0..10 {
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
sim.broadcast_alpha(&[0.95; N_HORIZONS])?;
sim.step_decision_with_latency(ts, &cfg)?;
sim.step_resting_orders(ts, 0.0)?;
ts += 1_000_000;
@@ -487,7 +487,7 @@ fn position_target_not_additive_with_latency() -> Result<()> {
let mut ts: u64 = 0;
// 500 events of persistent target — most should produce delta=0.
for _ in 0..500 {
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
sim.broadcast_alpha(&[0.95; N_HORIZONS])?;
sim.step_decision_with_latency(ts, &cfg_lat)?;
sim.step_resting_orders(ts, 0.0)?;
ts += 1_000_000;
@@ -508,7 +508,7 @@ fn trail_hwm_reset_on_close() -> Result<()> {
};
let mut sim = LobSimCuda::new(1, &dev)?;
let cold_start: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
let cold_start: [IsvKellyStateHost; N_HORIZONS] = std::array::from_fn(|_| IsvKellyStateHost {
pnl_ema_win: 0.0, pnl_ema_loss: 10.0,
win_rate_ema: 0.0, n_trades_seen: 0,
realised_return_var: 0.0, recent_sharpe: 0.0,
@@ -521,14 +521,14 @@ fn trail_hwm_reset_on_close() -> Result<()> {
sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?;
// Open long.
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
sim.broadcast_alpha(&[0.95; N_HORIZONS])?;
let mut ts: u64 = 1_000_000;
sim.step_decision_with_latency(ts, &cfg_default(1))?;
for _ in 0..3 { sim.step_resting_orders(ts, 0.0)?; sim.step_pnl_track(ts)?; ts += 1_000_000; }
assert!(sim.read_pos(0)?.position_lots > 0, "setup: long opens");
// Switch ISV to trail-test state (pnl_ema_win=1.5 → trail_distance=1.5).
let trail_state: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
let trail_state: [IsvKellyStateHost; N_HORIZONS] = std::array::from_fn(|_| IsvKellyStateHost {
pnl_ema_win: 1.5, pnl_ema_loss: 100.0, // big SL → only trail can fire
win_rate_ema: 0.5, n_trades_seen: 20,
realised_return_var: 0.5, recent_sharpe: 0.5,
@@ -539,7 +539,7 @@ fn trail_hwm_reset_on_close() -> Result<()> {
for &m in &[5501.0_f32, 5502.0, 5503.0, 5504.0, 5505.0] {
let (b, bs, a, asz) = level_book(m, 0.25);
sim.apply_snapshot(&b, &bs, &a, &asz)?;
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
sim.broadcast_alpha(&[0.95; N_HORIZONS])?;
sim.step_decision_with_latency(ts, &cfg_default(1))?;
sim.step_resting_orders(ts, 0.0)?;
sim.step_pnl_track(ts)?;
@@ -551,7 +551,7 @@ fn trail_hwm_reset_on_close() -> Result<()> {
// Fire trail — drop mid past trail_distance from peak.
let (b, bs, a, asz) = level_book(5502.0, 0.25);
sim.apply_snapshot(&b, &bs, &a, &asz)?;
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
sim.broadcast_alpha(&[0.95; N_HORIZONS])?;
sim.step_decision_with_latency(ts, &cfg_default(1))?;
let (s_fire, _) = sim.read_market_target(0)?;
assert_eq!(s_fire, 3, "trail must fire force-flat");
@@ -593,7 +593,7 @@ fn max_hold_forces_close() -> Result<()> {
let mut sim = LobSimCuda::new(1, &dev)?;
// Cold-start ISV: large pnl_ema_loss so SL never fires during the test.
let cold_start: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
let cold_start: [IsvKellyStateHost; N_HORIZONS] = std::array::from_fn(|_| IsvKellyStateHost {
pnl_ema_win: 0.0,
pnl_ema_loss: 100.0,
win_rate_ema: 0.0,
@@ -626,7 +626,7 @@ fn max_hold_forces_close() -> Result<()> {
// Open long at t=1ms via strong alpha.
let mut ts: u64 = 1_000_000;
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
sim.broadcast_alpha(&[0.95; N_HORIZONS])?;
sim.step_decision_with_latency(ts, &cfg_max_hold)?;
for _ in 0..3 { sim.step_resting_orders(ts, 0.0)?; sim.step_pnl_track(ts)?; ts += 1_000_000; }
assert!(sim.read_pos(0)?.position_lots > 0, "setup: long opens");
@@ -693,7 +693,7 @@ fn book_nan_inf_prices_dont_corrupt_realized_pnl() -> Result<()> {
// Open long via strong alpha. The fill must NOT propagate NaN/Inf into
// pos.realized_pnl, even though the book has NaN at ask[3] and Inf at bid[5].
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
sim.broadcast_alpha(&[0.95; N_HORIZONS])?;
let mut ts: u64 = 1_000_000;
sim.step_decision_with_latency(ts, &cfg_default(1))?;
for _ in 0..5 {
@@ -730,7 +730,7 @@ fn pnl_track_resets_scratch_on_close() -> Result<()> {
};
let mut sim = LobSimCuda::new(1, &dev)?;
let cold_start: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
let cold_start: [IsvKellyStateHost; N_HORIZONS] = std::array::from_fn(|_| IsvKellyStateHost {
pnl_ema_win: 0.0, pnl_ema_loss: 100.0, // huge SL so it doesn't fire
win_rate_ema: 0.0, n_trades_seen: 0,
realised_return_var: 0.0, recent_sharpe: 0.0,
@@ -760,7 +760,7 @@ fn pnl_track_resets_scratch_on_close() -> Result<()> {
// Trade 1: open long, force-flatten via max_hold.
let mut ts: u64 = 1_000_000;
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
sim.broadcast_alpha(&[0.95; N_HORIZONS])?;
sim.step_decision_with_latency(ts, &cfg_max_hold)?;
for _ in 0..3 {
sim.step_resting_orders(ts, 0.0)?;
@@ -771,7 +771,7 @@ fn pnl_track_resets_scratch_on_close() -> Result<()> {
// Force close trade 1 via max_hold: jump ts by 200ms.
ts += 200_000_000;
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
sim.broadcast_alpha(&[0.95; N_HORIZONS])?;
sim.step_decision_with_latency(ts, &cfg_max_hold)?;
for _ in 0..5 {
sim.step_resting_orders(ts, 0.0)?;
@@ -795,7 +795,7 @@ fn pnl_track_resets_scratch_on_close() -> Result<()> {
// Trade 2: re-open + close via max_hold.
ts += 10_000_000;
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
sim.broadcast_alpha(&[0.95; N_HORIZONS])?;
sim.step_decision_with_latency(ts, &cfg_max_hold)?;
for _ in 0..3 {
sim.step_resting_orders(ts, 0.0)?;
@@ -806,7 +806,7 @@ fn pnl_track_resets_scratch_on_close() -> Result<()> {
// Force close trade 2.
ts += 200_000_000;
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
sim.broadcast_alpha(&[0.95; N_HORIZONS])?;
sim.step_decision_with_latency(ts, &cfg_max_hold)?;
for _ in 0..5 {
sim.step_resting_orders(ts, 0.0)?;
@@ -850,7 +850,7 @@ fn trade_vol_floor_prevents_sub_cost_stops() -> Result<()> {
// Zero ema_loss: only ATR and cost floor govern sl_distance.
// Large pnl_ema_win to keep trail_distance enormous, preventing trail fires.
let cold_start: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
let cold_start: [IsvKellyStateHost; N_HORIZONS] = std::array::from_fn(|_| IsvKellyStateHost {
pnl_ema_win: 100.0, pnl_ema_loss: 0.0,
win_rate_ema: 0.0, n_trades_seen: 0,
realised_return_var: 0.0, recent_sharpe: 0.0,
@@ -886,7 +886,7 @@ fn trade_vol_floor_prevents_sub_cost_stops() -> Result<()> {
delta_floor: 0.0,
});
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
sim.broadcast_alpha(&[0.95; N_HORIZONS])?;
sim.step_decision_with_latency(0, &cfg_cost)?;
let mut ts: u64 = 1_000_000;
for _ in 0..3 { sim.step_resting_orders(ts, 0.0)?; ts += 1_000_000; }
@@ -903,7 +903,7 @@ fn trade_vol_floor_prevents_sub_cost_stops() -> Result<()> {
let (bp3, bs3, ap3, az3) = level_book(mid_no_fire, 0.25);
sim.apply_snapshot(&bp3, &bs3, &ap3, &az3)?;
// ATR update: delta ≈ |(entry+0.17) - 5500.1| — pumped slightly but still << 0.125 ✓
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
sim.broadcast_alpha(&[0.95; N_HORIZONS])?;
sim.step_decision_with_latency(ts, &cfg_cost)?;
let (side_no_fire, _) = sim.read_market_target(0)?;
assert_ne!(side_no_fire, 3,
@@ -915,7 +915,7 @@ fn trade_vol_floor_prevents_sub_cost_stops() -> Result<()> {
let mid_fire = entry + 0.05;
let (bp4, bs4, ap4, az4) = level_book(mid_fire, 0.25);
sim.apply_snapshot(&bp4, &bs4, &ap4, &az4)?;
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
sim.broadcast_alpha(&[0.95; N_HORIZONS])?;
sim.step_decision_with_latency(ts, &cfg_cost)?;
let (side_fire, size_fire) = sim.read_market_target(0)?;
assert_eq!(side_fire, 3,
@@ -940,7 +940,7 @@ fn session_gap_force_closes_open_positions() -> Result<()> {
};
let mut sim = LobSimCuda::new(1, &dev)?;
let cold_start: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
let cold_start: [IsvKellyStateHost; N_HORIZONS] = std::array::from_fn(|_| IsvKellyStateHost {
pnl_ema_win: 0.0, pnl_ema_loss: 100.0, // huge SL — won't fire
win_rate_ema: 0.0, n_trades_seen: 0,
realised_return_var: 0.0, recent_sharpe: 0.0,
@@ -954,7 +954,7 @@ fn session_gap_force_closes_open_positions() -> Result<()> {
// Open long at t=1ms.
let mut ts: u64 = 1_000_000;
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
sim.broadcast_alpha(&[0.95; N_HORIZONS])?;
sim.step_decision_with_latency(ts, &cfg_default(1))?;
for _ in 0..3 {
sim.step_resting_orders(ts, 0.0)?;
@@ -1231,15 +1231,14 @@ fn multi_horizon_conviction_cancels_on_disagreement() -> Result<()> {
delta_floor: 0.0,
});
// Two horizons bullish (0.7), two bearish (0.3), one neutral (0.5).
// Magnitudes: [0.4, 0.4, 0.4, 0.4, 0.0]. Directions: [+1, -1, +1, -1, +1].
// One horizon bullish (0.7), one bearish (0.3), one neutral (0.5).
// Magnitudes: [0.4, 0.4, 0.0]. Directions: [+1, -1, +1].
// At cold start (ISV all zero, cost=1.0), every weight_h is
// eps_edge / (0 + cost²) = 0.01 — equal. Weighted signed sum:
// 0.4*0.01*(+1) + 0.4*0.01*(-1) + 0.4*0.01*(+1) + 0.4*0.01*(-1) + 0
// = 0
// 0.4*0.01*(+1) + 0.4*0.01*(-1) + 0 = 0
// conviction_signed = 0 / total_abs_weight = 0
// → conv_ema = 0 → target_lots = 0 → side ∈ {2, 3}.
let mixed: [f32; N_HORIZONS] = [0.7, 0.3, 0.7, 0.3, 0.5];
let mixed: [f32; N_HORIZONS] = [0.7, 0.3, 0.5];
sim.broadcast_alpha(&mixed)?;
sim.step_decision_with_latency(1_000_000_000u64, &cfg)?;
let (side, size) = sim.read_market_target(0)?;

View File

@@ -7,6 +7,7 @@
//! contract in isolation.
use anyhow::Result;
use ml_backtesting::policy::N_HORIZONS;
use ml_backtesting::sim::{BatchedSimConfig, LobSimCuda, UniformSimParams};
use ml_core::device::MlDevice;
@@ -39,7 +40,7 @@ fn threshold_gate_skips_low_conviction() -> Result<()> {
// identical across horizons. With uniform alpha=0.51 across all 5
// horizons (all bullish), magnitude_h = 0.02 and conviction_signed =
// 0.02 — well below threshold = 0.10. Kernel writes side=2 (no-op).
sim.broadcast_alpha(&[0.51, 0.51, 0.51, 0.51, 0.51])?;
sim.broadcast_alpha(&[0.51; N_HORIZONS])?;
sim.step_decision_with_latency(0, &cfg_with_threshold(1, 0.10, 1.0))?;
let (side, size) = sim.read_market_target(0)?;
assert_eq!(side, 2, "side should be noop under threshold gate; got side={side}");
@@ -58,7 +59,7 @@ fn threshold_gate_allows_high_conviction() -> Result<()> {
// CRT.1 C1.2: uniform alpha=0.8 across horizons → magnitude_h = 0.6,
// direction_h = +1 for all → conviction_signed = 0.6, above threshold
// = 0.10. target_lots = round(1 * 0.6 * 5) = 3.
sim.broadcast_alpha(&[0.8, 0.8, 0.8, 0.8, 0.8])?;
sim.broadcast_alpha(&[0.8; N_HORIZONS])?;
sim.step_decision_with_latency(0, &cfg_with_threshold(1, 0.10, 1.0))?;
let (side, size) = sim.read_market_target(0)?;
assert_eq!(side, 0, "side should be buy with strong alpha; got side={side}");
@@ -76,7 +77,7 @@ fn threshold_zero_is_passthrough() -> Result<()> {
Err(e) => { eprintln!("skipping: cuda device unavailable ({e})"); return Ok(()); }
};
let mut sim = LobSimCuda::new(1, &dev)?;
sim.broadcast_alpha(&[0.8, 0.8, 0.8, 0.8, 0.8])?;
sim.broadcast_alpha(&[0.8; N_HORIZONS])?;
sim.step_decision_with_latency(0, &cfg_with_threshold(1, 0.0, 1.0))?;
let (side, size) = sim.read_market_target(0)?;
assert_eq!(side, 0, "threshold=0 with strong alpha should pass through; got side={side}");

View File

@@ -31,7 +31,7 @@ fn try_loader(inference_only: bool) -> Option<MultiHorizonLoader> {
files,
predecoded_dir: mbp10.clone(),
seq_len: 1,
horizons: [30, 100, 300, 1000, 6000],
horizons: ml_alpha::heads::HORIZONS,
n_max_sequences: 1,
seed: 0xCAFEF00D,
inference_only,