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:
250
crates/ml-alpha/examples/phase1d_long_horizon.rs
Normal file
250
crates/ml-alpha/examples/phase1d_long_horizon.rs
Normal 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(())
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
128
crates/ml-alpha/src/multi_horizon_labels.rs
Normal file
128
crates/ml-alpha/src/multi_horizon_labels.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user