diff --git a/crates/ml/src/fxcache.rs b/crates/ml/src/fxcache.rs index bf7eed06b..bfe5adb23 100644 --- a/crates/ml/src/fxcache.rs +++ b/crates/ml/src/fxcache.rs @@ -514,8 +514,6 @@ fn read_body_bf16( // ── Finder ─────────────────────────────────────────────────────────────────── -// ── Tests ──────────────────────────────────────────────────────────────────── - #[cfg(test)] mod has_ofi_tests { use super::*; @@ -575,3 +573,156 @@ pub fn find_fxcache(cache_dir: &Path, cache_key: &[u8; 32]) -> Option { None } } + +/// Resolve fxcache directory: explicit override > env var > walk-up sibling. +pub fn resolve_cache_dir(data_dir: &Path, override_dir: Option<&Path>) -> Option { + // 1. Explicit override (CLI --feature-cache-dir) + if let Some(dir) = override_dir { + if dir.exists() { + return Some(dir.to_path_buf()); + } + } + + // 2. Environment variable + if let Ok(dir) = std::env::var("FOXHUNT_FEATURE_CACHE_DIR") { + let p = PathBuf::from(dir); + if p.exists() { + return Some(p); + } + } + + // 3. Walk up from data_dir to find sibling feature-cache/ + let mut dir = data_dir; + loop { + if let Some(parent) = dir.parent() { + let candidate = parent.join("feature-cache"); + if candidate.exists() { + return Some(candidate); + } + if parent == dir { + break; + } + dir = parent; + } else { + break; + } + } + + None +} + +/// Discover and load an fxcache file. Single source of truth for all callers. +/// +/// Cache dir priority: `cache_dir_override` > `FOXHUNT_FEATURE_CACHE_DIR` env > walk-up sibling. +/// Strict key match only — returns `None` on miss. No "most recent" fallback. +/// +/// # Arguments +/// * `data_dir` — Base data directory (e.g. `test_data/futures-baseline`) +/// * `symbol` — Trading symbol (e.g. `"ES.FUT"`) +/// * `mbp10_dir` — Optional MBP-10 order book data directory +/// * `trades_dir` — Optional trades data directory +/// * `data_source` — Data source mode (`"ohlcv"` or `"mbp10"`) +/// * `cache_dir_override` — Explicit cache directory (from CLI `--feature-cache-dir`) +pub fn discover_and_load( + data_dir: &Path, + symbol: &str, + mbp10_dir: Option<&Path>, + trades_dir: Option<&Path>, + data_source: &str, + cache_dir_override: Option<&Path>, +) -> Option { + // 1. Resolve cache directory + let cache_dir = resolve_cache_dir(data_dir, cache_dir_override)?; + + // 2. Compute cache key (includes symbol + data_source) + let key_hex = crate::feature_cache::calculate_dbn_cache_key_full( + data_dir, mbp10_dir, trades_dir, symbol, data_source, + ) + .ok()?; + let key: [u8; 32] = hex::decode(&key_hex).ok()?.try_into().ok()?; + + // 3. Strict key match — no fallback to "most recent" + let path = find_fxcache(&cache_dir, &key)?; + + // 4. Load and return + match load_fxcache(&path) { + Ok(data) => { + info!( + "fxcache hit: {} bars, OFI={} from {:?}", + data.bar_count, data.has_ofi, path + ); + Some(data) + } + Err(e) => { + tracing::warn!("fxcache load failed: {e}"); + None + } + } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod discover_tests { + use super::*; + + #[test] + fn test_discover_returns_none_for_nonexistent_dir() { + let result = discover_and_load( + Path::new("/nonexistent/path"), + "ES.FUT", + None, + None, + "ohlcv", + None, + ); + assert!(result.is_none()); + } + + #[test] + fn test_discover_returns_none_on_key_mismatch() { + let dir = tempfile::tempdir().unwrap(); + let cache_dir = dir.path().join("feature-cache"); + std::fs::create_dir_all(&cache_dir).unwrap(); + + // Write a cache file with a known dummy key + let path = cache_dir.join( + "0000000000000000000000000000000000000000000000000000000000000000.fxcache", + ); + let features = vec![[1.0_f64; 42]; 5]; + let targets = vec![[0.0_f64; 4]; 5]; + let ofi = vec![[0.0_f64; 8]; 5]; + let timestamps = vec![1_i64; 5]; + write_fxcache(&path, &features, &targets, &ofi, ×tamps, [0u8; 32], false, false) + .unwrap(); + + // Try to discover with a real data_dir (different key) — should NOT match + let result = discover_and_load( + Path::new("test_data/futures-baseline"), + "ES.FUT", + None, + None, + "ohlcv", + Some(&cache_dir), + ); + // Strict match only — no "most recent" fallback + assert!(result.is_none(), "Wrong key should not match (strict mode)"); + } + + #[test] + fn test_resolve_cache_dir_explicit_override() { + let dir = tempfile::tempdir().unwrap(); + let result = resolve_cache_dir(Path::new("/some/data"), Some(dir.path())); + assert_eq!(result, Some(dir.path().to_path_buf())); + } + + #[test] + fn test_resolve_cache_dir_nonexistent_override_falls_through() { + let result = resolve_cache_dir( + Path::new("/some/data"), + Some(Path::new("/nonexistent/override")), + ); + // Falls through to env var / walk-up (both will fail here) + assert!(result.is_none()); + } +}