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>
222 lines
8.0 KiB
Rust
222 lines
8.0 KiB
Rust
//! 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_{t−i}
|
||
/// ```
|
||
///
|
||
/// 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"
|
||
);
|
||
}
|
||
}
|