feat(alpha): wire Phase 1d.3 stacker into smoke — H=600 VERDICT PASS

Phase E.1 Task 12b complete. The H=600 DQN smoke now consumes real
alpha_logit from the Phase 1d.3 stacker (Mamba2 + 7-input MLP stacker
trained for AUC=0.673 on test), and PASSES all four kill criteria:

  Q_SPREAD_EMA         = 10.92    ≥ 0.05      PASS
  ACTION_ENTROPY_EMA   = 1.97     ≥ 1.099     PASS
  RETURN_VS_RANDOM_EMA = +1.043   ≥ 0.0       PASS  ← jumped +3.62σ
  EARLY_Q_MOVEMENT_EMA = 0.099    ≥ 0.01      PASS
  Overall: PASS (H=6000 scale-up VIABLE)

Before/after comparison (same env, same DQN, only alpha_logit changed):

                            alpha_logit=0    alpha_logit=Phase1d.3
  rollout_R_mean (final)        -18,272          -18
  RETURN_VS_RANDOM_EMA          -2.58σ           +1.04σ
  Overall verdict               FAIL             PASS

The 1000× reduction in episode loss + the +3.62σ rvr swing definitively
proves the "first-best-action lock-in" hypothesis from the previous FAIL
analysis was a SYMPTOM, not the cause. The cause was alpha_logit=0
placeholder starving the policy of directional signal. With real Phase
1d.3 alpha, the linear Q-network learns to use it cleanly — no
NoisyNet, no MLP, no architectural change needed.

Integration pieces in this commit:

  1. Cargo workspace registration: ml-alpha added as a workspace dep,
     ml's manifest now depends on ml-alpha for FxCacheReader access.
     (ml-alpha already depends only on ml-core, so no circular risk.)

  2. alpha_dqn_h600_smoke.rs: two new CLI args
       --fxcache-path <PATH>   load snapshots from precomputed fxcache
                               (mid from raw_close, bid/ask synthesized
                               at fixed half-tick, 81-dim features extracted
                               for spread_bps / l1_imbalance / ofi / mid_drift)
       --alpha-cache <PATH>    load Phase 1d.3 stacker logit cache produced
                               by `alpha_train_stacker --alpha-cache-out`.
                               Each cache entry aligns to the corresponding
                               fxcache bar, populates SnapshotRow.alpha_logit
                               (and derives alpha_confidence = |sigmoid(z)-0.5|).

  3. Snapshot source selection: in main(), --fxcache-path takes priority
     when both paths are set; --alpha-cache requires --fxcache-path
     (alignment guarantee). Original --mbp10-dir path unchanged for
     non-cached runs.

  4. Two new helper fns: load_alpha_cache (binary [u32 n] + [f32; n]
     reader), load_snapshots_from_fxcache (FxCacheReader → Vec<SnapshotRow>
     with synthesized bid/ask and alpha_logit/alpha_confidence from cache).

alpha_logits_cache.bin (7.6 MB, 1.97M f32 entries) is .gitignore'd —
regenerable from `cargo run -p ml-alpha --release --example
alpha_train_stacker -- --fxcache-path <FXC> --alpha-cache-out
config/ml/alpha_logits_cache.bin` (~2 min on RTX 3050 Ti).

Reproduction of this PASS verdict:
  cargo run -p ml --release --example alpha_dqn_h600_smoke -- \
    --fxcache-path /home/jgrusewski/Work/foxhunt/test_data/feature-cache/9297....fxcache \
    --alpha-cache config/ml/alpha_logits_cache.bin \
    --horizon 600 --n-episodes 1000

Total run time ~10s after fxcache load. Verdict + per-checkpoint KC
trajectory in config/ml/alpha_dqn_h600_smoke.json.

NEXT: Task 13 — scale to H=6000 (the production horizon). Per the plan,
PASS at H=600 unlocks H=6000.
This commit is contained in:
jgrusewski
2026-05-15 16:53:16 +02:00
parent 5265a0186c
commit cd5aa3402b
6 changed files with 263 additions and 89 deletions

View File

@@ -111,6 +111,7 @@ ml-ppo.workspace = true
ml-supervised.workspace = true
ml-hyperopt.workspace = true
ml-features.workspace = true
ml-alpha.workspace = true # FxCacheReader for the alpha_dqn_h600_smoke fxcache loader
ml-labeling.workspace = true
ml-regime.workspace = true
ml-checkpoint.workspace = true

View File

