Files
foxhunt/crates/ml/tests/fxcache_roundtrip_test.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
7.4 KiB
Rust

//! Roundtrip tests for the `.fxcache` binary format.
//!
//! Covers f32 serialization, cache-key lookup, header validation,
//! and the empty-bars error path.
use ml::fxcache::{find_fxcache, load_fxcache, write_fxcache, FxCacheData, FXCACHE_VERSION, OFI_DIM};
use tempfile::TempDir;
// ── Helpers ─────────────────────────────────────────────────────────────────
/// Deterministic feature vector for bar `i`.
fn make_features(n: usize) -> Vec<[f64; 42]> {
(0..n)
.map(|i| {
let mut row = [0.0_f64; 42];
for j in 0..42 {
row[j] = (i * 42 + j) as f64 * 0.01;
}
row
})
.collect()
}
/// Deterministic target vector for bar `i`.
fn make_targets(n: usize) -> Vec<[f64; 6]> {
(0..n)
.map(|i| {
let mut row = [0.0_f64; 6];
for j in 0..6 {
row[j] = 5000.0 + (i * 6 + j) as f64 * 0.25;
}
row
})
.collect()
}
/// Deterministic OFI vector for bar `i`.
fn make_ofi(n: usize) -> Vec<[f64; OFI_DIM]> {
(0..n)
.map(|i| {
let mut row = [0.0_f64; OFI_DIM];
for j in 0..OFI_DIM {
row[j] = (i * OFI_DIM + j) as f64 * 0.001;
}
row
})
.collect()
}
/// Deterministic per-bar timestamps (1-minute intervals starting 2022-01-01).
fn make_timestamps(n: usize) -> Vec<i64> {
(0..n)
.map(|i| 1640995200_000_000_000i64 + i as i64 * 60_000_000_000)
.collect()
}
/// A fixed 32-byte cache key for tests.
fn test_cache_key() -> [u8; 32] {
let mut key = [0u8; 32];
for (i, slot) in key.iter_mut().enumerate() {
*slot = (i as u8).wrapping_mul(7).wrapping_add(13);
}
key
}
// ── Tests ───────────────────────────────────────────────────────────────────
/// Write 100 bars as f32 (version 1), read back, verify within f32 tolerance.
#[test]
fn test_fxcache_f32_roundtrip() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test_f32.fxcache");
let n = 100;
let features = make_features(n);
let targets = make_targets(n);
let ofi = make_ofi(n);
let timestamps = make_timestamps(n);
let key = test_cache_key();
let bytes_written =
write_fxcache(&path, &features, &targets, &ofi, &timestamps, key, false, None).unwrap();
assert!(bytes_written > 0, "expected nonzero bytes written");
let data: FxCacheData = load_fxcache(&path).unwrap();
assert_eq!(data.bar_count, n);
assert_eq!(data.cache_key, key);
assert_eq!(data.features.len(), n);
assert_eq!(data.targets.len(), n);
assert_eq!(data.ofi.len(), n);
assert_eq!(data.timestamps.len(), n);
// Timestamps are always i64 — bit-exact.
for i in 0..n {
assert_eq!(
data.timestamps[i], timestamps[i],
"timestamp mismatch at bar {i}"
);
}
// f32 roundtrip tolerance checks (~7 significant digits).
for i in 0..n {
for j in 0..42 {
let diff = (data.features[i][j] - features[i][j]).abs();
assert!(
diff < 1e-4,
"feature[{i}][{j}]: expected {}, got {}, diff={diff}",
features[i][j],
data.features[i][j]
);
}
for j in 0..6 {
let diff = (data.targets[i][j] - targets[i][j]).abs();
// f32 at ~5000 has resolution of ~0.0005, allow generous margin.
assert!(
diff < 0.01,
"target[{i}][{j}]: expected {}, got {}, diff={diff}",
targets[i][j],
data.targets[i][j]
);
}
for j in 0..OFI_DIM {
let diff = (data.ofi[i][j] - ofi[i][j]).abs();
assert!(
diff < 1e-4,
"ofi[{i}][{j}]: expected {}, got {}, diff={diff}",
ofi[i][j],
data.ofi[i][j]
);
}
}
}
/// Write a file with a known cache key, verify `find_fxcache` locates it.
/// Also verify that a different key returns `None`.
#[test]
fn test_fxcache_find() {
let dir = TempDir::new().unwrap();
let key = test_cache_key();
let hex_key = hex::encode(key);
let path = dir.path().join(format!("{hex_key}.fxcache"));
let features = make_features(5);
let targets = make_targets(5);
let ofi = make_ofi(5);
let timestamps = make_timestamps(5);
write_fxcache(&path, &features, &targets, &ofi, &timestamps, key, false, None).unwrap();
// Should find the file.
let found = find_fxcache(dir.path(), &key);
assert!(found.is_some(), "expected to find fxcache file");
assert_eq!(found.unwrap(), path);
// Wrong key should miss.
let mut wrong_key = [0xFFu8; 32];
wrong_key[0] = 0x00;
let miss = find_fxcache(dir.path(), &wrong_key);
assert!(miss.is_none(), "expected miss for wrong key");
}
/// Write garbage bytes to a file, verify `load_fxcache` returns an error
/// whose message contains "magic".
#[test]
fn test_fxcache_header_validation() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("garbage.fxcache");
// Write 128 bytes of garbage (more than header size).
std::fs::write(&path, vec![0xABu8; 128]).unwrap();
let err = load_fxcache(&path).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.to_lowercase().contains("magic"),
"expected error about magic bytes, got: {msg}"
);
}
/// Attempting to write 0 bars should fail (writer rejects empty data).
/// Attempting to read a hand-crafted header with bar_count=0 should also fail.
#[test]
fn test_fxcache_empty() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("empty.fxcache");
// Writer rejects 0 bars.
let features: Vec<[f64; 42]> = vec![];
let targets: Vec<[f64; 6]> = vec![];
let ofi: Vec<[f64; OFI_DIM]> = vec![];
let timestamps: Vec<i64> = vec![];
let key = test_cache_key();
let err = write_fxcache(&path, &features, &targets, &ofi, &timestamps, key, false, None).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("0 bars") || msg.contains("empty"),
"expected empty/0-bars error from writer, got: {msg}"
);
// Reader also rejects a header with bar_count=0.
// Hand-craft a valid-magic header with current version + bar_count=0.
let mut header = [0u8; 64];
header[0..8].copy_from_slice(b"FXCACHE\0");
header[8..10].copy_from_slice(&FXCACHE_VERSION.to_le_bytes()); // current version
header[10..12].copy_from_slice(&42u16.to_le_bytes()); // feat_dim
header[12..14].copy_from_slice(&6u16.to_le_bytes()); // target_dim
header[14..16].copy_from_slice(&(OFI_DIM as u16).to_le_bytes()); // ofi_dim
header[16..24].copy_from_slice(&0u64.to_le_bytes()); // bar_count=0
// cache_key + reserved stay zeroed.
let path_reader = dir.path().join("empty_header.fxcache");
std::fs::write(&path_reader, &header).unwrap();
let err = load_fxcache(&path_reader).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("zero") || msg.contains("bar_count"),
"expected bar_count/zero error from reader, got: {msg}"
);
}