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>
273 lines
10 KiB
Rust
273 lines
10 KiB
Rust
//! 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]);
|
||
}
|
||
}
|