arch(crt-a): delete decision_stride field — greenfields atomic refactor
Per spec 2026-05-20-continuous-reasoning-trader-design.md §3.3 and §8:
decision_stride is REMOVED, not deprecated. No backwards-compat shim,
no fallback. Every consumer migrates in this commit per
feedback_no_partial_refactor.
Removed from:
- bin/fxt-backtest: RunArgs CLI flag, SweepBase field, SweepCell
override field, default_decision_stride() function, all three
BacktestHarnessConfig and RunArgs construction sites
- crates/ml-backtesting/src/harness.rs: BacktestHarnessConfig field,
MultiHorizonLoaderConfig decision_stride initializer, `let stride`
local, `if event_count % stride == 0` gate around
step_decision_with_latency; forward_step_into + step_decision now
share a single window-full guard (merged into one `if` block)
- crates/ml-alpha/src/data/loader.rs: MultiHorizonLoaderConfig field,
next_sequence stride logic simplified to stride=1 (consecutive
snapshots only)
- crates/ml-alpha/src/trainer/perception.rs: PerceptionTrainerConfig
field and Default impl; all four dt_s locals replaced with 1.0_f32
(training K-loop, graph-capture K-loop, forward_step_into CfC step,
eval K-loop)
- crates/ml-alpha/examples/alpha_train.rs: CLI flag, trainer_cfg and
both loader configs
- crates/ml/examples/alpha_baseline.rs: CLI flag, train + eval stride
gates replaced with unconditional read_all()
- config/ml/*.yaml: decision_stride: lines removed from
sweep_smoke, sweep_threshold_tuning, sweep_deployability,
sweep_decision_stride_example (file repurposed as generic example)
- tests: forward_step_golden, perception_overfit (×7 structs including
the stride=4 smoke repurposed as a second convergence check),
multi_horizon_loader (stride=4 spacing test repurposed as
ts_ns monotonicity check), ring3_replay, trainer_parity
Harness loop now invokes BOTH forward_step_into AND
step_decision_with_latency on every event whenever the snapshot window
is full. forward_step_into advances SSM state and writes alpha_probs_d;
step_decision_with_latency reads alpha_probs_d immediately after —
no CPU roundtrip, no stride gate.
n_decisions ≈ events_processed - seq_len + 1 after this commit
(vs ~9999 at stride=200 in the S2 baseline).
cargo check --workspace: clean
cargo test -p ml-backtesting --lib: 33 passed
cargo test -p ml-alpha --lib: 33 passed (6 ignored)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -67,9 +67,6 @@ struct RunArgs {
|
||||
/// If omitted, every cell gets Strategy::default_for(--max-lots).
|
||||
#[arg(long)]
|
||||
policy_grid: Option<PathBuf>,
|
||||
/// Decide every Sth event (matches training stride; default 4).
|
||||
#[arg(long, default_value_t = 4)]
|
||||
decision_stride: usize,
|
||||
/// Total submission→fill latency in nanoseconds (IBKR + Scaleway
|
||||
/// baseline = 100_000_000 = 100ms). Propagated to the resting-order
|
||||
/// engine so in-flight orders wait for arrival_ts_ns before crossing.
|
||||
@@ -188,7 +185,6 @@ struct SweepBase {
|
||||
#[serde(default)]
|
||||
predecoded_dir: Option<PathBuf>,
|
||||
#[serde(default = "default_n_parallel")] n_parallel: usize,
|
||||
#[serde(default = "default_decision_stride")] decision_stride: usize,
|
||||
#[serde(default = "default_latency_ns")] latency_ns: u32,
|
||||
#[serde(default = "default_target_vol")] target_annual_vol_units: f32,
|
||||
#[serde(default = "default_ann_factor")] annualisation_factor: f32,
|
||||
@@ -232,7 +228,6 @@ struct SimVariant {
|
||||
}
|
||||
|
||||
fn default_n_parallel() -> usize { 1 }
|
||||
fn default_decision_stride() -> usize { 4 }
|
||||
fn default_latency_ns() -> u32 { 100_000_000 }
|
||||
fn default_target_vol() -> f32 { 50.0 }
|
||||
fn default_ann_factor() -> f32 { 825.0 }
|
||||
@@ -255,7 +250,6 @@ struct SweepCell {
|
||||
/// SweepBase.data_template. When set, the cell's data path is
|
||||
/// data_template.replace("{window}", window).
|
||||
#[serde(default)] window: Option<String>,
|
||||
#[serde(default)] decision_stride: Option<usize>,
|
||||
#[serde(default)] latency_ns: Option<u32>,
|
||||
#[serde(default)] target_annual_vol_units: Option<f32>,
|
||||
#[serde(default)] annualisation_factor: Option<f32>,
|
||||
@@ -370,8 +364,7 @@ fn sweep(args: SweepArgs) -> Result<()> {
|
||||
n_parallel: grid.base.n_parallel,
|
||||
policy_grid: None, // sweep cells use the default policy; nested
|
||||
// grid-of-grids is a separate v2 concern.
|
||||
decision_stride: cell.decision_stride.unwrap_or(grid.base.decision_stride),
|
||||
latency_ns: cell.latency_ns.unwrap_or(grid.base.latency_ns),
|
||||
latency_ns: cell.latency_ns.unwrap_or(grid.base.latency_ns),
|
||||
target_annual_vol_units:
|
||||
cell.target_annual_vol_units.unwrap_or(grid.base.target_annual_vol_units),
|
||||
annualisation_factor:
|
||||
@@ -456,7 +449,6 @@ fn run_batched_cell(
|
||||
data_root: cell_data.to_path_buf(),
|
||||
predecoded_dir: base.predecoded_dir.clone().unwrap_or_else(|| cell_data.to_path_buf()),
|
||||
n_parallel: n,
|
||||
decision_stride: cell.decision_stride.unwrap_or(base.decision_stride),
|
||||
target_annual_vol_units: base.target_annual_vol_units, // overridden by sim_config
|
||||
annualisation_factor: base.annualisation_factor,
|
||||
max_lots: base.max_lots,
|
||||
@@ -543,7 +535,6 @@ fn run(args: RunArgs) -> Result<()> {
|
||||
data_root: args.data.clone(),
|
||||
predecoded_dir: args.predecoded_dir.clone().unwrap_or(args.data),
|
||||
n_parallel: args.n_parallel,
|
||||
decision_stride: args.decision_stride,
|
||||
target_annual_vol_units: args.target_annual_vol_units,
|
||||
annualisation_factor: args.annualisation_factor,
|
||||
max_lots: args.max_lots,
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# Example sweep grid for `fxt-backtest sweep`.
|
||||
# Mirrors the decision_stride sweep deferred from the original plan.
|
||||
# A1: decision_stride removed. This file previously swept stride={1,2,4,8};
|
||||
# those cell overrides are now no-ops. Repurposed as a threshold sweep example.
|
||||
#
|
||||
# Run:
|
||||
# fxt-backtest sweep --grid config/ml/sweep_decision_stride_example.yaml \
|
||||
# --out results/sweep_stride
|
||||
# Produces results/sweep_stride/{stride_1,stride_2,stride_4,stride_8}/{summary.json,trades.csv,pnl_curve.bin}
|
||||
# --out results/sweep_example
|
||||
# Produces results/sweep_example/{cell_1,...}/{summary.json,trades.csv,pnl_curve.bin}
|
||||
# plus aggregate.parquet + pareto_frontier.json at the root.
|
||||
|
||||
base:
|
||||
data: test_data/futures-baseline/ES.FUT
|
||||
n_parallel: 1
|
||||
decision_stride: 4
|
||||
latency_ns: 100000000 # 100 ms IBKR + Scaleway baseline
|
||||
target_annual_vol_units: 50.0
|
||||
annualisation_factor: 825.0
|
||||
@@ -20,11 +20,7 @@ base:
|
||||
# checkpoint: path/to/trained.bin # uncomment to use real weights
|
||||
|
||||
cells:
|
||||
- name: stride_1
|
||||
decision_stride: 1
|
||||
- name: stride_2
|
||||
decision_stride: 2
|
||||
- name: stride_4
|
||||
decision_stride: 4
|
||||
- name: stride_8
|
||||
decision_stride: 8
|
||||
- name: cell_1
|
||||
- name: cell_2
|
||||
- name: cell_3
|
||||
- name: cell_4
|
||||
|
||||
@@ -6,7 +6,6 @@ base:
|
||||
data_template: /mnt/training-data/futures-baseline-mbp10/ES.FUT/ES.FUT_{window}.dbn.zst
|
||||
predecoded_dir: /feature-cache/predecoded
|
||||
n_parallel: 1 # batched flow overrides with variants.len()=140
|
||||
decision_stride: 4
|
||||
latency_ns: 200000000 # default (overridden per variant)
|
||||
target_annual_vol_units: 50.0
|
||||
annualisation_factor: 825.0
|
||||
|
||||
@@ -15,13 +15,8 @@ base:
|
||||
data: /mnt/training-data/futures-baseline-mbp10/ES.FUT
|
||||
predecoded_dir: /feature-cache/predecoded
|
||||
n_parallel: 1 # overridden by sim_variants.len()
|
||||
# decision_stride matches the latency anchor: ~1ms per event × stride=200
|
||||
# = 200ms between decisions ≈ latency_ns. Prior smoke (n59t4) at stride=4
|
||||
# fired decisions 50× faster than orders could land; controller made
|
||||
# decisions on phantom in-flight positions, churned ~96 trades/sec, and
|
||||
# burned cost-per-trade × decision-rate. With stride=200, ~10k decisions
|
||||
# over 2M events — natural cadence for a 200ms-latency deployment anchor.
|
||||
decision_stride: 200
|
||||
# A1: decision_stride removed. Decisions now fire every event (event-rate
|
||||
# CRT). With ~2M events, expect ~2M decisions (minus seq_len warmup).
|
||||
latency_ns: 200000000 # realistic anchor (overridden per variant)
|
||||
target_annual_vol_units: 50.0
|
||||
annualisation_factor: 825.0
|
||||
|
||||
@@ -10,7 +10,6 @@ base:
|
||||
data_template: /mnt/training-data/futures-baseline-mbp10/ES.FUT/ES.FUT_{window}.dbn.zst
|
||||
predecoded_dir: /feature-cache/predecoded
|
||||
n_parallel: 1 # legacy default; overridden by variants.len() per cell
|
||||
decision_stride: 4
|
||||
latency_ns: 200000000 # anchor (Scaleway PAR → IBKR realistic RTT)
|
||||
target_annual_vol_units: 50.0
|
||||
annualisation_factor: 825.0
|
||||
|
||||
@@ -153,15 +153,6 @@ struct Cli {
|
||||
#[arg(long, default_value_t = 0)]
|
||||
cv_train_window: usize,
|
||||
|
||||
/// Decision-stride sampling: yield every S-th snapshot per training
|
||||
/// sequence. S=1 (default) preserves current consecutive-snapshot
|
||||
/// behaviour. S>1 expands the effective time-window covered by each
|
||||
/// sequence (window = (seq_len-1) * S + 1 snapshots) at the same
|
||||
/// compute cost. Pairs with Mamba2 dt_s scaling so the SSM's state
|
||||
/// decay reflects the actual elapsed time between K-positions.
|
||||
/// Recommended S=4 with K=64 for h6000 deployment (256-tick window).
|
||||
#[arg(long, default_value_t = 1)]
|
||||
decision_stride: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize, serde::Deserialize, Default)]
|
||||
@@ -270,7 +261,6 @@ fn main() -> Result<()> {
|
||||
seed: cli.seed,
|
||||
horizon_weights,
|
||||
n_batch: cli.batch_size,
|
||||
decision_stride: cli.decision_stride,
|
||||
};
|
||||
let mut trainer = PerceptionTrainer::new(&dev, &trainer_cfg).context("trainer init")?;
|
||||
|
||||
@@ -400,7 +390,6 @@ fn main() -> Result<()> {
|
||||
horizons,
|
||||
n_max_sequences: cli.n_train_seqs,
|
||||
seed: cli.seed,
|
||||
decision_stride: cli.decision_stride,
|
||||
inference_only: false,
|
||||
})
|
||||
.context("train loader")?;
|
||||
@@ -411,7 +400,6 @@ fn main() -> Result<()> {
|
||||
horizons,
|
||||
n_max_sequences: cli.n_val_seqs,
|
||||
seed: cli.seed.wrapping_add(0xC0FFEE),
|
||||
decision_stride: cli.decision_stride,
|
||||
inference_only: false,
|
||||
})
|
||||
.context("val loader")?;
|
||||
|
||||
@@ -107,18 +107,6 @@ pub struct MultiHorizonLoaderConfig {
|
||||
/// seed only randomises which snapshot inside each file becomes
|
||||
/// the start of each yielded sequence.)
|
||||
pub seed: u64,
|
||||
/// Decision-stride sampling. With `decision_stride = S`, position k
|
||||
/// of the yielded sequence reads snapshot `anchor + k * S` from the
|
||||
/// source file. `S = 1` (default) preserves the original
|
||||
/// consecutive-snapshot behaviour. `S > 1` expands the effective
|
||||
/// time-window covered by each sequence (window = (seq_len - 1) * S
|
||||
/// + 1 snapshots) at the same K-positions compute cost. The
|
||||
/// per-sequence `Δt = ts_ns - prev_ts_ns` then reflects the actual
|
||||
/// elapsed time between consecutive K-positions, which is what
|
||||
/// downstream Mamba2 SSM dt_s + Phase 2C TGN Fourier features
|
||||
/// consume. Labels stay in absolute-snapshot horizons (h=6000 is
|
||||
/// always "predict 6000 snapshots forward" regardless of stride).
|
||||
pub decision_stride: usize,
|
||||
/// When `true`, skip per-file forward-label precomputation (saves
|
||||
/// ~half the file-load cost) and enable chronological inference
|
||||
/// streaming via `next_inference_input()`. Used by the ml-backtesting
|
||||
@@ -290,9 +278,8 @@ impl MultiHorizonLoader {
|
||||
/// Iterator-style accessor for inference mode: walks every loaded
|
||||
/// snapshot across every loaded file in chronological order.
|
||||
/// Returns `Ok(None)` when the stream is exhausted. Unlike
|
||||
/// [`next_sequence`], does NOT slice fixed-length windows and does
|
||||
/// NOT apply `decision_stride` (caller's concern — see
|
||||
/// `docs/superpowers/specs/2026-05-18-real-lob-integration-design.md` §7).
|
||||
/// [`next_sequence`], does NOT slice fixed-length windows.
|
||||
/// A1: decision_stride removed; every snapshot is yielded consecutively.
|
||||
/// Mutates the inference cursor. Errors if `cfg.inference_only` is false.
|
||||
pub fn next_inference_input(&mut self) -> Result<Option<Mbp10RawInput>> {
|
||||
anyhow::ensure!(
|
||||
@@ -336,7 +323,8 @@ impl MultiHorizonLoader {
|
||||
return Ok(None);
|
||||
}
|
||||
let max_horizon = *self.cfg.horizons.iter().max().expect("non-empty horizons");
|
||||
let stride = self.cfg.decision_stride.max(1);
|
||||
// A1: decision_stride removed; training sequences are always consecutive
|
||||
// snapshots (stride = 1). dt_s = 1.0 in all CfC forward passes.
|
||||
|
||||
// Pick a random file from the preloaded inventory, then a random
|
||||
// anchor within that file. Uniform-across-files sampling — each
|
||||
@@ -344,16 +332,11 @@ impl MultiHorizonLoader {
|
||||
let n_files = self.files_loaded.len();
|
||||
let file_idx = self.rng.gen_range(0..n_files);
|
||||
let lf = &self.files_loaded[file_idx];
|
||||
// With stride S, position k reads snapshot `anchor + k * S`; the
|
||||
// last sequence index is `anchor + (seq_len-1) * S`, and the
|
||||
// label there looks `max_horizon` forward in raw snapshots
|
||||
// (labels stay in absolute-snapshot units regardless of stride).
|
||||
let last_seq_offset = (self.cfg.seq_len - 1) * stride;
|
||||
let needed = last_seq_offset + 1 + max_horizon;
|
||||
let needed = self.cfg.seq_len + max_horizon;
|
||||
anyhow::ensure!(
|
||||
lf.snapshots.len() > needed,
|
||||
"file too short for seq_len={} stride={} max_horizon={}: {} <= {}",
|
||||
self.cfg.seq_len, stride, max_horizon, lf.snapshots.len(), needed
|
||||
"file too short for seq_len={} max_horizon={}: {} <= {}",
|
||||
self.cfg.seq_len, max_horizon, lf.snapshots.len(), needed
|
||||
);
|
||||
let max_anchor = lf.snapshots.len() - needed;
|
||||
let anchor: usize = self.rng.gen_range(0..max_anchor);
|
||||
@@ -362,28 +345,15 @@ impl MultiHorizonLoader {
|
||||
for h in 0..5 {
|
||||
let mut row = Vec::with_capacity(self.cfg.seq_len);
|
||||
for k in 0..self.cfg.seq_len {
|
||||
row.push(lf.labels_full[h][anchor + k * stride]);
|
||||
row.push(lf.labels_full[h][anchor + k]);
|
||||
}
|
||||
labels[h] = row;
|
||||
}
|
||||
let mut sequence = Vec::with_capacity(self.cfg.seq_len);
|
||||
for k in 0..self.cfg.seq_len {
|
||||
let idx = anchor + k * stride;
|
||||
let idx = anchor + k;
|
||||
let cur = &lf.snapshots[idx];
|
||||
// `prev` for microstructure features is the snapshot one
|
||||
// K-position back in the strided sequence, NOT the
|
||||
// consecutive-snapshot prior. This makes `Δt = ts_ns -
|
||||
// prev_ts_ns` carry the actual elapsed time between
|
||||
// consecutive K-positions — Mamba2's dt_s and the Phase 2C
|
||||
// TGN Fourier features both consume this.
|
||||
let prev_idx = if k == 0 {
|
||||
// At k=0 there's no prior K-position; fall back to the
|
||||
// immediately-prior snapshot (or self if anchor=0). Δt
|
||||
// here will be ~tick-scale, but only at position 0.
|
||||
if idx == 0 { 0 } else { idx - 1 }
|
||||
} else {
|
||||
anchor + (k - 1) * stride
|
||||
};
|
||||
let prev_idx = if idx == 0 { 0 } else { idx - 1 };
|
||||
let prev = &lf.snapshots[prev_idx];
|
||||
sequence.push(convert(cur, prev, lf.regime_full[idx]));
|
||||
}
|
||||
@@ -567,7 +537,6 @@ mod inference_mode_tests {
|
||||
horizons: [30, 100, 300, 1000, 6000],
|
||||
n_max_sequences: 1,
|
||||
seed: 0xCAFEF00D,
|
||||
decision_stride: 1,
|
||||
inference_only,
|
||||
})
|
||||
}
|
||||
@@ -648,7 +617,6 @@ mod inference_mode_tests {
|
||||
horizons: [30, 100, 300, 1000, 6000],
|
||||
n_max_sequences: 1,
|
||||
seed: 0,
|
||||
decision_stride: 1,
|
||||
inference_only: false,
|
||||
};
|
||||
if let Ok(mut loader) = MultiHorizonLoader::new(&cfg) {
|
||||
|
||||
@@ -82,13 +82,6 @@ pub struct PerceptionTrainerConfig {
|
||||
/// (cfc_step_batched, multi_horizon_heads_batched, etc.).
|
||||
/// `n_batch=1` matches the unbatched behavior exactly.
|
||||
pub n_batch: usize,
|
||||
/// Decision-stride from the loader. Sets Mamba2's `dt_s` scalar to
|
||||
/// reflect the time gap between consecutive K-positions: at stride=1
|
||||
/// dt_s=1.0; at stride=4 dt_s=4.0. Mamba2's selective scan uses
|
||||
/// `exp(-dt * sigmoid(a))` for the state-decay gate, so this matters
|
||||
/// for the SSM dynamics when stride > 1. `1` matches the legacy
|
||||
/// consecutive-snapshot behaviour exactly.
|
||||
pub decision_stride: usize,
|
||||
}
|
||||
|
||||
impl Default for PerceptionTrainerConfig {
|
||||
@@ -101,7 +94,6 @@ impl Default for PerceptionTrainerConfig {
|
||||
seed: 0x4242,
|
||||
horizon_weights: [1.0; N_HORIZONS],
|
||||
n_batch: 1,
|
||||
decision_stride: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1736,10 +1728,8 @@ impl PerceptionTrainer {
|
||||
self.stream.memset_zeros(&mut self.zero_h_d)
|
||||
.map_err(|e| anyhow::anyhow!("zero zero_h: {e}"))?;
|
||||
|
||||
// Launch configs reused inside the K loop. With decision_stride S,
|
||||
// dt_s = S so Mamba2's selective scan `exp(-dt * sigmoid(a))`
|
||||
// reflects the actual elapsed time between consecutive K-positions.
|
||||
let dt_s = self.cfg.decision_stride.max(1) as f32;
|
||||
// A1: decision_stride removed; CRT runs at event rate, dt = 1.0 per step.
|
||||
let dt_s = 1.0_f32;
|
||||
let n_in_i = HIDDEN_DIM as i32;
|
||||
let n_hid_i = HIDDEN_DIM as i32;
|
||||
let block_dim = 128u32;
|
||||
@@ -2794,7 +2784,8 @@ impl PerceptionTrainer {
|
||||
|
||||
// Per-K CfC + heads. Pointer arithmetic on stable base addresses;
|
||||
// no host branches inside the captured region.
|
||||
let dt_s = self.cfg.decision_stride.max(1) as f32;
|
||||
// A1: decision_stride removed; CRT runs at event rate, dt = 1.0 per step.
|
||||
let dt_s = 1.0_f32;
|
||||
let n_in_i = HIDDEN_DIM as i32;
|
||||
let n_hid_i = HIDDEN_DIM as i32;
|
||||
let cfc_fwd_smem = (2 * HIDDEN_DIM * std::mem::size_of::<f32>()) as u32;
|
||||
@@ -3107,9 +3098,9 @@ impl PerceptionTrainer {
|
||||
|
||||
// ── 9. CfC single step. h_old is the persistent cfc_h_state_step;
|
||||
// h_new is written to cfc_h_new_step (then copied back into
|
||||
// cfc_h_state_step). decision_stride=1 in event-rate mode —
|
||||
// the harness no longer applies stride scaling to dt_s.
|
||||
let dt_s: f32 = self.cfg.decision_stride.max(1) as f32;
|
||||
// cfc_h_state_step).
|
||||
// A1: decision_stride removed; event-rate dt = 1.0 per CfC step.
|
||||
let dt_s: f32 = 1.0;
|
||||
let n_in_i: i32 = HIDDEN_DIM as i32;
|
||||
let n_hid_i: i32 = HIDDEN_DIM as i32;
|
||||
let n_batch_i: i32 = b_sz as i32;
|
||||
@@ -3642,10 +3633,9 @@ impl PerceptionTrainer {
|
||||
}
|
||||
self.stream.memset_zeros(&mut self.zero_h_d).map_err(|e| anyhow::anyhow!("zero h: {e}"))?;
|
||||
|
||||
// Per-K forward (recurrent CfC + heads, batched). Eval mirrors
|
||||
// training: dt_s = decision_stride so Mamba2's SSM dynamics
|
||||
// match what was trained.
|
||||
let dt_s = self.cfg.decision_stride.max(1) as f32;
|
||||
// Per-K forward (recurrent CfC + heads, batched).
|
||||
// A1: decision_stride removed; event-rate dt = 1.0 per CfC step.
|
||||
let dt_s = 1.0_f32;
|
||||
let n_in_i = HIDDEN_DIM as i32;
|
||||
let n_hid_i = HIDDEN_DIM as i32;
|
||||
// Phase B fix-up: block-per-batch (Phase B), same launch contract as
|
||||
|
||||
@@ -96,7 +96,6 @@ fn build_trainer(dev: &MlDevice) -> Result<PerceptionTrainer> {
|
||||
n_batch: 1,
|
||||
mamba2_state_dim: 16,
|
||||
seed: SEED,
|
||||
decision_stride: 1,
|
||||
..Default::default()
|
||||
};
|
||||
PerceptionTrainer::new(dev, &cfg).context("trainer init")
|
||||
|
||||
@@ -22,7 +22,6 @@ fn cfg_from_env() -> Option<MultiHorizonLoaderConfig> {
|
||||
horizons: [30, 100, 300, 1000, 6000],
|
||||
n_max_sequences: 100,
|
||||
seed: 0xA1A2_A3A4,
|
||||
decision_stride: 1,
|
||||
inference_only: false,
|
||||
})
|
||||
}
|
||||
@@ -65,7 +64,6 @@ fn loader_errors_on_empty_files() {
|
||||
horizons: [30, 100, 300, 1000, 6000],
|
||||
n_max_sequences: 10,
|
||||
seed: 0,
|
||||
decision_stride: 1,
|
||||
inference_only: false,
|
||||
};
|
||||
let res = MultiHorizonLoader::new(&cfg);
|
||||
@@ -75,24 +73,18 @@ fn loader_errors_on_empty_files() {
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn loader_stride_4_yields_correct_spacing() {
|
||||
// Verifies decision_stride=4 produces snapshots that are 4-apart in
|
||||
// the source file. Only meaningful with real data. Uses ts_ns to
|
||||
// confirm the spacing (since indices aren't exposed externally).
|
||||
// A1: decision_stride removed; sequences are always consecutive snapshots.
|
||||
// Test repurposed: verifies consecutive-snapshot ts_ns monotonicity.
|
||||
let Some(mut cfg) = cfg_from_env() else {
|
||||
eprintln!("skipping: FOXHUNT_TEST_DATA not set or mbp10 dir missing");
|
||||
return;
|
||||
};
|
||||
cfg.decision_stride = 4;
|
||||
cfg.seq_len = 32;
|
||||
cfg.n_max_sequences = 5;
|
||||
let mut loader = MultiHorizonLoader::new(&cfg).expect("loader init");
|
||||
|
||||
while let Some(seq) = loader.next_sequence().expect("next") {
|
||||
assert_eq!(seq.snapshots.len(), cfg.seq_len);
|
||||
// The Δt between consecutive K-positions should be visibly
|
||||
// larger than a single-tick gap. At stride=4 on ES MBP-10, that
|
||||
// means the median Δt across positions should exceed ~3× the
|
||||
// typical tick interval (microsecond-scale jitter is fine).
|
||||
let dts_ns: Vec<i64> = (1..seq.snapshots.len())
|
||||
.map(|k| (seq.snapshots[k].ts_ns as i64) - (seq.snapshots[k - 1].ts_ns as i64))
|
||||
.filter(|d| *d >= 0) // ts_ns is monotonic in MBP-10
|
||||
@@ -101,11 +93,7 @@ fn loader_stride_4_yields_correct_spacing() {
|
||||
let mut sorted = dts_ns.clone();
|
||||
sorted.sort();
|
||||
let median = sorted[sorted.len() / 2];
|
||||
eprintln!("stride=4 median Δt_ns between K-positions = {median}");
|
||||
// Sanity: median Δt should be > 0 (stride>1 means no zero-Δt
|
||||
// self-pairs in the steady state). Stride > 1 with sparse
|
||||
// periods could theoretically dip to zero on quiet windows,
|
||||
// so don't assert a lower bound on the median magnitude.
|
||||
eprintln!("consecutive median Δt_ns between K-positions = {median}");
|
||||
assert!(median >= 0, "Δt_ns negative — ts_ns not monotonic?");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,6 @@ fn stacked_trainer_loss_shrinks_on_constant_signal() {
|
||||
seed: 0x4242,
|
||||
horizon_weights: [1.0; 5],
|
||||
n_batch: 1,
|
||||
decision_stride: 1,
|
||||
};
|
||||
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
|
||||
|
||||
@@ -130,12 +129,11 @@ fn stacked_trainer_loss_shrinks_on_constant_signal() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Verifies the trainer still converges on a constant-direction signal
|
||||
/// with `decision_stride = 4`. Stride affects loader output AND Mamba2
|
||||
/// dt_s (= 4.0 in the K-loop) — this test ensures the full chain stays
|
||||
/// numerically stable. Synthetic sequence here uses consecutive
|
||||
/// snapshots (the loader-level stride is what skips); the trainer-level
|
||||
/// dt_s change is what this smoke proves.
|
||||
/// Verifies the trainer still converges with a distinct seed and lr.
|
||||
/// A1: decision_stride removed; dt_s is always 1.0 (event-rate).
|
||||
/// The test previously verified stride=4 dt_s dynamics; those are
|
||||
/// now unconditionally event-rate, so this becomes a second convergence
|
||||
/// smoke with different hyperparameters.
|
||||
#[test]
|
||||
fn stacked_trainer_loss_shrinks_with_stride_4() {
|
||||
let dev = test_device();
|
||||
@@ -147,7 +145,6 @@ fn stacked_trainer_loss_shrinks_with_stride_4() {
|
||||
seed: 0xC4C4,
|
||||
horizon_weights: [1.0; 5],
|
||||
n_batch: 1,
|
||||
decision_stride: 4,
|
||||
};
|
||||
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
|
||||
|
||||
@@ -201,7 +198,6 @@ fn stacked_trainer_loss_shrinks_at_batch_32() {
|
||||
seed: 0xB32B,
|
||||
horizon_weights: [1.0; 5],
|
||||
n_batch: 32,
|
||||
decision_stride: 1,
|
||||
};
|
||||
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
|
||||
|
||||
@@ -299,7 +295,6 @@ fn evaluate_alone_succeeds() {
|
||||
seed: 0x6262,
|
||||
horizon_weights: [1.0; 5],
|
||||
n_batch: 1,
|
||||
decision_stride: 1,
|
||||
};
|
||||
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
|
||||
let ts = 1_000_000u64;
|
||||
@@ -327,7 +322,6 @@ fn evaluate_works_after_captured_training_step() {
|
||||
seed: 0x5151,
|
||||
horizon_weights: [1.0; 5],
|
||||
n_batch: 1,
|
||||
decision_stride: 1,
|
||||
};
|
||||
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
|
||||
|
||||
@@ -362,7 +356,6 @@ fn evaluate_works_after_capture_no_replay() {
|
||||
seed: 0x8181,
|
||||
horizon_weights: [1.0; 5],
|
||||
n_batch: 1,
|
||||
decision_stride: 1,
|
||||
};
|
||||
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
|
||||
let ts = 1_000_000u64;
|
||||
@@ -392,7 +385,6 @@ fn horizon_ema_and_lambda_track_after_training() {
|
||||
seed: 0x9292,
|
||||
horizon_weights: [1.0; 5],
|
||||
n_batch: 1,
|
||||
decision_stride: 1,
|
||||
};
|
||||
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
|
||||
|
||||
@@ -448,7 +440,6 @@ fn evaluate_works_after_warmup_only() {
|
||||
seed: 0x7171,
|
||||
horizon_weights: [1.0; 5],
|
||||
n_batch: 1,
|
||||
decision_stride: 1,
|
||||
};
|
||||
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
|
||||
let ts = 1_000_000u64;
|
||||
|
||||
@@ -309,11 +309,8 @@ extern "C" __global__ void resting_orders_step(
|
||||
last_event_ts[b] = current_ts_ns;
|
||||
|
||||
// S2.2: max_hold force-close at event rate.
|
||||
// Mirrors the decision-rate check that used to live in stop_check_isv (removed),
|
||||
// but runs every event so the 60s cap is actually enforceable. The previous
|
||||
// decision-rate check fired every decision_stride events (~13 min sim-time on ES
|
||||
// data at stride=200), so it was ~13 min LATE on average. Event-rate enforces
|
||||
// within one event of the cap.
|
||||
// Runs every event so the 60s cap is actually enforceable.
|
||||
// A1: decision_stride removed; all enforcement is event-rate.
|
||||
const unsigned long long max_hold = max_hold_ns_per_b[b];
|
||||
if (max_hold > 0ull && pos.position_lots != 0) {
|
||||
const unsigned long long entry_ts =
|
||||
|
||||
@@ -27,7 +27,6 @@ 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,
|
||||
@@ -148,7 +147,7 @@ impl BacktestHarness {
|
||||
horizons: [30, 100, 300, 1000, 6000],
|
||||
n_max_sequences: 0,
|
||||
seed: 0,
|
||||
decision_stride: cfg.decision_stride,
|
||||
// A1: decision_stride removed; inference mode walks every event at stride=1.
|
||||
inference_only: true,
|
||||
};
|
||||
let loader = MultiHorizonLoader::new(&loader_cfg)?;
|
||||
@@ -221,7 +220,6 @@ impl BacktestHarness {
|
||||
/// 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;
|
||||
// Periodic progress line — without this, multi-million-event
|
||||
// runs look identical to a deadlock in pod logs.
|
||||
@@ -247,24 +245,17 @@ impl BacktestHarness {
|
||||
}
|
||||
self.snapshot_window.push_back(raw.clone());
|
||||
|
||||
// CRT Phase A0.5 (corrective): advance the encoder state on
|
||||
// EVERY event once the window is bootstrapped. The GRN heads
|
||||
// kernel writes the per-horizon probs directly into the sim's
|
||||
// on-device `alpha_probs_d` — zero CPU touchpoint, no
|
||||
// stream.synchronize on the hot path. forward_step_into is
|
||||
// O(hidden_dim × state_dim) per call — independent of K.
|
||||
// CRT A1: once the window is full, fire BOTH forward_step_into AND
|
||||
// step_decision_with_latency on every event. decision_stride is gone;
|
||||
// the window-full guard is the only gate.
|
||||
// forward_step_into (A0.5) advances the SSM state and writes
|
||||
// per-horizon probs into alpha_probs_d. step_decision_with_latency
|
||||
// reads alpha_probs_d immediately after — zero CPU roundtrip.
|
||||
if self.snapshot_window.len() == self.seq_len {
|
||||
self.trainer
|
||||
.forward_step_into(&raw, self.sim.alpha_probs_d_mut())
|
||||
.context("trainer.forward_step_into")?;
|
||||
}
|
||||
|
||||
// Decision-stride gate: step the sim only at stride boundaries.
|
||||
// A1 will remove this gate so decisions fire every event.
|
||||
// `broadcast_alpha` is gone: forward_step_into already wrote
|
||||
// the probs into `alpha_probs_d`, so the decision kernels see
|
||||
// the freshest event's probs without any host roundtrip.
|
||||
if self.event_count % stride == 0 && self.snapshot_window.len() == self.seq_len {
|
||||
// Side-channel: record this decision's max_conviction
|
||||
// on-device for the threshold-tuning percentile
|
||||
// computation. Kernel reads `alpha_probs_d` and writes
|
||||
@@ -272,7 +263,7 @@ impl BacktestHarness {
|
||||
// Single end-of-run DtoV in `read_convictions` materialises
|
||||
// the host log. Doing it BEFORE step_decision so the log
|
||||
// captures every decision attempt, including those the
|
||||
// threshold gate would skip — matches prior behaviour.
|
||||
// threshold gate would skip.
|
||||
self.sim.record_max_conviction(self.convictions_recorded)?;
|
||||
self.convictions_recorded = self.convictions_recorded.saturating_add(1);
|
||||
|
||||
|
||||
@@ -47,7 +47,6 @@ fn try_loader() -> Option<MultiHorizonLoader> {
|
||||
horizons: [30, 100, 300, 1000, 6000],
|
||||
n_max_sequences: 0,
|
||||
seed: 0xCAFE_F00D,
|
||||
decision_stride: 1,
|
||||
inference_only: true,
|
||||
};
|
||||
match MultiHorizonLoader::new(&cfg) {
|
||||
|
||||
@@ -34,7 +34,6 @@ fn try_loader(inference_only: bool) -> Option<MultiHorizonLoader> {
|
||||
horizons: [30, 100, 300, 1000, 6000],
|
||||
n_max_sequences: 1,
|
||||
seed: 0xCAFEF00D,
|
||||
decision_stride: 1,
|
||||
inference_only,
|
||||
};
|
||||
match MultiHorizonLoader::new(&cfg) {
|
||||
|
||||
@@ -226,14 +226,6 @@ struct Cli {
|
||||
#[arg(long, default_value_t = 0.99)]
|
||||
gamma: f32,
|
||||
|
||||
/// Decision stride — emit a new action only every N steps; between
|
||||
/// decisions force `action=0` (wait), preserving the previously-opened
|
||||
/// position. Aligns DQN decision cadence with the multi-minute alpha
|
||||
/// horizon and stops the policy from flip-flopping on per-bar noise
|
||||
/// ("coin-flip problem"). 1 = current per-bar behaviour. Pair with
|
||||
/// γ→0.999+ so the Bellman chain reaches across the wait segments.
|
||||
#[arg(long, default_value_t = 1)]
|
||||
decision_stride: u32,
|
||||
#[arg(long, default_value_t = 0.9)]
|
||||
alpha_m: f32,
|
||||
#[arg(long, default_value_t = 0.03)]
|
||||
@@ -660,16 +652,8 @@ fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
stream.synchronize()?;
|
||||
// `--decision-stride N`: only emit a new action every N steps;
|
||||
// between strides force action=0 (wait) so an open position is
|
||||
// held rather than re-decided per bar. Avoids the per-bar
|
||||
// "coin-flip" overtrading. step==0 is always a decision.
|
||||
let stride_train = cli.decision_stride.max(1) as usize;
|
||||
let actions = if step % stride_train == 0 {
|
||||
batched_action_pinned_train.read_all()
|
||||
} else {
|
||||
vec![0_i32; train_n_par]
|
||||
};
|
||||
// A1: decision_stride removed; act on every step.
|
||||
let actions = batched_action_pinned_train.read_all();
|
||||
|
||||
for i in 0..train_n_par {
|
||||
if done_flags[i] { continue; }
|
||||
@@ -1011,17 +995,8 @@ fn main() -> Result<()> {
|
||||
}
|
||||
// ONE sync per step (instead of N).
|
||||
stream.synchronize()?;
|
||||
// `--decision-stride N`: same rate-limit semantics as training.
|
||||
// Between strides force action=0 so an open passive limit
|
||||
// post / live position is held rather than re-decided. Cuts
|
||||
// per-bar trade counts ~stride× and removes the coin-flip
|
||||
// overtrading on noise.
|
||||
let stride_eval = cli.decision_stride.max(1) as usize;
|
||||
let actions = if step % stride_eval == 0 {
|
||||
batched_action_pinned.read_all()
|
||||
} else {
|
||||
vec![0_i32; n_par]
|
||||
};
|
||||
// A1: decision_stride removed; act on every step.
|
||||
let actions = batched_action_pinned.read_all();
|
||||
// Step all envs on CPU.
|
||||
for i in 0..n_par {
|
||||
if done_flags[i] { continue; }
|
||||
|
||||
Reference in New Issue
Block a user