Files
foxhunt/crates/ml-features/src/bouchaud_features.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

273 lines
10 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 BB — Bouchaud-line trade-flow features (light-weight proxies).
//!
//! Two scalars from the trade tape that capture self-excitation / persistence
//! without requiring full Hawkes MLE:
//!
//! ## 1. Trade-sign autocorrelation at lag 1 (Lillo-Mike-Farmer 2005)
//!
//! `ρ(1) = corr(q_t, q_{t-1})` where `q_t ∈ {-1, +1}` is the sign of trade t
//! (buyer-initiated = +1, seller-initiated = -1). Empirically `ρ(1)` runs
//! ~0.3-0.6 in equity markets: trade-sign sequences are far from i.i.d.,
//! exhibiting strong short-term persistence. A bar where `ρ(1)` is unusually
//! high (vs the trailing average) indicates **directional momentum regime**;
//! near-zero values indicate **mean-reverting / dispersed flow**.
//!
//! This is the *fast* version of the Bouchaud γ exponent — a single scalar
//! at one lag rather than a fitted power-law decay.
//!
//! ## 2. Branching ratio proxy via Fano factor (Filimonov-Sornette 2012)
//!
//! For trade arrival times, compute the **Fano factor** `F(T) = Var(N(T)) /
//! E(N(T))` over windows of size T (where N(T) = count of trades in T).
//!
//! - Poisson independent arrivals: `F(T) = 1`
//! - Self-exciting Hawkes: `F(T) > 1`, growing with T
//! - In the limit, `F(T) → 1 / (1 n)²` where `n` is the branching ratio
//! (Filimonov-Sornette 2012, *PNAS*)
//!
//! We emit `branching_ratio_proxy = F(T) 1`, which is:
//! - 0 for independent arrivals
//! - Strictly positive and growing for self-exciting regimes
//! - Bounded for finite-window estimates
//!
//! This is the *fast* version of the Hawkes branching ratio n (full MLE in
//! Block R provides the actual `n = α/β` estimate).
//!
//! ## References
//!
//! - Lillo, Mike & Farmer (2005), "Theory for long memory in supply and demand"
//! - Filimonov & Sornette (2012), "Quantifying reflexivity in financial markets",
//! *Phys. Rev. E* (also PNAS 2015)
//! - Bouchaud, Bonart, Donier & Gould (2018), *Trades, Quotes and Prices*, Ch. 10
use std::collections::VecDeque;
/// V10 Block BB — streaming Bouchaud trade-flow proxies.
///
/// Maintains:
/// - A rolling buffer of recent trade signs (for autocorrelation)
/// - A rolling buffer of recent trade timestamps (for Fano factor)
#[derive(Debug, Clone)]
pub struct BouchaudFeatures {
/// Window for autocorrelation estimation (trade signs).
sign_window: usize,
/// Window for Fano factor estimation (in trades).
fano_window: usize,
/// Fano factor sub-window size T (typically 10-50 trades).
fano_sub_window: usize,
signs: VecDeque<f64>,
arrival_ts_ns: VecDeque<u64>,
}
impl BouchaudFeatures {
pub fn new(sign_window: usize, fano_window: usize, fano_sub_window: usize) -> Self {
Self {
sign_window,
fano_window,
fano_sub_window: fano_sub_window.max(2),
signs: VecDeque::with_capacity(sign_window),
arrival_ts_ns: VecDeque::with_capacity(fano_window),
}
}
/// Defaults: 200-trade autocorr window, 500-trade Fano buffer, 20-trade
/// sub-windows. Calibrated for HFT futures (high trade rate).
pub fn with_defaults() -> Self {
Self::new(200, 500, 20)
}
/// Update with a new trade event.
pub fn update(&mut self, is_buy: bool, timestamp_ns: u64) {
let sign: f64 = if is_buy { 1.0 } else { -1.0 };
self.signs.push_back(sign);
if self.signs.len() > self.sign_window {
self.signs.pop_front();
}
self.arrival_ts_ns.push_back(timestamp_ns);
if self.arrival_ts_ns.len() > self.fano_window {
self.arrival_ts_ns.pop_front();
}
}
/// Returns `[trade_sign_autocorr_lag1, branching_ratio_proxy]`.
/// Both default to 0 when insufficient data.
pub fn features(&self) -> [f64; 2] {
[self.trade_sign_autocorr_lag1(), self.branching_ratio_proxy()]
}
/// Lag-1 autocorrelation of trade signs in the rolling window.
/// Returns 0 for fewer than 10 observations.
pub fn trade_sign_autocorr_lag1(&self) -> f64 {
if self.signs.len() < 10 {
return 0.0;
}
let n = self.signs.len() as f64;
let mean: f64 = self.signs.iter().sum::<f64>() / n;
let mut numer = 0.0_f64;
let mut denom = 0.0_f64;
for i in 1..self.signs.len() {
let s_t = self.signs[i] - mean;
let s_tm1 = self.signs[i - 1] - mean;
numer += s_t * s_tm1;
denom += s_t * s_t;
}
if denom < 1e-12 {
return 0.0;
}
numer / denom
}
/// Fano-factor-based branching-ratio proxy: `F(T) 1` where `T` is the
/// sub-window size in trades. Bins the arrivals into non-overlapping
/// sub-windows of `fano_sub_window` trades, computes `Var(N) / E(N)`.
///
/// Returns 0 if there are fewer than `3 × fano_sub_window` trades observed.
pub fn branching_ratio_proxy(&self) -> f64 {
let n_arrivals = self.arrival_ts_ns.len();
if n_arrivals < 3 * self.fano_sub_window {
return 0.0;
}
// Determine total observation window (last ts first ts in nanoseconds)
// We partition this duration into K equal sub-windows and count arrivals
// per sub-window. K = floor(n_arrivals / fano_sub_window).
let first_ts = *self.arrival_ts_ns.front().expect("non-empty");
let last_ts = *self.arrival_ts_ns.back().expect("non-empty");
if last_ts <= first_ts {
return 0.0;
}
let total_span_ns = last_ts - first_ts;
let n_sub_windows = (n_arrivals / self.fano_sub_window).max(2);
let sub_window_ns = total_span_ns / n_sub_windows as u64;
if sub_window_ns == 0 {
return 0.0;
}
// Count arrivals per sub-window
let mut counts: Vec<u32> = vec![0; n_sub_windows];
for &ts in self.arrival_ts_ns.iter() {
let bucket = ((ts - first_ts) / sub_window_ns) as usize;
let bucket = bucket.min(n_sub_windows - 1);
counts[bucket] += 1;
}
// Compute mean + variance of count distribution
let k = counts.len() as f64;
let mean: f64 = counts.iter().map(|&c| c as f64).sum::<f64>() / k;
if mean < 1e-9 {
return 0.0;
}
let var: f64 =
counts.iter().map(|&c| (c as f64 - mean).powi(2)).sum::<f64>() / k;
// Fano 1 ≈ 0 for Poisson; > 0 for self-exciting
(var / mean - 1.0).max(-1.0).min(50.0) // sanity-bound for numerical stability
}
pub fn reset(&mut self) {
self.signs.clear();
self.arrival_ts_ns.clear();
}
}
impl Default for BouchaudFeatures {
fn default() -> Self {
Self::with_defaults()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cold_start_features_zero() {
let bf = BouchaudFeatures::with_defaults();
assert_eq!(bf.features(), [0.0, 0.0]);
}
#[test]
fn test_autocorr_lag1_positive_for_persistent_signs() {
// All same sign → perfect lag-1 autocorrelation
let mut bf = BouchaudFeatures::new(100, 500, 20);
for i in 0..50 {
bf.update(true, (i as u64) * 1_000_000);
}
let [ac, _] = bf.features();
// Variance is zero (all +1) → numer/denom = 0/0 guarded → 0
// To get nonzero ac we need variance. Let me check the implementation.
// With all +1: mean=1, deviations all = 0, denom = 0 → returns 0.
assert_eq!(ac, 0.0, "all-same signs have zero variance → ac=0 by guard");
}
#[test]
fn test_autocorr_lag1_positive_for_persistent_with_variance() {
// 10× +1 followed by 10× -1 followed by 10× +1, etc. → high persistence
let mut bf = BouchaudFeatures::new(200, 500, 20);
let mut t = 0_u64;
for outer in 0..10 {
for _ in 0..10 {
bf.update(outer % 2 == 0, t);
t += 1_000_000;
}
}
let [ac, _] = bf.features();
// Same-sign runs → lag-1 ac should be near 1 except at run boundaries
// 100 trades, 10 sign changes → 90/99 = ~0.91 ac
assert!(ac > 0.5, "persistent runs → high lag-1 ac, got {ac}");
}
#[test]
fn test_autocorr_lag1_negative_for_alternating_signs() {
// +1, -1, +1, -1, ... → lag-1 ac → -1
let mut bf = BouchaudFeatures::new(200, 500, 20);
for i in 0..100 {
bf.update(i % 2 == 0, (i as u64) * 1_000_000);
}
let [ac, _] = bf.features();
assert!(ac < -0.9, "alternating signs → ac ≈ -1, got {ac}");
}
#[test]
fn test_branching_ratio_proxy_zero_for_regular_arrivals() {
// Perfectly regular arrivals: each trade exactly Δt apart → variance
// of count per sub-window is 0 → Fano = 0 → proxy = -1
let mut bf = BouchaudFeatures::new(200, 500, 20);
for i in 0..100 {
bf.update(i % 2 == 0, (i as u64) * 1_000_000);
}
let [_, br] = bf.features();
// Regular arrivals: each sub-window of 20 trades takes exactly 20Δt
// → variance ~0 → proxy ~ -1 (less than Poisson)
assert!(br < 0.0, "regular arrivals are sub-Poisson, got {br}");
}
#[test]
fn test_branching_ratio_proxy_positive_for_clustered_arrivals() {
// Heavily clustered: 80 trades in first 10% of time, 20 in remaining 90%
let mut bf = BouchaudFeatures::new(200, 500, 20);
// Cluster 1: 80 trades quickly
for i in 0..80 {
bf.update(i % 2 == 0, (i as u64) * 1_000_000);
}
// Sparse: 20 trades spread out
for i in 0..20 {
let ts = 80_000_000 + (i as u64) * 100_000_000;
bf.update(i % 2 == 0, ts);
}
let [_, br] = bf.features();
assert!(br > 0.0, "clustered arrivals are super-Poisson, got {br}");
}
#[test]
fn test_reset_clears_features() {
let mut bf = BouchaudFeatures::with_defaults();
for i in 0..50 {
bf.update(i % 2 == 0, (i as u64) * 1_000_000);
}
assert_ne!(bf.features()[0], 0.0);
bf.reset();
assert_eq!(bf.features(), [0.0, 0.0]);
}
}