Per spec §4.0 + plan C1.3. Without this gate, every fractional change in target_lots seeds an order via target-delta semantics; combined with the continuous controller invocation (every event after A1) this generates hyperactivity even with the multi-horizon §4.4 conviction formula (C1.2). v2 Gate-1 failures confirmed: scalar EMA + amplification clamp + per-event controller = 155k trades on a 2M-event smoke. The band absorbs fractional target adjustments so most events produce no order. When the signal genuinely shifts and target moves enough to cross the band, the kernel seeds. Continuous evaluation, sparse action. Greenfields atomic refactor — single config knob threaded through every layer in one commit: SweepBase.delta_floor: f32 (YAML, default 1.0 = 1 lot) SimVariant.delta_floor: Option<f32> (per-variant override) ResolvedSimVariant.delta_floor: f32 UniformSimParams.delta_floor: f32 BatchedSimConfig.delta_floor: Vec<f32> LobSimCuda.delta_floor_d: CudaSlice<f32> seed_inflight_limits_batched kernel param + skip-if-below-floor logic New accessor: LobSimCuda::read_inflight_count(b) — counts active != 0 limit slots for backtest b. Not cfg(test); follows existing read_limit_slot pattern. Test: no_trade_band_blocks_micro_delta verifies that a second decision with the same strong-bullish alpha (same target, effective = in-flight lots) produces delta=0 and does not re-seed. max_lots=1 keeps the arithmetic unambiguous. Existing tests: all UniformSimParams struct literals updated with delta_floor=0.0 (band disabled) so existing behaviour is preserved. harness.rs from_uniform path uses delta_floor=1.0 (production default). Hot-path discipline: the delta_floor_d upload happens once per run in the existing config-upload block (alongside threshold, cost, max_hold_ns); the kernel reads the device slot per-event without any host roundtrip. No memcpy_htod / dtoh / dtov / synchronize introduced on the per-event path. Per pearl_controller_anchors_isv_driven, this floor is currently a config constant; CRT.2 (Phase 2) makes it ISV-derived from rolling spread cost / signal volatility. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
86 lines
3.6 KiB
Rust
86 lines
3.6 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,
|
|
max_hold_ns: 0,
|
|
min_reasonable_px: 0.0,
|
|
max_reasonable_px: f32::INFINITY,
|
|
delta_floor: 0.0,
|
|
})
|
|
}
|
|
|
|
#[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)?;
|
|
// CRT.1 C1.2: at cold-start ISV all weights are eps_edge / cost² and
|
|
// 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.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}");
|
|
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)?;
|
|
// 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.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}");
|
|
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 pass any non-zero conviction
|
|
// through to a non-zero target. conviction_abs >= 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, 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}");
|
|
assert!(size >= 1, "size {size} < 1 — passthrough behaviour broken");
|
|
Ok(())
|
|
}
|