Files
foxhunt/crates/ml-backtesting/tests/parallel_sim_correctness.rs
jgrusewski cd82f9a4a0 feat(ml-backtesting): threshold gate + per-fill cost integration (P4)
Adds the two sweep axes that the spec's deployability grid needs but
were missing from the kernels:

Threshold gate (decision_policy.cu, both kernels):
- New per-backtest `threshold_per_b` array kernel arg.
- Pre-Kelly prelude: if max_h |alpha[h] - 0.5| * 2 < threshold[b],
  emit noop and return. Kept deterministic from alpha alone so the
  threshold pre-registration step (p60-p95 absolute calibration on a
  validation window, future P6) reflects exactly what gets gated in
  deployment.

Per-fill cost integration (resting_orders.cu / apply_fill_to_pos):
- apply_fill_to_pos signature grows three args: b, cost_per_lot_per_side_per_b,
  total_fees_per_b. Single insertion point at line 90.
- After the close-leg realized_pnl math runs (so the gross unwind P&L
  is preserved), deduct fill_cost = filled_lots * cost_per_lot_per_side[b]
  from pos.realized_pnl AND accumulate into total_fees_per_b[b].
- Net-of-cost semantics: isv_kelly_update_on_close reads realized_pnl
  delta which is now net of cost — Kelly state learns from realistic
  return distribution.
- All 3 apply_fill_to_pos call sites in step_resting_orders updated.
  order_match.cu's submit_market_immediate path is dead code in the
  post-P1 flow (everything routes through seed_inflight_limits_batched
  → step_resting_orders → apply_fill_to_pos) so not touched here.

BatchedSimConfig + UniformSimParams + BacktestHarnessConfig gain
threshold + cost_per_lot_per_side fields. All UniformSimParams
constructors in tests and main.rs updated with defaults (0.0, 0.0 =
gate disabled, frictionless).

Regression:
- threshold_gate_skips_low_conviction (p=0.51 + threshold=0.10 → noop)
- threshold_gate_allows_high_conviction (p=0.8 + threshold=0.10 → buy 1+)
- threshold_zero_is_passthrough (sanity)
- All P1+P2+P3 tests continue to pass via the new ABI.

cost_deducted_at_each_fill + kelly_state_sees_net_return end-to-end
tests deferred — they require a full submit_market → fill → close
sequence, which the production smoke exercises.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:13:29 +02:00

54 lines
2.0 KiB
Rust

//! P1 regression test: per-backtest sim parameter arrays. With uniform
//! config across N backtests, all N must produce the same market_target
//! decision (proves the per-backtest indexing reduces correctly to the
//! pre-refactor scalar-broadcast semantics).
//!
//! Pair: the independence test (proving DIFFERENT per-backtest configs
//! produce different outputs) lands alongside the P2 inflight-limits
//! kernel where the read_first_inflight_arrival_ts helper exists.
use anyhow::Result;
use ml_backtesting::sim::{BatchedSimConfig, LobSimCuda, UniformSimParams};
use ml_core::device::MlDevice;
#[test]
#[ignore = "requires CUDA"]
fn parallel_sim_equivalence_with_uniform_config() -> Result<()> {
let dev = match MlDevice::cuda(0) {
Ok(d) => d,
Err(e) => {
eprintln!("skipping: cuda device unavailable ({e})");
return Ok(());
}
};
let mut sim = LobSimCuda::new(8, &dev)?;
sim.broadcast_alpha(&[0.8, 0.8, 0.8, 0.8, 0.8])?;
let cfg = BatchedSimConfig::from_uniform(
8,
&UniformSimParams {
target_annual_vol_units: 50.0,
annualisation_factor: 825.0,
max_lots: 5,
latency_ns: 0,
kelly_frac_floor: 0.20,
sharpe_weight_floor: 0.10,
threshold: 0.0,
cost_per_lot_per_side: 0.0,
},
);
sim.step_decision_with_latency(0, &cfg)?;
let first = sim.read_market_target(0)?;
for b in 1..8 {
let got = sim.read_market_target(b)?;
assert_eq!(
got, first,
"backtest {b} differs from backtest 0 under uniform config: {got:?} vs {first:?}"
);
}
// And the uniform-config result must equal the result we'd get from
// a single-backtest sim (preserves pre-refactor behaviour).
assert_eq!(first.0, 0, "uniform config with p_h=0.8 should produce a buy; got side={}", first.0);
assert!(first.1 >= 1, "uniform-config size {} < 1", first.1);
Ok(())
}