diff --git a/crates/ml-alpha/examples/phase1d_long_horizon.rs b/crates/ml-alpha/examples/phase1d_long_horizon.rs new file mode 100644 index 000000000..285edfc4d --- /dev/null +++ b/crates/ml-alpha/examples/phase1d_long_horizon.rs @@ -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, + /// 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 = Vec::with_capacity(n_bars * alpha_dim); + let mut prices: Vec = 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::() / 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 = (0..n_valid) + .filter(|&p| labels.valid_indices[p] < train_split_bar.saturating_sub(embargo)) + .collect(); + let val_label_range: Vec = (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 { + 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)> { + 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 = (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 = 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 = 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 = Vec::with_capacity(val_pos.len()); + let mut val_ys: Vec = 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 = 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 = 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(()) +} diff --git a/crates/ml-alpha/src/lib.rs b/crates/ml-alpha/src/lib.rs index 15f0d7bd4..500489ab5 100644 --- a/crates/ml-alpha/src/lib.rs +++ b/crates/ml-alpha/src/lib.rs @@ -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}; diff --git a/crates/ml-alpha/src/multi_horizon_labels.rs b/crates/ml-alpha/src/multi_horizon_labels.rs new file mode 100644 index 000000000..b488a4e09 --- /dev/null +++ b/crates/ml-alpha/src/multi_horizon_labels.rs @@ -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, + /// 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, + /// 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 = (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 = (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)); + } +}