diff --git a/crates/ml-alpha/src/data/loader.rs b/crates/ml-alpha/src/data/loader.rs
index 5e2052c2e..a53f7ed5c 100644
--- a/crates/ml-alpha/src/data/loader.rs
+++ b/crates/ml-alpha/src/data/loader.rs
@@ -679,6 +679,55 @@ impl MultiHorizonLoader {
self.next_sequence_random()
}
+ /// Phase R2 (was G fix): return a `(s_t, s_{t+1})` pair of
+ /// consecutive sequences at adjacent anchors in the same file.
+ /// The first sequence ends one snapshot earlier than the second —
+ /// the canonical RL Bellman-target requirement that
+ /// `IntegratedTrainer::step_with_lobsim` consumes as
+ /// `(snapshots, next_snapshots)`.
+ ///
+ /// Both yielded sequences come from the SAME random file so the
+ /// underlying market regime / time-of-day context is consistent.
+ /// The anchor for `s_t` is sampled uniformly from `[min_anchor,
+ /// max_anchor − 1)` so `anchor + 1` also satisfies the
+ /// upper-bound constraint. Counts as ONE yielded sequence against
+ /// `n_max_sequences` (the loader views the pair as one training
+ /// step's worth of input).
+ pub fn next_sequence_pair(
+ &mut self,
+ ) -> Result> {
+ if self.yielded >= self.cfg.n_max_sequences {
+ return Ok(None);
+ }
+ let n_files = self.files_loaded.len();
+ let file_idx = self.rng.gen_range(0..n_files);
+ let lf = &self.files_loaded[file_idx];
+
+ let max_horizon = *self.cfg.horizons.iter().max().expect("non-empty horizons");
+ let total = self.cfg.multi_resolution.total_positions();
+ let lookback = self.cfg.multi_resolution.required_lookback_ticks();
+ let min_anchor = lookback.saturating_sub(total);
+ // Reserve one extra event past the standard max_anchor so
+ // `anchor + 1`'s window also fits within the file.
+ let max_anchor = lf
+ .snapshots
+ .len()
+ .saturating_sub(total + max_horizon + 1);
+ anyhow::ensure!(
+ max_anchor > min_anchor,
+ "file too short for paired-window sampling (total={total}, lookback={lookback}, max_horizon={max_horizon}): \
+ snapshots.len()={} cannot span [{min_anchor}, {max_anchor})",
+ lf.snapshots.len()
+ );
+ let anchor_t: usize = self.rng.gen_range(min_anchor..max_anchor);
+
+ let s_t = self.build_sequence_at(lf, anchor_t)?;
+ let s_tp1 = self.build_sequence_at(lf, anchor_t + 1)?;
+
+ self.yielded += 1;
+ Ok(Some((s_t, s_tp1)))
+ }
+
fn next_sequence_random(&mut self) -> Result > {
if self.yielded >= self.cfg.n_max_sequences {
return Ok(None);
@@ -712,6 +761,24 @@ impl MultiHorizonLoader {
);
let anchor: usize = self.rng.gen_range(min_anchor..max_anchor);
+ let seq = self.build_sequence_at(lf, anchor)?;
+ self.yielded += 1;
+ Ok(Some(seq))
+ }
+
+ /// Construct a `LabeledSequence` at a specific `(file, anchor)`
+ /// pair. Pure function of the loader config + file contents; does
+ /// not advance the loader's `yielded` counter (callers update it).
+ ///
+ /// Phase R2: this helper exists so `next_sequence_random` (random
+ /// anchor) and `next_sequence_pair` (paired adjacent anchors)
+ /// share the multi-resolution windowing logic verbatim per
+ /// `feedback_single_source_of_truth_no_duplicates`.
+ fn build_sequence_at(
+ &self,
+ lf: &LoadedFile,
+ anchor: usize,
+ ) -> Result {
let mut labels: [Vec; N_HORIZONS] = Default::default();
let mut outcome_prof_long: [Vec; N_HORIZONS] = Default::default();
let mut outcome_prof_short: [Vec; N_HORIZONS] = Default::default();
@@ -777,8 +844,7 @@ impl MultiHorizonLoader {
let sequence: Vec = sequence_rev.into_iter().rev().collect();
debug_assert_eq!(sequence.len(), total);
- self.yielded += 1;
- Ok(Some(LabeledSequence {
+ Ok(LabeledSequence {
snapshots: sequence,
labels,
outcome_prof_long,
@@ -787,7 +853,7 @@ impl MultiHorizonLoader {
outcome_size_short,
sigma_k,
pos_fraction: lf.pos_fraction.clone(),
- }))
+ })
}
}
diff --git a/crates/ml-backtesting/cuda/order_match.cu b/crates/ml-backtesting/cuda/order_match.cu
index 55a192840..ea3e3c6bd 100644
--- a/crates/ml-backtesting/cuda/order_match.cu
+++ b/crates/ml-backtesting/cuda/order_match.cu
@@ -67,12 +67,21 @@ __device__ static float walk_bid_for_sell(
//
// S1.22: min_reasonable_px / max_reasonable_px added so walk_* can reject
// sentinel-priced depth levels (zero-priced or huge-priced empty MBP-10 slots).
+//
+// Phase R2 (was F.3a): cost_per_lot_per_side + total_fees_per_b mirror
+// the per-fill fee deduction in resting_orders.cu::apply_fill_to_pos
+// (lines 213-219). Fee is deducted from pos.realized_pnl on every fill
+// (open, scale-in, counter) and accumulated to total_fees_per_b for
+// telemetry. Default cost is zero (LobSimCuda::new), so this is a
+// no-op for callers that don't call upload_cost_per_lot_per_side.
extern "C" __global__ void submit_market_immediate(
const Book* __restrict__ books,
const int* __restrict__ targets, // [n_backtests, 2]
Pos* __restrict__ positions, // [n_backtests]
const float* __restrict__ min_reasonable_px, // [n_backtests]
const float* __restrict__ max_reasonable_px, // [n_backtests]
+ const float* __restrict__ cost_per_lot_per_side, // [n_backtests]
+ float* __restrict__ total_fees_per_b, // [n_backtests]
int n_backtests
) {
int b = blockIdx.x;
@@ -127,4 +136,13 @@ extern "C" __global__ void submit_market_immediate(
// If position is now flat, vwap_entry becomes irrelevant.
}
pos.position_lots = (int)new_pos_f;
+
+ // Phase R2 (was F.3a): per-fill cost deduction
+ // (single-writer-per-block, plain += safe per feedback_no_atomicadd).
+ // Mirrors apply_fill_to_pos at resting_orders.cu:213-219 so the
+ // simple submit_market path matches the production resting-order
+ // path's economic model.
+ const float fill_cost = filled * cost_per_lot_per_side[b];
+ pos.realized_pnl -= fill_cost;
+ total_fees_per_b[b] += fill_cost;
}
diff --git a/crates/ml-backtesting/src/sim/mod.rs b/crates/ml-backtesting/src/sim/mod.rs
index d322b564c..943898a28 100644
--- a/crates/ml-backtesting/src/sim/mod.rs
+++ b/crates/ml-backtesting/src/sim/mod.rs
@@ -1189,6 +1189,8 @@ impl LobSimCuda {
.arg(&mut self.pos_d)
.arg(&self.min_reasonable_px_d)
.arg(&self.max_reasonable_px_d)
+ .arg(&self.cost_per_lot_per_side_d)
+ .arg(&mut self.total_fees_per_b_d)
.arg(&n)
.launch(cfg)?;
}
@@ -1199,6 +1201,22 @@ impl LobSimCuda {
Ok(())
}
+ /// Upload the per-batch fee constant (USD per contract per side).
+ /// Default after `new()` is zero — callers should set this before
+ /// the first `submit_market` call if they want fees to apply. ES
+ /// futures default per AMP/CME pricing is ≈$1.25/contract/side
+ /// (~$2.50 round-trip). The production `step_decision_with_latency`
+ /// path uploads its own cost via `sim_cfg.cost_per_lot_per_side`.
+ pub fn upload_cost_per_lot_per_side(&mut self, cost: &[f32]) -> Result<()> {
+ anyhow::ensure!(
+ cost.len() == self.n_backtests,
+ "upload_cost_per_lot_per_side: len {} != n_backtests {}",
+ cost.len(), self.n_backtests
+ );
+ self.stream.memcpy_htod(cost, &mut self.cost_per_lot_per_side_d)?;
+ Ok(())
+ }
+
/// Run pnl_track_step kernel: detect segment_complete, emit TradeRecord.
/// Called automatically by submit_market; exposed for callers that
/// orchestrate matching themselves.