# Feature Extraction Pipeline Fix

**Goal:** Make the fxcache data pipeline correct, strict, and consistent across all callers. One shared discovery function, strict key matching, symbol + data_source in the cache key, explicit symbol parameter on trainer API.

**Problem:** The current pipeline has 3 separate code paths for fxcache discovery, each with different logic for symbol scoping, cache key calculation, and fallback behavior. This causes: silent data corruption (wrong cache loaded), symbol mixing (4 instruments loaded instead of 1), OFI features silently dropped, and cache key collisions between symbols.

---

## Changes

### 1. Shared fxcache discovery function

**File:** `crates/ml/src/fxcache.rs`

New function replaces all 3 inline discovery paths:

```rust
pub fn discover_and_load(
    data_dir: &Path,       // Base data dir (e.g. test_data/futures-baseline)
    symbol: &str,          // e.g. "ES.FUT"
    mbp10_dir: Option<&Path>,
    trades_dir: Option<&Path>,
    data_source: &str,     // "ohlcv" or "mbp10"
    cache_dir_override: Option<&Path>,  // CLI --feature-cache-dir
) -> Option<FxCacheData>
```

**Cache dir priority:** `cache_dir_override` > `FOXHUNT_FEATURE_CACHE_DIR` env > walk-up sibling from `data_dir`.

**Behavior:**
- Compute cache key including symbol + data_source
- Strict key match only — return `None` on miss
- No "most recent" fallback (the dangerous `data_loading.rs:167-181` code is deleted)
- On hit, load and return `FxCacheData`
- On miss, return `None` (caller falls through to DBN loading)

### 2. Cache key includes symbol + data_source

**File:** `crates/ml/src/feature_cache.rs`

Extend `calculate_dbn_cache_key_full()` signature:

```rust
pub fn calculate_dbn_cache_key_full(
    data_dir: &Path,
    mbp10_dir: Option<&Path>,
    trades_dir: Option<&Path>,
    symbol: &str,
    data_source: &str,
) -> Result<String>
```

The hash includes `symbol` and `data_source` as additional inputs after the file metadata. This means:
- ES.FUT and NQ.FUT get different keys (even with same base dir)
- ohlcv and mbp10 modes get different keys (even with same files)
- Old caches auto-invalidate (different key format)

All callers of `calculate_dbn_cache_key_full` must be updated to pass symbol + data_source.

### 3. `DQNTrainer::train()` takes explicit symbol

**File:** `crates/ml/src/trainers/dqn/trainer/mod.rs`

Change signature:

```rust
pub async fn train<F>(
    &mut self,
    dbn_data_dir: &str,
    symbol: &str,
    checkpoint_callback: F,
) -> Result<TrainingMetrics>
```

`load_training_data()` also takes `symbol`:

```rust
pub async fn load_training_data(
    &mut self,
    dbn_data_dir: &str,
    symbol: &str,
) -> Result<(Vec<...>, Vec<...>)>
```

- Symbol scoping: loads from `data_dir/symbol/` for DBN files
- Cache key: uses base `data_dir` + symbol in hash (via shared `discover_and_load`)
- All callers updated to pass symbol explicitly

### 4. Remove 3 duplicate discovery paths

Delete inline fxcache discovery from:

- `train_baseline_rl.rs` lines 545-589 — replace with `fxcache::discover_and_load()`
- `data_loading.rs` lines 100-200 — replace with `fxcache::discover_and_load()`
- `hyperopt/adapters/dqn.rs` lines 794-925 — replace with `fxcache::discover_and_load()`

Each caller becomes ~5 lines: call `discover_and_load()`, if `Some` use it, else fall through to DBN.

### 5. Remove dangerous "most recent" fallback

**File:** `crates/ml/src/trainers/dqn/data_loading.rs`

Delete lines 167-181 entirely (the code that loads the newest `.fxcache` file regardless of key match). Strict key match or DBN loading, nothing in between.

### 6. OFI: explicit flag instead of zero-detection

**File:** `crates/ml/src/fxcache.rs`

Add `has_ofi: bool` field to `FxCacheData` struct. Set it during `write_fxcache()` based on whether MBP-10 data was present during precompute.

