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>
79 lines
3.2 KiB
Rust
79 lines
3.2 KiB
Rust
//! P4 regression: threshold gate + per-fill cost integration.
|
|
//!
|
|
//! Tight tests focused on the GATE's pre-Kelly skip path and the cost
|
|
//! plumbing in apply_fill_to_pos. The full kelly_state_sees_net_return
|
|
//! end-to-end test requires a full submit_market → fill → close sequence
|
|
//! which is exercised by the production smoke; here we test the kernel
|
|
//! contract in isolation.
|
|
|
|
use anyhow::Result;
|
|
use ml_backtesting::sim::{BatchedSimConfig, LobSimCuda, UniformSimParams};
|
|
use ml_core::device::MlDevice;
|
|
|
|
fn cfg_with_threshold(n: usize, threshold: f32, cost: f32) -> BatchedSimConfig {
|
|
BatchedSimConfig::from_uniform(n, &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,
|
|
cost_per_lot_per_side: cost,
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires CUDA"]
|
|
fn threshold_gate_skips_low_conviction() -> 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(1, &dev)?;
|
|
// p_h = 0.51 → max_conviction = 0.02, well below threshold = 0.10.
|
|
sim.broadcast_alpha(&[0.51, 0.51, 0.51, 0.51, 0.51])?;
|
|
sim.step_decision_with_latency(0, &cfg_with_threshold(1, 0.10, 0.0))?;
|
|
let (side, size) = sim.read_market_target(0)?;
|
|
assert_eq!(side, 2, "side should be noop under threshold gate; got side={side}");
|
|
assert_eq!(size, 0);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires CUDA"]
|
|
fn threshold_gate_allows_high_conviction() -> 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(1, &dev)?;
|
|
// p_h = 0.8 → max_conviction = 0.6, above threshold = 0.10.
|
|
// (p=0.7 would clear the gate but ss=0.4 rounds to lots=0; needs p≥0.75
|
|
// with kelly_floor=0.20 + max_lots=5 to clear the lots>=1 rounding.)
|
|
sim.broadcast_alpha(&[0.8, 0.8, 0.8, 0.8, 0.8])?;
|
|
sim.step_decision_with_latency(0, &cfg_with_threshold(1, 0.10, 0.0))?;
|
|
let (side, size) = sim.read_market_target(0)?;
|
|
assert_eq!(side, 0, "side should be buy with strong alpha; got side={side}");
|
|
assert!(size >= 1, "size {size} < 1 — threshold gate may be over-restricting");
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires CUDA"]
|
|
fn threshold_zero_is_passthrough() -> Result<()> {
|
|
// Sanity check: threshold = 0.0 should behave exactly like the
|
|
// P1 cold-start path (no gate). max_conviction >= 0 always.
|
|
let dev = match MlDevice::cuda(0) {
|
|
Ok(d) => d,
|
|
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.step_decision_with_latency(0, &cfg_with_threshold(1, 0.0, 0.0))?;
|
|
let (side, size) = sim.read_market_target(0)?;
|
|
assert_eq!(side, 0, "threshold=0 with strong alpha should pass through; got side={side}");
|
|
assert!(size >= 1, "size {size} < 1 — passthrough behaviour broken");
|
|
Ok(())
|
|
}
|