Files
foxhunt/crates/ml-features/src/frac_diff_adapter.rs
jgrusewski db874b1841 feat(foxhuntq): Phase 1c snapshot-resolution alpha + leakage fix + variable-dim fxcache
Three things landing atomically because they're load-bearing for each other:

1. **Trend-scanning leakage fix** — trend_scanning.rs was emitting OLS slope+t-stat
   over a *forward* window [t, t+L]. With the Phase 1a label = sign(price[t+60]
   − price[t]), the forward feature window overlaps the label window, contaminating
   it. Purged walk-forward only sterilizes forward-looking *labels* that cross
   the train/val split, not forward-looking *features* that peek inside the same
   horizon the label measures. The leak inflated MLP accuracy from 0.49
   (legacy 74-dim baseline) to 0.75 — vanished to 0.50 after switching to a
   trailing window. Bounded the perfect-fit t-stat sentinel from ±1e6 → ±20
   (p<1e-30 is already meaningless); eliminated the 16k corruption-cap drops.

2. **Variable-dim alpha column** — fxcache schema now carries the alpha-feature
   width via metadata (`alpha_feature_dim`), not a compile-time constant. Same
   on-disk format hosts the 134-dim bar-level stack OR the 81-dim snapshot stack.
   Reader + auto-detect honor the metadata-declared dim; downstream MLP auto-sizes
   `in_dim`. Single schema, no forks.

3. **Snapshot pipeline (Phase 1c falsification)** — `snapshot_pipeline.rs`: 81-dim
   per-MBP10-snapshot extractor reusing 10 snapshot-native alpha blocks + 6 new
   snapshot-specific features (time-since-trade, time-since-snap, event-rate,
   spread-bps, L1-imbalance, microprice-mid drift). `precompute_features` gets
   `--row-unit snapshot` flag; emits one fxcache row per LOB update (1.97M rows
   from MBP-10 data vs 206K for bar mode).

**Smoke verdict on real data** (ES.FUT, 1.97M snapshots, 384K val):
- Bar-level honest alpha: accuracy=0.5005, AUC=0.5043 (no signal)
- **Snapshot-level alpha**: accuracy=0.5241, AUC=0.6849 (real signal, 384K val)
- GBM corroboration: accuracy=0.5401 (non-linear partitioning sees more)
- Horizon decay: alpha peaks at K=20-50 snapshots (~5-25ms), gone by K=500
- Regime-conditional: spread-Q4 quintile hits 0.752 accuracy on 76k samples

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 01:01:15 +02:00