On load, check `has_ofi` flag instead of scanning for non-zero values. This eliminates the silent failure where a cache built without MBP-10 (all-zero OFI) is loaded and OFI appears "not present."

Update `precompute_features.rs` to set the flag when writing.

### 7. precompute_features key update

**File:** `crates/ml/examples/precompute_features.rs`

Update both `calculate_dbn_cache_key_full` calls (early check at line 214 and final write at line 394) to pass `--symbol` and data_source. The precompute binary already takes `--symbol` — just wire it into the key.

### 8. hyperopt symbol passthrough

**File:** `crates/ml/examples/hyperopt_baseline_rl.rs`

The `DQNTrainer` in hyperopt already takes a symbol dir. Update `preload_data()` to use the shared `discover_and_load()` and pass symbol correctly.

---

## Tests

### Unit tests for shared discovery (`crates/ml/src/fxcache.rs`)

- `test_discover_cache_hit` — precompute a cache, discover it → `Some(data)`
- `test_discover_cache_miss_wrong_key` — cache exists but wrong key → `None`
- `test_discover_cache_miss_no_cache` — empty cache dir → `None`
- `test_discover_env_var_override` — `FOXHUNT_FEATURE_CACHE_DIR` takes priority over walk-up
- `test_discover_explicit_dir_override` — explicit dir overrides env var

### Unit tests for cache key (`crates/ml/src/feature_cache.rs`)

- `test_cache_key_includes_symbol` — same data_dir + different symbol → different key
- `test_cache_key_includes_data_source` — same data_dir + different data_source → different key
- `test_cache_key_stable` — same inputs → same key (deterministic)

### Integration tests (existing, updated)

- `dqn_training_pipeline_test.rs` — all 5 tests pass symbol to `train()`
- `dqn_early_stopping_termination_test.rs` — all 3 tests pass symbol to `train()`
- Smoke tests — use shared discovery

---

## Files touched

| File | Change |
|------|--------|
| `crates/ml/src/fxcache.rs` | Add `discover_and_load()`, add `has_ofi` to `FxCacheData`, update read/write |
| `crates/ml/src/feature_cache.rs` | Add symbol + data_source params to `calculate_dbn_cache_key_full()` |
| `crates/ml/src/trainers/dqn/data_loading.rs` | Replace inline discovery with shared function, delete fallback, update `load_training_data(symbol)` |
| `crates/ml/src/trainers/dqn/trainer/mod.rs` | Update `train(data_dir, symbol, callback)` and `train_walk_forward` signatures |
| `crates/ml/src/hyperopt/adapters/dqn.rs` | Replace `preload_data()` discovery with shared function |
| `crates/ml/examples/train_baseline_rl.rs` | Replace inline discovery with `fxcache::discover_and_load()` |
| `crates/ml/examples/hyperopt_baseline_rl.rs` | Wire symbol into discovery |
| `crates/ml/examples/precompute_features.rs` | Pass symbol + data_source to cache key |
| `crates/ml/tests/dqn_training_pipeline_test.rs` | Pass symbol to `train()` |
| `crates/ml/tests/dqn_early_stopping_termination_test.rs` | Pass symbol to `train()` |
| `crates/ml/src/trainers/dqn/smoke_tests/helpers.rs` | Use shared discovery |

---

## What this does NOT change

- The fxcache binary format (read/write remain compatible, just adds `has_ofi` field)
- The DBN loading logic (only the discovery/cache layer changes)
- The training loop, CUDA kernels, or GPU infrastructure
- The Argo workflow templates (they already pass `--symbol`)
- The smoketest profile hyperparameters (NaN stability is a separate issue)

## Success criteria

1. `cargo test -p ml-core --lib` — all pass
2. `cargo test -p ml --lib -- fxcache` — new discovery tests pass
3. `cargo test -p ml --lib -- feature_cache` — new key tests pass
4. `cargo test -p ml --release --test dqn_training_pipeline_test` — all 5 pass (with symbol)
5. `cargo test -p ml --release --test dqn_early_stopping_termination_test` — all 4 pass
6. Precompute → train round-trip: fxcache hit with correct OFI
