feat(alpha): decision-stride + cluster 9-fold CV workflow

Two complementary additions to validate the minute-horizon alpha
hypothesis at IBKR-realistic costs:

1. `alpha_baseline --decision-stride N`: emits a new action every N
   steps; between decisions force action=0 (wait) so an open position
   is held rather than re-decided per bar. Cuts per-bar trade counts
   ~stride× and removes the coin-flip overtrading. Local 2Q sweep
   showed stride=200 + scaled training (8K episodes × 25 envs × H=1200)
   flipped Sharpe at ¼-tick from -4.29 (per-bar, 3-fold mean) to +1.78,
   with std collapsing from ±8.8 to ±1.15. Break-even cost moved from
   <¼-tick to ~1-tick — for the first time positive at IBKR-realistic
   passive-execution frictions.

2. `alpha_train_stacker --max-rows N`: optional cap on bars consumed
   from the fxcache. Used during local 2Q smoke (--max-rows 4M against
   the 17.8M-row 9Q fxcache) to fit Mamba2 training on a 4 GB consumer
   GPU; on the cluster (--no-cap) it sees all 9Q.

3. New Argo workflow `alpha-cv`: standalone template that compiles
   alpha_train_stacker + alpha_baseline + alpha_fill_coeffs.json,
   trains the stacker on the 9Q fxcache, then runs 9 sequential
   walk-forward folds of alpha_baseline on disjoint 1.9M-bar windows
   (one per quarter). Launcher script `scripts/argo-alpha-cv.sh`
   mirrors argo-train.sh conventions.

The local 2Q test that motivated this commit is summarised inline in
the alpha-cv template comments; the verdict was "framing was the bug —
once decision cadence matches the multi-minute alpha horizon, the
strategy is positive at IBKR commission".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-16 18:04:34 +02:00
parent 737c2c1305
commit 10f4bcc15b
4 changed files with 623 additions and 5 deletions

View File

@@ -225,6 +225,15 @@ struct Cli {
eps_end: f32,
#[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)]
@@ -651,7 +660,16 @@ fn main() -> Result<()> {
}
}
stream.synchronize()?;
let actions = batched_action_pinned_train.read_all();
// `--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]
};
for i in 0..train_n_par {
if done_flags[i] { continue; }
@@ -901,7 +919,7 @@ fn main() -> Result<()> {
.context("reset vol_obs min slot")?;
}
// Lockstep step loop.
for _step in 0..cli.horizon {
for step in 0..cli.horizon {
// Gather current states (CPU; <100μs for N=500).
let mut batched_states_host = vec![0.0_f32; n_par * STATE_DIM];
for i in 0..n_par {
@@ -993,7 +1011,17 @@ fn main() -> Result<()> {
}
// ONE sync per step (instead of N).
stream.synchronize()?;
let actions = batched_action_pinned.read_all();
// `--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]
};
// Step all envs on CPU.
for i in 0..n_par {
if done_flags[i] { continue; }