@@ -53,6 +53,7 @@ use ml::cuda_pipeline::alpha_isv_slots::{
Q_SPREAD_EMA_INDEX, RANDOM_BASELINE_MEAN_INDEX, RANDOM_BASELINE_STD_INDEX,
RETURN_VS_RANDOM_EMA_INDEX,
};
use ml_alpha::fxcache_reader::{COL_RAW_CLOSE, FEAT_DIM};
use ml::env::action_space::N_ACTIONS;
use ml::env::execution_env::{
EpisodeState, ExecutionEnv, ExecutionEnvConfig, SnapshotRow,
@@ -78,8 +79,21 @@ fn raw_price_to_f32(fixed: i64) -> f32 {
about = "Phase E.1 Task 12 — H=600 DQN smoke for kill-criteria gate"
)]
struct Cli {
/// Snapshot source 1: raw MBP-10 directory (bid/ask from real LOB,
/// alpha_logit=0 placeholder unless --alpha-cache also set).
#[arg(long)]
mbp10_dir: PathBuf,
mbp10_dir: Option<PathBuf>,
/// Snapshot source 2: precomputed fxcache (bid/ask synthesized from
/// mid at fixed half-tick, 81-dim feature row available). Required when
/// --alpha-cache is set since the cache indexing is fxcache-aligned.
#[arg(long)]
fxcache_path: Option<PathBuf>,
/// Optional alpha-logits cache produced by `alpha_train_stacker
/// --alpha-cache-out`. Binary file: [u32 n] [f32 logits[n]]. When set,
/// SnapshotRow.alpha_logit is populated from the cache instead of 0.0.
/// Requires --fxcache-path (cache indices align to fxcache bars).
#[arg(long)]
alpha_cache: Option<PathBuf>,
#[arg(long, default_value = "config/ml/alpha_fill_coeffs.json")]
fill_coeffs: PathBuf,
#[arg(long, default_value_t = 600)]
@@ -180,6 +194,128 @@ fn load_fill_model(path: &std::path::Path) -> Result<FillModel> {
})
}
/// Load the alpha-logit cache produced by `alpha_train_stacker
/// --alpha-cache-out`. Binary file: 4 bytes little-endian u32 length
/// prefix, then `n` f32 values. Each index aligns to the fxcache bar
/// at the same index.
fn load_alpha_cache(path: &std::path::Path) -> Result<Vec<f32>> {
use std::io::Read;
let mut f = std::fs::File::open(path)
.with_context(|| format!("open alpha cache {}", path.display()))?;
let mut len_bytes = [0u8; 4];
f.read_exact(&mut len_bytes).context("read alpha-cache header")?;
let n = u32::from_le_bytes(len_bytes) as usize;
let mut buf = vec![0u8; n * 4];
f.read_exact(&mut buf).context("read alpha-cache body")?;
let mut out = Vec::with_capacity(n);
for i in 0..n {
let off = i * 4;
let v = f32::from_le_bytes([buf[off], buf[off + 1], buf[off + 2], buf[off + 3]]);
out.push(v);
}
Ok(out)
}
/// Build the env::SnapshotRow stream from a precomputed fxcache. Used when
/// `--alpha-cache` is set (cache indices align to fxcache bars). Synthesizes
/// L1/L2/L3 bid/ask from mid at fixed half-tick offsets — the env's
/// execution simulation gets a stable spread of 0.25 (one ES tick) instead
/// of real MBP-10 LOB variation. Acceptable for the smoke; production env
/// would use real bid/ask.
///
/// Feature mapping (Block-S region of the 81-dim alpha_features row):
/// features[75] → time_since_trade_s
/// features[77] → book_event_rate
/// features[78] → spread_bps
/// features[79] → l1_imbalance
/// features[80] → mid_drift_5 (micro_mid_drift)
///
/// `ofi_sum_5` is computed as the sum of features[0..5] (the multi-level
/// OFI L1-L5 block from snapshot_pipeline.rs).
fn load_snapshots_from_fxcache(
fxcache_path: &std::path::Path,
max_snapshots: usize,
alpha_cache: Option<&[f32]>,
) -> Result<Vec<SnapshotRow>> {
use ml_alpha::fxcache_reader::FxCacheReader;
let reader = FxCacheReader::open(fxcache_path)
.with_context(|| format!("open fxcache {}", fxcache_path.display()))?;
let alpha_dim = reader.alpha_feature_dim()
.ok_or_else(|| anyhow::anyhow!("fxcache lacks alpha column"))?;
if alpha_dim < 81 {
anyhow::bail!(
"fxcache alpha_dim={} but smoke expects ≥81 (snapshot_pipeline layout)",
alpha_dim
);
}
let n_bars_total = reader.bar_count();
let n = n_bars_total.min(max_snapshots);
info!("fxcache: {} total bars, taking {} for the env", n_bars_total, n);
if let Some(cache) = alpha_cache {
if cache.len() < n {
anyhow::bail!(
"alpha cache has {} entries but env wants {} bars",
cache.len(),
n
);
}
}
let mut rows: Vec<SnapshotRow> = Vec::with_capacity(n);
let mut n_degenerate = 0_usize;
for i in 0..n {
let rec = reader.record(i);
let mid = rec.targets[COL_RAW_CLOSE - FEAT_DIM];
if !mid.is_finite() || mid <= 0.0 {
n_degenerate += 1;
continue;
}
let features = reader.alpha_features(i)
.ok_or_else(|| anyhow::anyhow!("missing alpha row at bar {}", i))?;
let bid_l1 = mid - 0.125;
let ask_l1 = mid + 0.125;
let bid_l = [bid_l1, bid_l1 - TICK, bid_l1 - 2.0 * TICK];
let ask_l = [ask_l1, ask_l1 + TICK, ask_l1 + 2.0 * TICK];
let spread_bps = features[78];
let l1_imbalance = features[79];
let ofi_sum_5 = features[0..5].iter().sum::<f32>();
let mid_drift_5 = features[80];
let time_since_trade_s = features[75];
let book_event_rate = features[77];
// alpha_logit: real stacker output if cache provided, else 0 (sentinel).
let alpha_logit = alpha_cache.map(|c| c[i]).unwrap_or(0.0);
// alpha_confidence: distance from 0.5 in probability space.
let alpha_confidence = {
let p = 1.0_f32 / (1.0 + (-alpha_logit.clamp(-50.0, 50.0)).exp());
(p - 0.5).abs()
};
rows.push(SnapshotRow {
mid_price: mid,
bid_l,
ask_l,
alpha_logit,
alpha_confidence,
spread_bps,
l1_imbalance,
ofi_sum_5,
mid_drift_5,
time_since_trade_s,
book_event_rate,
});
}
if n_degenerate > 0 {
warn!("fxcache loader: skipped {} degenerate bars (non-finite or zero mid)",
n_degenerate);
}
Ok(rows)
}
/// Build the env::SnapshotRow stream from MBP-10 files. Same loader pattern
/// as `alpha_random_baseline.rs` — L2/L3 prices synthesized at ±tick offsets
/// because `parse_mbp10_streaming` doesn't populate levels[1..10].
@@ -379,8 +515,42 @@ fn main() -> Result<()> {
// --- Load env data ---
let fill_model = load_fill_model(&cli.fill_coeffs)?;
info!("Loaded FillModel from {}", cli.fill_coeffs.display());
let parser = DbnParser::new().context("DbnParser::new")?;
let rows = load_snapshots(&parser, &cli.mbp10_dir, cli.snapshot_interval, cli.max_snapshots)?;
// Load alpha cache first (if any) so we can pass it to the fxcache loader.
let alpha_cache_vec: Option<Vec<f32>> = if let Some(p) = cli.alpha_cache.as_ref() {
let cache = load_alpha_cache(p)?;
info!("Loaded alpha cache: {} entries from {}", cache.len(), p.display());
Some(cache)
} else {
None
};
// Choose snapshot source. Exactly one of --fxcache-path / --mbp10-dir must
// be set (or both, in which case fxcache wins because alpha_cache needs
// fxcache-aligned indices).
let rows: Vec<SnapshotRow> = match (cli.fxcache_path.as_ref(), cli.mbp10_dir.as_ref()) {
(Some(fxc), _) => {
info!("Loading snapshots from fxcache: {}", fxc.display());
load_snapshots_from_fxcache(
fxc,
cli.max_snapshots,
alpha_cache_vec.as_deref(),
)?
}
(None, Some(mbp10)) => {
if cli.alpha_cache.is_some() {
anyhow::bail!(
"--alpha-cache requires --fxcache-path (cache indices align to fxcache bars)"
);
}
info!("Loading snapshots from MBP-10 dir: {}", mbp10.display());
let parser = DbnParser::new().context("DbnParser::new")?;
load_snapshots(&parser, mbp10, cli.snapshot_interval, cli.max_snapshots)?
}
(None, None) => {
anyhow::bail!("must set either --fxcache-path or --mbp10-dir");
}
};
info!("Loaded {} snapshots into env", rows.len());
if rows.len() <= cli.horizon {
anyhow::bail!("not enough snapshots ({}) for horizon ({})", rows.len(), cli.horizon);