feat(ml-alpha): Phase 1d.2 multi-minute label + smoke (K=6000 architectural test)

The DECISIVE gate for FoxhuntQ-Δ's two-head architecture. The K-sweep
(commit db874b184) showed stateless single-snapshot alpha decays from
K=50 peak to gone by K=500. The two-head design exists to amplify
short-horizon evidence into long-horizon prediction via SSM state
accumulation; this smoke is the actual test of that hypothesis.

Gate per the implementation plan:
- AUC > 0.55 at K=6000 → multi-minute alpha confirmed, design validated
- AUC < 0.52                → decisive FAIL, design dead in current form
- 0.52 ≤ AUC ≤ 0.55         → marginal, tune or extend seq_len

New module `multi_horizon_labels.rs` generates labels at arbitrary K
with tie-drops + NaN guards (mirror of `purged_split::binary_direction_label`
semantics but bypassing Phase1aConfig's hardcoded K=100). 5 unit tests
covering: strict-ramp all-ones, constant-series all-tied, K-too-large
edge case, index alignment with mixed up/down/tied, non-finite drops.

New example `phase1d_long_horizon.rs` loads snapshot fxcache, generates
K=6000 labels, splits 80/20 with horizon-sized embargo (so train sequences
ending near boundary don't share forward-window prices with val), trains
Mamba2 (seq_len=32, hidden=64, state=16), reports val AUC.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-15 09:00:17 +02:00
parent ab6922a199
commit 4cf9499b58
3 changed files with 379 additions and 0 deletions

View File

@@ -0,0 +1,250 @@
//! Phase 1d.2 — Multi-minute label smoke (K=6000 snapshots ≈ 1-5 min forward).
//!
//! THE decisive gate for the two-head architecture. The K-sweep (Phase 1c,
//! commit `db874b184`) showed alpha at stateless single-snapshot resolution
//! decays from K=50 peak to gone by K=500. Mamba2's job is to amplify
//! short-horizon evidence into a long-horizon prediction via SSM state
//! accumulation across `seq_len` snapshots.
//!
//! This test: feed a trailing window of 32 snapshots into Mamba2; predict
//! the binary direction `sign(mid[t+6000] - mid[t])`. Mamba's state must
//! integrate the per-step microstructure signal into a multi-minute call.
//!
//! Gate (per implementation plan):
//! - AUC > 0.55 at K=6000 → multi-minute alpha confirmed; design works.
//! - AUC < 0.52 → DECISIVE FAIL. The two-head architecture cannot turn
//! short-window snapshot context into multi-minute prediction with
//! the current model; need different inputs, model class, or both.
//!
//! Why this is decisive: if we *can* predict long horizons from short
//! tick context plus sequence state, the existing 81-dim snapshot stack
//! is the right foundation and the rest of FoxhuntQ-Δ proceeds. If we
//! can't, the whole architecture is wrong and needs a redesign before
//! any production work.
use anyhow::{Context, Result};
use clap::Parser;
use cudarc::driver::CudaContext;
use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha8Rng;
use std::sync::Arc;
use tracing_subscriber::EnvFilter;
use ml_alpha::eval::{accuracy_from_logits, auc_from_logits};
use ml_alpha::fxcache_reader::{FxCacheReader, COL_RAW_CLOSE, FEAT_DIM};
use ml_alpha::mamba2_block::{
Mamba2AdamW, Mamba2AdamWConfig, Mamba2Block, Mamba2BlockConfig,
};
use ml_alpha::multi_horizon_labels::generate_labels;
use ml_core::cuda_autograd::gpu_tensor::GpuTensor;
#[derive(Parser, Debug)]
#[command(name = "phase1d_long_horizon", about = "FoxhuntQ-Δ Phase 1d.2 multi-minute Mamba smoke")]
struct Cli {
#[arg(long)] fxcache_path: String,
/// Label horizon in snapshots. K=6000 ≈ 1-5 min forward at typical rates.
#[arg(long, default_value_t = 6000)] horizon: usize,
/// Sequence length per training example (≤ kernel cap of 32).
#[arg(long, default_value_t = 32)] seq_len: usize,
#[arg(long, default_value_t = 64)] hidden_dim: usize,
#[arg(long, default_value_t = 16)] state_dim: usize,
#[arg(long, default_value_t = 5)] epochs: usize,
#[arg(long, default_value_t = 128)] batch_size: usize,
#[arg(long, default_value_t = 3e-3)] lr: f32,
/// 80/20 train/val split (purged via the embargo).
#[arg(long, default_value_t = 0.8)] train_frac: f32,
/// Embargo bars between train and val ranges (default = horizon for safety).
#[arg(long)] embargo: Option<usize>,
/// Subsample stride for train sequences (1 = use every starting position).
#[arg(long, default_value_t = 4)] train_stride: usize,
#[arg(long, default_value_t = 42)] seed: u64,
}
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")))
.init();
let cli = Cli::parse();
let ctx = CudaContext::new(0).context("init CUDA")?;
let stream = ctx.default_stream();
// ── Load fxcache + extract feature matrix and prices ──────────────
let reader = FxCacheReader::open(&cli.fxcache_path)?;
let alpha_dim = reader.alpha_feature_dim()
.ok_or_else(|| anyhow::anyhow!("fxcache lacks alpha column"))?;
let n_bars = reader.bar_count();
println!("fxcache: {} rows, alpha_dim={}, horizon K={}", n_bars, alpha_dim, cli.horizon);
// Flat feature matrix (CPU-resident) and prices.
let mut feature_matrix: Vec<f32> = Vec::with_capacity(n_bars * alpha_dim);
let mut prices: Vec<f32> = Vec::with_capacity(n_bars);
for i in 0..n_bars {
let row = reader.alpha_features(i).context("alpha row missing")?;
feature_matrix.extend_from_slice(row);
let rec = reader.record(i);
prices.push(rec.targets[COL_RAW_CLOSE - FEAT_DIM]);
}
// ── Generate K=6000 labels ────────────────────────────────────────
let labels = generate_labels(&prices, cli.horizon);
println!(
"labels: kept={} dropped_edge={} dropped_invalid={} up_frac={:.4}",
labels.labels.len(), labels.n_dropped_edge, labels.n_dropped_invalid,
labels.labels.iter().sum::<f32>() / labels.labels.len().max(1) as f32
);
if labels.labels.is_empty() {
anyhow::bail!("no valid labels at K={} — fxcache too small or all-tied", cli.horizon);
}
// ── Purged train/val split over labels.valid_indices ──────────────
// We split the VALID label list (not the raw bar list) at train_frac,
// then apply an embargo so train sequences ending near the boundary
// don't share forward-window prices with val.
let embargo = cli.embargo.unwrap_or(cli.horizon);
let n_valid = labels.valid_indices.len();
let n_train_target = (n_valid as f32 * cli.train_frac) as usize;
let train_split_bar = labels.valid_indices[n_train_target - 1];
let val_start_bar = train_split_bar + embargo;
let train_label_range: Vec<usize> = (0..n_valid)
.filter(|&p| labels.valid_indices[p] < train_split_bar.saturating_sub(embargo))
.collect();
let val_label_range: Vec<usize> = (0..n_valid)
.filter(|&p| labels.valid_indices[p] >= val_start_bar)
.collect();
println!(
"split: n_train_labels={} n_val_labels={} (embargo={} bars, val_start_bar={})",
train_label_range.len(), val_label_range.len(), embargo, val_start_bar
);
// Sequence start: bar `valid_indices[p] - seq_len + 1` to bar `valid_indices[p]`.
// Filter out any p where the sequence would underflow.
let make_seq_label_pos = |label_positions: &[usize], stride: usize| -> Vec<usize> {
label_positions.iter()
.filter(|&&p| labels.valid_indices[p] + 1 >= cli.seq_len)
.copied()
.step_by(stride)
.collect()
};
let train_pos = make_seq_label_pos(&train_label_range, cli.train_stride);
let val_pos = make_seq_label_pos(&val_label_range, 1);
println!("train sequences (stride={}): {} val sequences: {}",
cli.train_stride, train_pos.len(), val_pos.len());
// ── Build Mamba2 + AdamW ──────────────────────────────────────────
let block_cfg = Mamba2BlockConfig {
in_dim: alpha_dim,
hidden_dim: cli.hidden_dim,
state_dim: cli.state_dim,
seq_len: cli.seq_len,
};
let mut block = Mamba2Block::new(block_cfg.clone(), Arc::clone(&stream))?;
let mut opt = Mamba2AdamW::new(&block, Mamba2AdamWConfig {
lr: cli.lr, ..Default::default()
})?;
println!("Mamba2Block params: {}", block.param_count());
let gather = |sel: &[usize]| -> Result<(GpuTensor, Vec<f32>)> {
let b = sel.len();
let mut host = Vec::with_capacity(b * cli.seq_len * alpha_dim);
let mut ys = Vec::with_capacity(b);
for &p in sel {
let end_bar = labels.valid_indices[p];
let start_bar = end_bar + 1 - cli.seq_len;
for t in 0..cli.seq_len {
let off = (start_bar + t) * alpha_dim;
host.extend_from_slice(&feature_matrix[off..off + alpha_dim]);
}
ys.push(labels.labels[p]);
}
let dev = stream.clone_htod(&host)?;
let tensor = GpuTensor::new(dev, vec![b, cli.seq_len, alpha_dim])
.map_err(|e| anyhow::anyhow!("batch tensor: {e}"))?;
Ok((tensor, ys))
};
let bce_loss = |logits: &[f32], ys: &[f32]| -> f32 {
let mut s = 0.0_f32;
let eps = 1e-7_f32;
for (&z, &y) in logits.iter().zip(ys.iter()) {
let z = z.clamp(-50.0, 50.0);
let p = (1.0 / (1.0 + (-z).exp())).clamp(eps, 1.0 - eps);
s += -(y * p.ln() + (1.0 - y) * (1.0 - p).ln());
}
s / logits.len() as f32
};
// ── Training loop ─────────────────────────────────────────────────
let mut rng = ChaCha8Rng::seed_from_u64(cli.seed);
let train_len = train_pos.len();
if train_len < cli.batch_size {
anyhow::bail!("n_train ({train_len}) < batch_size ({})", cli.batch_size);
}
let batches_per_epoch = train_len / cli.batch_size;
for epoch in 0..cli.epochs {
let mut perm: Vec<usize> = (0..train_len).collect();
for i in (1..train_len).rev() {
let j = rng.gen_range(0..=i);
perm.swap(i, j);
}
let mut loss_sum = 0.0_f32;
for batch_idx in 0..batches_per_epoch {
let raw_sel = &perm[batch_idx * cli.batch_size .. (batch_idx + 1) * cli.batch_size];
let sel: Vec<usize> = raw_sel.iter().map(|&i| train_pos[i]).collect();
let (input, ys) = gather(&sel)?;
let (logit, cache) = block.forward_train(&input)?;
let logit_host = logit.to_host(&stream)?;
loss_sum += bce_loss(&logit_host, &ys);
let n_b = ys.len() as f32;
let d_logit_host: Vec<f32> = logit_host.iter().zip(ys.iter())
.map(|(&z, &y)| {
let p = 1.0 / (1.0 + (-z.clamp(-50.0, 50.0)).exp());
(p - y) / n_b
})
.collect();
let d_logit_dev = stream.clone_htod(&d_logit_host)?;
let d_logit = GpuTensor::new(d_logit_dev, vec![ys.len(), 1])?;
let grads = block.backward(&cache, &d_logit)?;
opt.step(&mut block, &grads)?;
}
println!("epoch {epoch:2} mean_train_bce={:.5} n_batches={batches_per_epoch}",
loss_sum / batches_per_epoch as f32);
}
// ── Validation ────────────────────────────────────────────────────
let mut val_logits: Vec<f32> = Vec::with_capacity(val_pos.len());
let mut val_ys: Vec<f32> = Vec::with_capacity(val_pos.len());
let mut i = 0;
while i < val_pos.len() {
let this = cli.batch_size.min(val_pos.len() - i);
let sel: Vec<usize> = val_pos[i..i + this].to_vec();
let (input, ys) = gather(&sel)?;
let logit = block.forward(&input)?;
let logit_host = logit.to_host(&stream)?;
val_logits.extend_from_slice(&logit_host);
val_ys.extend_from_slice(&ys);
i += this;
}
let labels_u8: Vec<u8> = val_ys.iter().map(|&y| if y > 0.5 { 1 } else { 0 }).collect();
let acc = accuracy_from_logits(&val_logits, &labels_u8);
let auc = auc_from_logits(&val_logits, &labels_u8);
println!();
println!("===========================================================");
println!("PHASE 1d.2 — MAMBA2 MULTI-MINUTE SMOKE (K={})", cli.horizon);
println!("===========================================================");
println!("val sequences: {}", val_logits.len());
println!("val accuracy: {:.4}", acc);
println!("val AUC: {:.4}", auc);
if auc > 0.55 {
println!("GATE PASS: AUC > 0.55 at K={}; multi-minute alpha confirmed.", cli.horizon);
println!(" Two-head FoxhuntQ-Δ architecture validated.");
} else if auc < 0.52 {
println!("GATE FAIL (decisive): AUC < 0.52; design dead in current form.");
println!(" Need different inputs / model class / context window.");
} else {
println!("GATE MARGINAL: 0.52 ≤ AUC ≤ 0.55; tune hyperparameters or extend seq_len.");
}
Ok(())
}

View File

@@ -53,6 +53,7 @@ pub mod eval;
pub mod metrics_detail;
pub mod calibration;
pub mod mamba2_block;
pub mod multi_horizon_labels;
pub use fxcache_reader::{FxCacheReader, FxCacheRecord, FxCacheMetadata};
pub use purged_split::{PurgedSplit, SplitIndices};

View File

@@ -0,0 +1,128 @@
//! Phase 1d.2 — Long-horizon label generation.
//!
//! Generates binary direction labels at arbitrary K, with proper handling
//! of (a) the last K positions (no forward window available → drop),
//! (b) tied prices (drop, mirror short-horizon convention from
//! `purged_split::binary_direction_label`), and (c) NaN / non-positive
//! price guards (drop).
//!
//! Phase 1c snapshot smoke generates labels at K=100 via
//! `purged_split::binary_direction_label`. For K=6000 (≈1-5 min forward
//! at typical snapshot rates) we need the same semantics but at much
//! larger K — and we explicitly want to bypass `prepare_phase1a_data`,
//! which is hardwired to `Phase1aConfig::horizon` and has Phase 1a
//! corruption-audit assumptions baked in.
/// Output of [`generate_labels`].
pub struct LongHorizonLabels {
/// Generated labels in {0.0, 1.0} for each *valid* bar.
pub labels: Vec<f32>,
/// Indices into the original price vector for each kept label.
/// `prices[valid_indices[i]]` is the anchor whose label is `labels[i]`.
pub valid_indices: Vec<usize>,
/// Bars dropped because they fall in the last K positions (no forward window).
pub n_dropped_edge: usize,
/// Bars dropped because `price[t+K] == price[t]` (microstructure tie) OR a
/// non-positive / non-finite price was encountered.
pub n_dropped_invalid: usize,
}
/// Generate `sign(price[t+K] - price[t])` labels over the input prices.
/// Returns labels and the corresponding source indices, filtering out
/// the right-edge bars, tied bars, and bars with invalid prices.
pub fn generate_labels(prices: &[f32], k: usize) -> LongHorizonLabels {
let n = prices.len();
if n <= k || k == 0 {
return LongHorizonLabels {
labels: Vec::new(),
valid_indices: Vec::new(),
n_dropped_edge: n,
n_dropped_invalid: 0,
};
}
let mut labels = Vec::with_capacity(n - k);
let mut valid = Vec::with_capacity(n - k);
let mut n_invalid = 0_usize;
for t in 0..n - k {
let p_t = prices[t];
let p_kt = prices[t + k];
if !p_t.is_finite() || !p_kt.is_finite() || p_t <= 0.0 || p_kt <= 0.0 {
n_invalid += 1;
continue;
}
if (p_kt - p_t).abs() < f32::EPSILON {
n_invalid += 1;
continue;
}
labels.push(if p_kt > p_t { 1.0 } else { 0.0 });
valid.push(t);
}
LongHorizonLabels {
labels,
valid_indices: valid,
n_dropped_edge: k,
n_dropped_invalid: n_invalid,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strict_upward_ramp_all_ones() {
let prices: Vec<f32> = (0..1000).map(|i| 100.0 + i as f32 * 0.01).collect();
let out = generate_labels(&prices, 100);
// 1000 - 100 = 900 valid bars; all upward.
assert_eq!(out.labels.len(), 900);
assert_eq!(out.valid_indices.len(), 900);
assert!(out.labels.iter().all(|&y| (y - 1.0).abs() < 1e-6));
assert_eq!(out.n_dropped_edge, 100);
assert_eq!(out.n_dropped_invalid, 0);
}
#[test]
fn test_constant_series_all_dropped_as_tied() {
let prices = vec![100.0_f32; 200];
let out = generate_labels(&prices, 50);
assert!(out.labels.is_empty());
assert_eq!(out.n_dropped_invalid, 150); // 200 - 50 tied → dropped
}
#[test]
fn test_k_larger_than_n_drops_all_as_edge() {
let prices = vec![100.0_f32; 50];
let out = generate_labels(&prices, 100);
assert!(out.labels.is_empty());
assert_eq!(out.n_dropped_edge, 50);
assert_eq!(out.n_dropped_invalid, 0);
}
#[test]
fn test_indices_align_with_labels() {
// Mixed up/down: t=0 up, t=1 up, t=2 tied, t=3 down.
// prices length must be > K. K=2, so t in [0, n-2).
let prices = vec![100.0, 101.0, 102.0, 102.0, 100.0, 99.0];
let out = generate_labels(&prices, 2);
// t=0: price[0]=100 → price[2]=102 → up
// t=1: price[1]=101 → price[3]=102 → up
// t=2: price[2]=102 → price[4]=100 → down
// t=3: price[3]=102 → price[5]=99 → down
assert_eq!(out.labels, vec![1.0, 1.0, 0.0, 0.0]);
assert_eq!(out.valid_indices, vec![0, 1, 2, 3]);
}
#[test]
fn test_non_finite_prices_dropped() {
let mut prices: Vec<f32> = (0..100).map(|i| 100.0 + i as f32 * 0.01).collect();
prices[10] = f32::NAN;
prices[50] = f32::INFINITY;
let out = generate_labels(&prices, 10);
// 100 - 10 = 90 potentially-valid bars; some dropped.
// Bars where p_t or p_kt is non-finite are dropped.
// t=10 has p_t = NaN → drop. Also any t where t+10=10 (t=0) drops.
// t=50 has p_t = +inf → drop. Also t=40 has p_kt = +inf → drop.
assert!(out.n_dropped_invalid >= 4);
assert!(out.labels.iter().all(|&y| y == 0.0 || y == 1.0));
}
}