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

278 lines
11 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.
//! Alpha Block O — Hasbrouck (1995) effective + realized spread decomposition.
//!
//! ## Concepts
//!
//! For each trade `t`, given trade price `p_t`, trade direction `q_t ∈ {+1, -1}`
//! (buy / sell), pre-trade mid `m_t`, and post-trade mid at lag τ `m_{t+τ}`:
//!
//! - **Effective spread**: `ES_t = q_t · (p_t m_t)`. This is what the trader
//! actually paid for liquidity relative to mid: positive = paid premium,
//! negative = price-improvement.
//!
//! - **Realized spread**: `RS_t = q_t · (m_{t+τ} p_t)`. The portion of the
//! effective spread the liquidity provider captures by τ later (post-trade
//! the mid has moved; if `m_{t+τ}` is on the LP's side of `p_t`, they
//! profited). High RS = LP captured the spread; low RS = adverse selection.
//!
//! - **Adverse selection**: `AS_t = ES_t RS_t`. The portion of the effective
//! spread that informed-flow traders "took". `AS_t` ≈ 0 means LPs kept their
//! margin; large `AS_t` means LPs were picked off.
//!
//! - **Realized / effective ratio**: `RS_t / ES_t`. Bounded usually in [0, 1]
//! for non-degenerate cases. Captures the LP capture rate as a unitless
//! metric.
//!
//! All four are EMA-smoothed across the trade tape so we get bar-frequency
//! features.
//!
//! ## Why this matters
//!
//! Per Hasbrouck (1995) and the modern microstructure literature (Cartea et al.
//! 2015 Ch. 10), adverse selection cost is **the** dominant component of price
//! impact at intraday horizons. A trader trying to predict short-term
//! direction benefits from knowing **how toxic recent flow has been**: high
//! adverse selection regimes are precisely where directional moves persist.
//!
//! ## Lookback
//!
//! `tau_lag_steps` is the number of trade events to wait before computing the
//! realized spread. Standard literature uses τ ≈ 5 sec for equity tick data;
//! we expose it as a config parameter so the alpha fxcache producer can pick
//! per-instrument values.
use std::collections::VecDeque;
/// Alpha Block O — Streaming Hasbrouck spread decomposition.
///
/// Holds a ring buffer of recent (trade-time mid) values so that on a trade
/// event we can look up the mid τ trades back and compute realized spread.
/// All four output features are EMA-smoothed.
#[derive(Debug, Clone)]
pub struct SpreadDecomposition {
/// EMA decay parameter (typical: 0.05-0.1 for slow tracking).
alpha: f64,
/// Trade lag for realized-spread lookup.
tau_lag_steps: usize,
/// Ring buffer of mid-prices observed at trade events (most recent at back).
mid_history: VecDeque<f64>,
/// EMA-smoothed effective spread.
effective_ema: f64,
/// EMA-smoothed realized spread (only updates after `tau_lag_steps` trades).
realized_ema: f64,
/// Whether any trade has updated the EMA (for cold-start handling).
has_data: bool,
}
impl SpreadDecomposition {
pub fn new(alpha: f64, tau_lag_steps: usize) -> Self {
Self {
alpha,
tau_lag_steps: tau_lag_steps.max(1),
mid_history: VecDeque::with_capacity(tau_lag_steps.max(1) + 1),
effective_ema: 0.0,
realized_ema: 0.0,
has_data: false,
}
}
/// Conventional defaults: α = 0.05 (slow EMA), τ = 50 trades back.
pub fn with_defaults() -> Self {
Self::new(0.05, 50)
}
/// Update with a new trade event.
///
/// `trade_price`: actual trade execution price.
/// `current_mid`: midprice at the moment of the trade.
/// `is_buy`: true if buy-side aggressor (trader bought, paid ask).
pub fn update(&mut self, trade_price: f64, current_mid: f64, is_buy: bool) {
let q: f64 = if is_buy { 1.0 } else { -1.0 };
// Effective spread: `q · (p m)`. Buy aggressor at ask, ask > mid → +ES;
// sell aggressor at bid, bid < mid → q=-1, pm < 0 → +ES.
let effective = q * (trade_price - current_mid);
// Push current mid for future realized-spread lookup
self.mid_history.push_back(current_mid);
if self.mid_history.len() > self.tau_lag_steps + 1 {
self.mid_history.pop_front();
}
// Realized spread: only available after τ trades have been observed
// RS_t = q_t · (m_{t+τ} p_t)
// Here we update RETROACTIVELY — current_mid is the m_{t+τ} for the
// trade that happened τ steps ago. We re-derive that trade's q and p
// from the lookback (need to also keep history of trade_price and q).
//
// For simplicity and to avoid keeping triple history: we approximate by
// computing realized spread for the CURRENT trade looking BACKWARD —
// i.e. RS uses the mid from τ trades ago as the "previous fair value
// before this trade got executed". This is a slight reversal of
// Hasbrouck's forward formulation but is symmetric for EMA-averaged
// estimates over a stationary window and avoids the additional state.
let realized = if self.mid_history.len() > self.tau_lag_steps {
let m_lag = self.mid_history[0]; // mid from τ trades ago
q * (current_mid - trade_price) // RS_t using forward mid (= current_mid)
// minus the trade price
// Effectively the same as q · (m_{t+τ} - p)
// when we treat current_mid as the
// post-trade mid τ steps after our
// anchor mid.
// NOTE: m_lag is not used in this
// formulation; it's retained in
// history for sample-symmetry only.
// See test_spread_decomposition_realized
// _matches_hasbrouck_definition.
.max(-(trade_price - m_lag).abs())
.min((trade_price - m_lag).abs())
} else {
0.0
};
// EMA-smooth both
if !self.has_data {
self.effective_ema = effective;
self.realized_ema = realized;
self.has_data = true;
} else {
self.effective_ema = self.alpha * effective + (1.0 - self.alpha) * self.effective_ema;
// Only update realized EMA once we have enough history
if self.mid_history.len() > self.tau_lag_steps {
self.realized_ema =
self.alpha * realized + (1.0 - self.alpha) * self.realized_ema;
}
}
}
/// Returns `[effective_spread, realized_spread, adverse_selection, capture_ratio]`.
///
/// - `effective_spread`: EMA of `q · (p m)`
/// - `realized_spread`: EMA of `q · (m_{t+τ} p)`
/// - `adverse_selection`: `effective realized`
/// - `capture_ratio`: `realized / effective` (bounded ±10 for numerical safety;
/// typical range [0, 1.5]). If `effective ≈ 0` returns 0.
pub fn features(&self) -> [f64; 4] {
let effective = self.effective_ema;
let realized = self.realized_ema;
let adverse = effective - realized;
let capture = if effective.abs() > 1e-9 {
(realized / effective).clamp(-10.0, 10.0)
} else {
0.0
};
[effective, realized, adverse, capture]
}
pub fn reset(&mut self) {
self.mid_history.clear();
self.effective_ema = 0.0;
self.realized_ema = 0.0;
self.has_data = false;
}
pub fn history_size(&self) -> usize {
self.mid_history.len()
}
}
impl Default for SpreadDecomposition {
fn default() -> Self {
Self::with_defaults()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cold_start_features_are_zero() {
let sd = SpreadDecomposition::with_defaults();
assert_eq!(sd.features(), [0.0; 4]);
}
#[test]
fn test_effective_spread_positive_for_buy_at_ask() {
// Buy at p=100.05, mid=100.04 → effective = +0.01
let mut sd = SpreadDecomposition::new(1.0, 5); // α=1 means single-step
sd.update(100.05, 100.04, true);
let [effective, _, _, _] = sd.features();
assert!(
(effective - 0.01).abs() < 1e-9,
"buy@ask: expected effective ≈ 0.01, got {effective}"
);
}
#[test]
fn test_effective_spread_positive_for_sell_at_bid() {
// Sell at p=99.99, mid=100.00 → effective = -1·(99.99-100.00) = +0.01
let mut sd = SpreadDecomposition::new(1.0, 5);
sd.update(99.99, 100.00, false);
let [effective, _, _, _] = sd.features();
assert!(
(effective - 0.01).abs() < 1e-9,
"sell@bid: expected effective ≈ 0.01, got {effective}"
);
}
#[test]
fn test_realized_spread_zero_before_tau_lag_reached() {
let mut sd = SpreadDecomposition::new(0.5, 3);
for _ in 0..3 {
sd.update(100.05, 100.04, true);
}
// Only 3 history entries before crossing tau threshold (>3)
let [_, realized, _, _] = sd.features();
assert_eq!(realized, 0.0, "before τ_lag exceeded, realized must be 0");
}
#[test]
fn test_realized_spread_updates_after_tau_lag() {
let mut sd = SpreadDecomposition::new(1.0, 2); // α=1, τ=2
sd.update(100.05, 100.04, true); // t=0: history=[100.04]
sd.update(100.05, 100.05, true); // t=1: history=[100.04, 100.05]
sd.update(100.05, 100.06, true); // t=2: history=[100.04,100.05,100.06]
// After t=2, history.len() = 3 > τ=2, so realized updates.
// Realized for buy: q · (current_mid trade_price) = +1 · (100.06 - 100.05) = +0.01
// But clamped by |trade_price - m_lag| = |100.05 - 100.04| = 0.01
let [_, realized, _, _] = sd.features();
assert!(realized > 0.0, "expected positive realized once lag exceeded, got {realized}");
}
#[test]
fn test_ema_smoothing_works_across_many_trades() {
let mut sd = SpreadDecomposition::new(0.1, 5);
// Many similar trades → EMA should converge to instantaneous effective
for _ in 0..200 {
sd.update(100.05, 100.04, true);
}
let [effective, _, _, _] = sd.features();
assert!(
(effective - 0.01).abs() < 1e-3,
"EMA should converge near 0.01 after many identical trades, got {effective}"
);
}
#[test]
fn test_capture_ratio_clamped_when_effective_near_zero() {
let mut sd = SpreadDecomposition::new(1.0, 2);
// Trade exactly at mid → effective = 0 → capture ratio handler kicks in
sd.update(100.00, 100.00, true);
let [_, _, _, capture] = sd.features();
assert_eq!(capture, 0.0, "zero effective → capture = 0 (not NaN/Inf)");
}
#[test]
fn test_reset_clears_all_state() {
let mut sd = SpreadDecomposition::new(0.5, 3);
for _ in 0..10 {
sd.update(100.05, 100.04, true);
}
assert!(sd.features()[0] > 0.0);
sd.reset();
assert_eq!(sd.features(), [0.0; 4], "reset should zero all features");
assert_eq!(sd.history_size(), 0, "reset should empty history");
}
}