222 lines
8.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! V10 Block M — Fractional-differentiation f64 adapter for the v10 feature
//! pipeline.
//!
//! Thin streaming wrapper around `ml_labeling::fractional_diff::FractionalCoeffs`
//! providing an **f64 in / f64 out** interface. The existing
//! `ml_labeling::fractional_diff::StreamingDifferentiator` uses an i64
//! fixed-point I/O (designed for the label pipeline's `i64` storage with
//! ×10_000 scaling) which is wrong for our f64-resident close prices and
//! introduces precision loss. This module reuses the **shared binomial
//! coefficient calculator** (`FractionalCoeffs`) but maintains the rolling
//! window directly in f64 — no shortcuts.
//!
//! ## Motivation (Lopez de Prado *AdvFinML* Ch. 5)
//!
//! Standard differencing (`Δp = p_t p_{t-1}`) achieves stationarity by
//! removing ALL memory. Fractional differencing applies a non-integer `d` such
//! that the series becomes stationary while preserving long-memory structure.
//! For asset prices, typical `d` values:
//! - `d = 0.3` — light differencing, retains most autocorrelation
//! - `d = 0.5` — balanced (the most-cited value in the literature)
//! - `d = 0.7` — heavy differencing, near-fully-differenced
//!
//! The v10 fxcache pipeline emits all three as separate features (Block M, 3
//! dims): the model picks which trade-off works best at our bar resolution.
use std::collections::VecDeque;
use ml_labeling::fractional_diff::FractionalCoeffs;
/// Streaming fractional differentiator with **native f64 I/O**.
///
/// Holds a rolling window of the most recent `max_lags` raw values and the
/// shared `FractionalCoeffs` (binomial-coefficient series derived from `d` and
/// `threshold`). Each `process(x)` call appends `x` to the window, evicts the
/// oldest if over capacity, then computes
///
/// ```text
/// y_t = Σ_{i=0}^{N-1} c_i · x_{ti}
/// ```
///
/// where `c_i` are the binomial coefficients and `N = min(coeff.len(),
/// window.len())`.
///
/// Returns `f64::NAN` until the window has reached `min_window` samples (the
/// warmup gate prevents reporting under-determined values that can later
/// poison rolling-window normalization downstream).
#[derive(Debug, Clone)]
pub struct FracDiffF64 {
coeffs: FractionalCoeffs,
window: VecDeque<f64>,
max_lags: usize,
min_window: usize,
}
impl FracDiffF64 {
/// Construct with explicit knobs.
///
/// * `diff_order` — the fractional differencing power `d` (commonly 0.3-0.7
/// for price series).
/// * `max_lags` — maximum window size; also caps the number of binomial
/// coefficients computed (early-stop when `|c_i| < threshold`).
/// * `threshold` — coefficient magnitude below which we stop generating
/// coefficients. `1e-4` is the Lopez de Prado default.
/// * `min_window` — minimum samples before `process` returns a non-NaN
/// value. Pass `0` to default to `max_lags / 2`.
pub fn new(diff_order: f64, max_lags: usize, threshold: f64, min_window: usize) -> Self {
let coeffs = FractionalCoeffs::new(diff_order, max_lags, threshold);
let effective_min = if min_window == 0 {
max_lags / 2
} else {
min_window
};
Self {
coeffs,
window: VecDeque::with_capacity(max_lags),
max_lags,
min_window: effective_min,
}
}
/// Conventional defaults: `max_lags=100`, `threshold=1e-4`, `min_window=50`.
/// Caller picks `d` (0.3 / 0.5 / 0.7).
pub fn with_defaults(diff_order: f64) -> Self {
Self::new(diff_order, 100, 1e-4, 50)
}
/// Process one new value. Returns the fractionally-differenced output, or
/// `f64::NAN` if fewer than `min_window` samples have been observed.
pub fn process(&mut self, value: f64) -> f64 {
self.window.push_back(value);
if self.window.len() > self.max_lags {
self.window.pop_front();
}
if self.window.len() < self.min_window {
return f64::NAN;
}
let n = self.coeffs.len().min(self.window.len());
let mut diff_value = 0.0_f64;
for i in 0..n {
let window_idx = self.window.len() - 1 - i;
diff_value += self.coeffs.get(i) * self.window[window_idx];
}
diff_value
}
/// Clear the rolling window. The pre-computed coefficients are retained.
pub fn reset(&mut self) {
self.window.clear();
}
/// Current window occupancy.
pub fn window_size(&self) -> usize {
self.window.len()
}
/// `true` when `process` will return non-NaN values.
pub fn is_ready(&self) -> bool {
self.window.len() >= self.min_window
}
/// Number of active binomial coefficients (post early-stop truncation).
pub fn coeff_count(&self) -> usize {
self.coeffs.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_frac_diff_returns_nan_until_warmup() {
let mut fd = FracDiffF64::new(0.5, 100, 1e-4, 50);
for i in 0..49 {
let v = fd.process((i as f64) + 100.0);
assert!(v.is_nan(), "expected NaN at sample {i} (pre-warmup), got {v}");
}
let v = fd.process(149.0);
assert!(v.is_finite(), "expected finite at sample 50 (post-warmup), got {v}");
}
#[test]
fn test_frac_diff_zero_order_is_identity() {
// d=0 → c_0 = 1.0, all other coefficients = 0 (or below threshold).
// process(x) should converge to the latest x once warmup completes.
let mut fd = FracDiffF64::new(0.0, 100, 1e-4, 5);
let mut last = 0.0;
for i in 0..60 {
last = fd.process((i as f64) + 100.0);
}
// i=59 (0-indexed) → 159
assert!(
(last - 159.0).abs() < 1e-9,
"d=0 must act as identity, got {last}"
);
}
#[test]
fn test_frac_diff_d1_approximates_first_difference() {
// d=1 binomial coefficients: c_0=1, c_1=-1, c_2=0, c_3=0, ...
// Applied to a linear ramp x_t = t, first difference = 1.
let mut fd = FracDiffF64::new(1.0, 100, 1e-4, 5);
let mut latest_diff = f64::NAN;
for i in 0..30 {
let v = fd.process(i as f64);
if v.is_finite() {
latest_diff = v;
}
}
// Steady-state first-difference of linear ramp = 1.0
assert!(
(latest_diff - 1.0).abs() < 1e-6,
"d=1.0 on linear ramp must give first-difference ≈ 1.0, got {latest_diff}"
);
}
#[test]
fn test_frac_diff_d05_produces_finite_bounded_outputs_on_log_price() {
// Realistic input: log price around log(110) ≈ 4.7 with mild oscillation.
// Expected: d=0.5 differencing yields a near-zero-mean stationary
// series with bounded magnitude (definitely no NaN/Inf, |v| << input).
let mut fd = FracDiffF64::new(0.5, 100, 1e-4, 50);
let mut max_abs = 0.0_f64;
let mut n_finite = 0usize;
for i in 0..200 {
let log_p = ((i as f64) * 0.01).sin() + 4.7;
let v = fd.process(log_p);
if v.is_finite() {
max_abs = max_abs.max(v.abs());
n_finite += 1;
}
}
assert!(
n_finite > 100,
"expected >100 finite samples post-warmup, got {n_finite}"
);
assert!(
max_abs < 100.0,
"d=0.5 outputs should be bounded; max|v|={max_abs} exceeds sanity check"
);
}
#[test]
fn test_frac_diff_reset_clears_window_preserves_coeffs() {
let mut fd = FracDiffF64::new(0.5, 100, 1e-4, 50);
for i in 0..60 {
fd.process(i as f64);
}
let coeffs_before = fd.coeff_count();
assert!(fd.is_ready());
fd.reset();
assert_eq!(fd.window_size(), 0, "reset should clear the window");
assert!(!fd.is_ready(), "post-reset should require re-warmup");
assert_eq!(
fd.coeff_count(),
coeffs_before,
"reset must NOT recompute coefficients"
);
}
}