# Tick-Level Microstructure Intelligence Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Extract 12 new predictive features from raw MBP-10 tick data, expanding OFI_DIM from 8→20 and feature vector from 50→62 dimensions, giving the DQN model sub-bar microstructure awareness.

**Architecture:** Extend `OFICalculator` with `IncrementalMicrostructureCalculator` (all O(1) per tick). Update fxcache format (OFI_DIM 8→20, single format, regenerate all files). Update 15+ files with hardcoded `8` literals atomically. Same calculator struct used for both precompute (training) and live streaming (inference).

**Tech Stack:** Rust 1.85, ml-features crate, fxcache binary format, Databento MBP-10/Trades, CUDA experience collector

---

## Task 1: IncrementalMicrostructureCalculator — 12 New Features

Extend the existing `OFICalculator` with 12 new tick-level features. All O(1) per tick.

**Files:**
- Modify: `crates/ml-features/src/ofi_calculator.rs`

- [ ] **Step 1: Add `MicrostructureState` struct for running statistics**

At the end of `crates/ml-features/src/ofi_calculator.rs`, add:

```rust
/// Running state for 12 tick-level microstructure features (indices 8-19).
/// All computations are O(1) per tick via EMAs, counters, and running sums.
/// Used by both batch precompute (training) and live streaming (inference).
#[derive(Debug, Clone)]
pub struct MicrostructureState {
    // [8] OFI Trajectory: online linear regression of OFI_L1 over time
    ofi_traj_sum_t: f64,      // sum of t values
    ofi_traj_sum_t2: f64,     // sum of t² values
    ofi_traj_sum_ofi: f64,    // sum of OFI values
    ofi_traj_sum_t_ofi: f64,  // sum of t*OFI values
    ofi_traj_n: u64,          // count

    // [9] Realized Variance: sum of squared log returns
    rv_sum_sq: f64,
    prev_mid: f64,

    // [10] Trade Arrival Intensity (Hawkes EMA)
    hawkes_intensity: f64,
    last_trade_ns: u64,
    hawkes_mu: f64,       // baseline intensity
    hawkes_alpha: f64,    // excitation
    hawkes_beta: f64,     // decay rate

    // [11] Book Pressure Gradient
    book_pressure_ema: f64,

    // [12] Spread Dynamics
    spread_min: f64,
    spread_max: f64,
    spread_sum: f64,
    spread_count: u64,

    // [13] Aggression Ratio
    trades_at_ask: u64,
    trades_total: u64,

    // [14] Queue Depletion Asymmetry
    prev_bid_sz_top3: u64,
    prev_ask_sz_top3: u64,
    bid_depletion_ema: f64,
    ask_depletion_ema: f64,

    // [15] Order Count Flux (approximation from MBP-10 snapshots)
    prev_bid_ct_top3: u32,
    prev_ask_ct_top3: u32,
    ct_increase_sum: u64,
    ct_decrease_sum: u64,

    // [16] Intra-Bar Momentum (Welford online covariance)
    half1_sum: f64, half1_sum_sq: f64, half1_n: u64,
    half2_sum: f64, half2_sum_sq: f64, half2_n: u64,
    bar_start_ns: u64,
    bar_mid_ns: u64,

    // [17] Microstructure Regime Score (composite)
    // Derived from aggression, spread, book thickness at snapshot time

    // [18] OFI Acceleration (EMA of OFI deltas)
    prev_ofi_l1: f64,
    ofi_delta_ema: f64,

    // [19] Toxicity Gradient (EMA of VPIN deltas)
    prev_vpin: f64,
    vpin_delta_ema: f64,

    tick_count: u64,
}
```

- [ ] **Step 2: Implement `MicrostructureState` methods**

```rust
impl MicrostructureState {
    pub fn new(bar_start_ns: u64, bar_duration_ns: u64) -> Self {
        Self {
            ofi_traj_sum_t: 0.0, ofi_traj_sum_t2: 0.0,
            ofi_traj_sum_ofi: 0.0, ofi_traj_sum_t_ofi: 0.0, ofi_traj_n: 0,
            rv_sum_sq: 0.0, prev_mid: 0.0,
            hawkes_intensity: 0.0, last_trade_ns: 0, hawkes_mu: 1.0,
            hawkes_alpha: 0.5, hawkes_beta: 1.0,
            book_pressure_ema: 0.0,
            spread_min: f64::MAX, spread_max: f64::MIN, spread_sum: 0.0, spread_count: 0,
            trades_at_ask: 0, trades_total: 0,
            prev_bid_sz_top3: 0, prev_ask_sz_top3: 0,
            bid_depletion_ema: 0.0, ask_depletion_ema: 0.0,
            prev_bid_ct_top3: 0, prev_ask_ct_top3: 0,
            ct_increase_sum: 0, ct_decrease_sum: 0,
            half1_sum: 0.0, half1_sum_sq: 0.0, half1_n: 0,
            half2_sum: 0.0, half2_sum_sq: 0.0, half2_n: 0,
            bar_start_ns, bar_mid_ns: bar_start_ns + bar_duration_ns / 2,
            prev_ofi_l1: 0.0, ofi_delta_ema: 0.0,
            prev_vpin: 0.0, vpin_delta_ema: 0.0,
            tick_count: 0,
        }
    }

    /// Update from MBP-10 snapshot. O(1) per call.
    pub fn update_snapshot(&mut self, snapshot: &Mbp10Snapshot) {
        let alpha = 0.05;
        self.tick_count += 1;

        // Mid price
        let best = &snapshot.levels[0];
        let bid_f = BidAskPair::price_to_f64(best.bid_px);
        let ask_f = BidAskPair::price_to_f64(best.ask_px);
        let mid = (bid_f + ask_f) / 2.0;

        // [9] Realized Variance
        if self.prev_mid > 0.0 && mid > 0.0 {
            let log_ret = (mid / self.prev_mid).ln();
            self.rv_sum_sq += log_ret * log_ret;

            // [16] Intra-bar momentum
            if snapshot.timestamp < self.bar_mid_ns {
                self.half1_sum += log_ret;
                self.half1_sum_sq += log_ret * log_ret;
                self.half1_n += 1;
            } else {
                self.half2_sum += log_ret;
                self.half2_sum_sq += log_ret * log_ret;
                self.half2_n += 1;
            }
        }
        self.prev_mid = mid;

        // [11] Book Pressure Gradient: weighted imbalance across 10 levels
        let mut weighted_imb = 0.0;
        let mut total_sz = 0.0;
        for (l, pair) in snapshot.levels.iter().enumerate().take(10) {
            let w = (-0.3 * l as f64).exp();
            weighted_imb += w * (pair.bid_sz as f64 - pair.ask_sz as f64);
            total_sz += pair.bid_sz as f64 + pair.ask_sz as f64;
        }
        let pressure = if total_sz > 0.0 { weighted_imb / total_sz } else { 0.0 };
        self.book_pressure_ema = (1.0 - alpha) * self.book_pressure_ema + alpha * pressure;

        // [12] Spread Dynamics
        let spread = ask_f - bid_f;
        if spread > 0.0 {
            self.spread_min = self.spread_min.min(spread);
            self.spread_max = self.spread_max.max(spread);
            self.spread_sum += spread;
            self.spread_count += 1;
        }

        // [14] Queue Depletion
        let bid_sz_top3: u64 = snapshot.levels.iter().take(3).map(|p| p.bid_sz as u64).sum();
        let ask_sz_top3: u64 = snapshot.levels.iter().take(3).map(|p| p.ask_sz as u64).sum();
        if self.prev_bid_sz_top3 > 0 {
            let bid_dep = (self.prev_bid_sz_top3 as f64 - bid_sz_top3 as f64).max(0.0);
            let ask_dep = (self.prev_ask_sz_top3 as f64 - ask_sz_top3 as f64).max(0.0);
            self.bid_depletion_ema = (1.0 - alpha) * self.bid_depletion_ema + alpha * bid_dep;
            self.ask_depletion_ema = (1.0 - alpha) * self.ask_depletion_ema + alpha * ask_dep;
        }
        self.prev_bid_sz_top3 = bid_sz_top3;
        self.prev_ask_sz_top3 = ask_sz_top3;

        // [15] Order Count Flux
        let bid_ct_top3: u32 = snapshot.levels.iter().take(3).map(|p| p.bid_ct).sum();
        let ask_ct_top3: u32 = snapshot.levels.iter().take(3).map(|p| p.ask_ct).sum();
        if self.prev_bid_ct_top3 > 0 {
            let delta = (bid_ct_top3 as i64 - self.prev_bid_ct_top3 as i64)
                + (ask_ct_top3 as i64 - self.prev_ask_ct_top3 as i64);
            if delta > 0 { self.ct_increase_sum += delta as u64; }
            else { self.ct_decrease_sum += (-delta) as u64; }
        }
        self.prev_bid_ct_top3 = bid_ct_top3;
        self.prev_ask_ct_top3 = ask_ct_top3;
    }

    /// Update from trade event. O(1) per call.
    pub fn update_trade(&mut self, price: f64, _volume: u64, is_buy: bool, timestamp_ns: u64) {
        let alpha = 0.05;

        // [10] Hawkes intensity
        if self.last_trade_ns > 0 {
            let dt = (timestamp_ns - self.last_trade_ns) as f64 / 1e9; // seconds
            self.hawkes_intensity = self.hawkes_mu
                + self.hawkes_alpha * (self.hawkes_intensity - self.hawkes_mu)
                    * (-self.hawkes_beta * dt).exp()
                + self.hawkes_alpha;
        }
        self.last_trade_ns = timestamp_ns;

        // [13] Aggression Ratio
        if is_buy { self.trades_at_ask += 1; }
        self.trades_total += 1;

        // Update prev_mid for realized variance if we don't have a snapshot mid
        if self.prev_mid == 0.0 { self.prev_mid = price; }
    }

    /// Update OFI-derived features. Call after OFICalculator.calculate().
    pub fn update_ofi_derived(&mut self, ofi_l1: f64, vpin: f64) {
        let alpha = 0.1;
        let t = self.tick_count as f64;

        // [8] OFI Trajectory (online linear regression)
        self.ofi_traj_n += 1;
        self.ofi_traj_sum_t += t;
        self.ofi_traj_sum_t2 += t * t;
        self.ofi_traj_sum_ofi += ofi_l1;
        self.ofi_traj_sum_t_ofi += t * ofi_l1;

        // [18] OFI Acceleration
        let ofi_delta = ofi_l1 - self.prev_ofi_l1;
        self.ofi_delta_ema = (1.0 - alpha) * self.ofi_delta_ema + alpha * ofi_delta;
        self.prev_ofi_l1 = ofi_l1;

        // [19] Toxicity Gradient
        let vpin_delta = vpin - self.prev_vpin;
        self.vpin_delta_ema = (1.0 - alpha) * self.vpin_delta_ema + alpha * vpin_delta;
        self.prev_vpin = vpin;
    }

    /// Snapshot all 12 features. Pure read, non-mutating, idempotent.
    pub fn snapshot(&self) -> [f64; 12] {
        let n = self.ofi_traj_n as f64;

        // [8] OFI Trajectory slope
        let ofi_slope = if n > 1.0 {
            let denom = n * self.ofi_traj_sum_t2 - self.ofi_traj_sum_t * self.ofi_traj_sum_t;
            if denom.abs() > 1e-12 {
                (n * self.ofi_traj_sum_t_ofi - self.ofi_traj_sum_t * self.ofi_traj_sum_ofi) / denom
            } else { 0.0 }
        } else { 0.0 };

        // [9] Realized Variance (already accumulated)
        let rv = self.rv_sum_sq;

        // [10] Hawkes intensity
        let hawkes = self.hawkes_intensity.min(100.0);

        // [11] Book Pressure Gradient (EMA)
        let book_pressure = self.book_pressure_ema;

        // [12] Spread Dynamics
        let spread_dyn = if self.spread_count > 0 && self.spread_sum > 0.0 {
            let mean = self.spread_sum / self.spread_count as f64;
            (self.spread_max - self.spread_min) / mean.max(1e-12)
        } else { 0.0 };

        // [13] Aggression Ratio
        let aggression = if self.trades_total > 0 {
            self.trades_at_ask as f64 / self.trades_total as f64
        } else { 0.5 };

        // [14] Queue Depletion Asymmetry
        let max_dep = self.bid_depletion_ema.max(self.ask_depletion_ema).max(1e-12);
        let queue_asym = (self.bid_depletion_ema - self.ask_depletion_ema) / max_dep;

        // [15] Order Count Flux
        let total_flux = (self.ct_increase_sum + self.ct_decrease_sum) as f64;
        let flux = if total_flux > 0.0 {
            self.ct_increase_sum as f64 / total_flux
        } else { 0.5 };

        // [16] Intra-Bar Momentum (correlation proxy via sign of product of means)
        let h1_mean = if self.half1_n > 0 { self.half1_sum / self.half1_n as f64 } else { 0.0 };
        let h2_mean = if self.half2_n > 0 { self.half2_sum / self.half2_n as f64 } else { 0.0 };
        let momentum = (h1_mean * h2_mean).signum() * (h1_mean * h2_mean).abs().sqrt().min(1.0);

        // [17] Microstructure Regime Score
        let thin_book = 1.0 / (self.book_pressure_ema.abs() + 1.0);
        let regime_score = 1.0 / (1.0 + (-5.0 * (aggression - 0.5) * spread_dyn * thin_book).exp());

        // [18] OFI Acceleration
        let ofi_accel = self.ofi_delta_ema;

        // [19] Toxicity Gradient
        let tox_grad = self.vpin_delta_ema;

        [ofi_slope, rv, hawkes, book_pressure, spread_dyn, aggression,
         queue_asym, flux, momentum, regime_score, ofi_accel, tox_grad]
    }

    /// Reset for next bar.
    pub fn reset(&mut self, bar_start_ns: u64, bar_duration_ns: u64) {
        *self = Self::new(bar_start_ns, bar_duration_ns);
    }
}
```

- [ ] **Step 3: Add `ExtendedOFIFeatures` struct**

```rust
/// Extended OFI features: original 8 + 12 microstructure = 20 total.
#[derive(Debug, Clone)]
pub struct ExtendedOFIFeatures {
    pub base: OFIFeatures,
    pub micro: [f64; 12],
}

impl ExtendedOFIFeatures {
    pub fn to_array(&self) -> [f64; 20] {
        let mut out = [0.0; 20];
        let base = self.base.to_array();
        out[..8].copy_from_slice(&base);
        out[8..20].copy_from_slice(&self.micro);
        out
    }

    pub fn zeros() -> Self {
        Self { base: OFIFeatures::zeros(), micro: [0.0; 12] }
    }
}
```

- [ ] **Step 4: Verify compilation**

```bash
SQLX_OFFLINE=true cargo check -p ml-features 2>&1 | tail -5
```

- [ ] **Step 5: Commit**

```bash
cd /home/jgrusewski/Work/foxhunt && git add crates/ml-features/src/ofi_calculator.rs && git commit -m "feat(tick): IncrementalMicrostructureCalculator — 12 new tick-level features

MicrostructureState: OFI trajectory (online LinReg), realized variance,
Hawkes trade intensity, book pressure gradient, spread dynamics,
aggression ratio, queue depletion, order count flux, intra-bar momentum
(Welford), microstructure regime score, OFI acceleration, toxicity
gradient. All O(1) per tick, no allocation. ExtendedOFIFeatures [20].

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
```

---

## Task 2: fxcache OFI_DIM 8→20 — Single Atomic Update

Update ALL hardcoded OFI_DIM=8 literals across the codebase. This MUST be a single commit — partial updates break the build.

**Files (ALL modified in this task):**
- Modify: `crates/ml/src/fxcache.rs`
- Modify: `crates/ml/examples/precompute_features.rs`
- Modify: `crates/ml/src/cuda_pipeline/gpu_experience_collector.rs`
- Modify: `crates/ml/src/cuda_pipeline/mod.rs`
- Modify: `crates/ml/src/cuda_pipeline/gpu_walk_forward.rs`
- Modify: `crates/ml/src/cuda_pipeline/backtest_gather_kernel.cu`
- Modify: `crates/ml/src/trainers/dqn/trainer/mod.rs`
- Modify: `crates/ml/src/trainers/dqn/trainer/constructor.rs`
- Modify: `crates/ml/src/trainers/dqn/trainer/metrics.rs`
- Modify: `crates/ml/src/trainers/dqn/data_loading.rs`
- Modify: `crates/ml/src/hyperopt/adapters/dqn.rs`
- Modify: `crates/ml/src/hyperopt/adapters/ppo.rs`
- Modify: `crates/ml/examples/train_baseline_rl.rs`
- Modify: `crates/ml/tests/fxcache_roundtrip_test.rs`
- Modify: `crates/ml/tests/smoke_test_real_data.rs`

- [ ] **Step 1: Export OFI_DIM from fxcache.rs**

In `crates/ml/src/fxcache.rs`, change constants:

```rust
/// OFI vector dimensionality (20 MBP-10 microstructure features).
pub const OFI_DIM: usize = 20;

/// Total f64 values per record: features + targets + OFI = 42 + 4 + 20 = 66.
const RECORD_F64_COUNT: usize = FEAT_DIM + TARGET_DIM + OFI_DIM;
```

Make `OFI_DIM` public (`pub const`) so other crates can import it.

Also update the doc comment at line 16: `ofi_dim u16 = 20`.

- [ ] **Step 2: Update all `[f64; 8]` → `[f64; 20]` in fxcache.rs**

In `fxcache.rs`, change:
- Line 195: `pub ofi: Vec<[f64; OFI_DIM]>` — already uses constant, no change needed if OFI_DIM is updated
- Line 426: `vec![[0.5_f64; 8]; 10]` → `vec![[0.5_f64; OFI_DIM]; 10]`
- Line 442: `vec![[0.0_f64; 8]; 10]` → `vec![[0.0_f64; OFI_DIM]; 10]`
- Line 593: `vec![[0.0_f64; 8]; 5]` → `vec![[0.0_f64; OFI_DIM]; 5]`

Note: `[f64; OFI_DIM]` works because OFI_DIM is a const.

- [ ] **Step 3: Update precompute_features.rs**

- Line 329: `Vec<[f64; 8]>` → `Vec<[f64; 20]>` (or import and use `OFI_DIM`)
- Line 417: `[0.0; 8]` → `[0.0; 20]`
- Line 420: `[0.0; 8]` → `[0.0; 20]`
- Line 500: `"8-dim"` → `"20-dim"`

For now, the precompute still only fills 8 features (from existing OFICalculator). The remaining 12 are zero-padded. Task 3 will wire the new calculator.

- [ ] **Step 4: Update gpu_experience_collector.rs**

- Line 747: `if state_dim >= market_dim + portfolio_dim + 8 { 8 }` → `if state_dim >= market_dim + portfolio_dim + 20 { 20 }`

- [ ] **Step 5: Update cuda_pipeline/mod.rs**

- Line 301: `ofi: &[[f64; 8]]` → `ofi: &[[f64; 20]]`
- Line 342: `[0.0_f32; 8]` → `[0.0_f32; 20]`
- Line 366: `ofi_data: &[[f64; 8]]` → `ofi_data: &[[f64; 20]]`
- Line 402: `if self.ofi_features.is_some() { 8 }` → `{ 20 }`
- Line 477: same → `{ 20 }`
- Line 516: same → `{ 20 }`

- [ ] **Step 6: Update gpu_walk_forward.rs**

- Line 554: `ofi_data: Option<&[[f64; 8]]>` → `Option<&[[f64; 20]]>`
- Line 602: `[0.0_f32; 8]` → `[0.0_f32; 20]`

- [ ] **Step 7: Update backtest_gather_kernel.cu**

- Line 45: comment `// 0 = no OFI, 8 = standard OFI features` → `// 0 = no OFI, 20 = extended microstructure`

- [ ] **Step 8: Update trainers/dqn/trainer/mod.rs**

- Line 411: `pub ofi_features: Option<Arc<[[f64; 8]]>>` → `Arc<[[f64; 20]]>`
- Line 739: `Vec<[f64; 8]>` → `Vec<[f64; 20]>`
- Line 1215: `ofi: &[[f64; 8]]` → `ofi: &[[f64; 20]]`

- [ ] **Step 9: Update constructor.rs**

- Line 53: `if ofi_pre { 80 } else { 72 }` → `if ofi_pre { 88 } else { 72 }` (42+8+16+20=86, aligned (86+7)&!7=88)
- Line 255: `if ofi_enabled { 74 } else { 66 }` → `if ofi_enabled { 86 } else { 66 }` (42+8+16+20=86)
- Line 256: alignment stays `(raw_state_dim + 7) & !7`

- [ ] **Step 10: Update metrics.rs**

- Line 488: `ofi_dim: if ofi_enabled { 8 }` → `{ 20 }`

- [ ] **Step 11: Update data_loading.rs**

- Line 402: `[0.0; 8]` → `[0.0; 20]`
- Line 405: `[0.0; 8]` → `[0.0; 20]`
- Line 408: `[0.0; 8]` → `[0.0; 20]`

- [ ] **Step 12: Update hyperopt/adapters/dqn.rs**

- Line 538: `Option<Arc<[[f64; 8]]>>` → `Arc<[[f64; 20]]>`
- Line 920: `Vec<[f64; 8]>` → `Vec<[f64; 20]>`
- Line 1208: `Option<Arc<[[f64; 8]]>>` → `Arc<[[f64; 20]]>`
- Line 1437: `ofi_dim: if ofi_enabled { 8 }` → `{ 20 }`

- [ ] **Step 13: Update hyperopt/adapters/ppo.rs**

- Line 1498: `Option<Vec<[f64; 8]>>` → `Option<Vec<[f64; 20]>>`

- [ ] **Step 14: Update train_baseline_rl.rs**

- Line 594: `vec![[0.0_f64; 8]; n]` → `vec![[0.0_f64; 20]; n]`

- [ ] **Step 15: Update test files**

- `tests/fxcache_roundtrip_test.rs:38`: `fn make_ofi(n: usize) -> Vec<[f64; 8]>` → `[f64; 20]`
- `tests/fxcache_roundtrip_test.rs:190`: `vec![[0.0_f64; 8]; ...]` → `[0.0_f64; 20]`
- `tests/smoke_test_real_data.rs:444`: `Option<Vec<[f64; 8]>>` → `[f64; 20]`

- [ ] **Step 16: Verify compilation**

```bash
SQLX_OFFLINE=true cargo check --workspace 2>&1 | tail -10
```

Fix ANY remaining `[f64; 8]` or literal `8` references related to OFI.

- [ ] **Step 17: Run tests**

```bash
SQLX_OFFLINE=true cargo test -p ml --lib 2>&1 | tail -5
```

Note: fxcache roundtrip tests may fail if they create fxcache files with old OFI_DIM=8. The test code was updated in step 15 — verify they pass with the new dimension.

- [ ] **Step 18: Commit**

```bash
cd /home/jgrusewski/Work/foxhunt && git add -A && git commit -m "feat(tick): OFI_DIM 8→20 — atomic update across 15 files

fxcache: OFI_DIM=20, RECORD_F64_COUNT=66, 272 bytes/bar.
constructor: state_dim 74→86 (aligned 88) with OFI.
experience collector: ofi_dim detection 8→20.
All [f64; 8] → [f64; 20]. All literal 8 → 20.
Existing 8 features preserved at indices 0-7.
New 12 features zero-padded until precompute is extended.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
```

---

## Task 3: Extend Precompute Pipeline

Wire `MicrostructureState` into `precompute_features.rs` to compute all 20 OFI features per bar from tick data.

**Files:**
- Modify: `crates/ml/examples/precompute_features.rs`

- [ ] **Step 1: Import MicrostructureState**

Add import at top of `precompute_features.rs`:

```rust
use ml_features::ofi_calculator::MicrostructureState;
```

- [ ] **Step 2: Wire into per-bar OFI computation**

Find the OFI computation loop (around line 329). Currently it creates `OFICalculator`, processes snapshots per bar, and outputs `[f64; 8]`. Change to:

For each bar:
1. Create `MicrostructureState::new(bar_start_ns, 60_000_000_000)` (60s bar)
2. For each MBP-10 snapshot within the bar: call `micro_state.update_snapshot(snapshot)`
3. For each trade within the bar: call `micro_state.update_trade(price, volume, is_buy, ts)`
4. After OFI calculation: call `micro_state.update_ofi_derived(ofi.ofi_level1, ofi.vpin)`
5. Get `micro_12 = micro_state.snapshot()`
6. Combine: `base_8 ++ micro_12 = [f64; 20]`

- [ ] **Step 3: Verify compilation**

```bash
SQLX_OFFLINE=true cargo check -p ml 2>&1 | tail -5
```

- [ ] **Step 4: Commit**

```bash
cd /home/jgrusewski/Work/foxhunt && git add crates/ml/examples/precompute_features.rs && git commit -m "feat(tick): precompute pipeline extended — 20 OFI features per bar

MicrostructureState processes all MBP-10 snapshots + trades per bar.
12 new tick-level features: OFI trajectory, realized variance, Hawkes
intensity, book pressure, spread dynamics, aggression, queue depletion,
order count flux, intra-bar momentum, regime score, OFI acceleration,
toxicity gradient. Zero-padded when MBP-10 data unavailable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
```

---

## Task 4: Regenerate fxcache on PVC + Smoke Test

Regenerate all fxcache files with 20-dim OFI, verify model loads correctly.

**Files:**
- No code changes — operational task

- [ ] **Step 1: Regenerate local test data fxcache**

```bash
SQLX_OFFLINE=true cargo run --release --example precompute_features -- --data-dir test_data/futures-baseline 2>&1 | tail -10
```

- [ ] **Step 2: Verify fxcache header**

```bash
xxd test_data/futures-baseline/*.fxcache | head -5
```

Check that `ofi_dim` field (bytes 14-15) is `0x14 0x00` (20 in little-endian).

- [ ] **Step 3: Run smoke test**

```bash
SQLX_OFFLINE=true FOXHUNT_TEST_DATA=test_data/futures-baseline cargo test -p ml --lib -- test_generalization_components_smoke --include-ignored --nocapture 2>&1 | tail -20
```

- [ ] **Step 4: Run compute-sanitizer**

```bash
FOXHUNT_TEST_DATA=test_data/futures-baseline compute-sanitizer --tool memcheck --print-limit 5 target/debug/deps/ml-* "test_generalization_components_smoke" --test-threads=1 --include-ignored 2>&1 | grep "ERROR SUMMARY"
```

**Target: 0 errors.**

- [ ] **Step 5: Run full test suite**

```bash
SQLX_OFFLINE=true cargo test -p ml --lib 2>&1 | tail -5
```

**Target: 899+ passed, 0 failed.**

- [ ] **Step 6: Commit test data**

```bash
cd /home/jgrusewski/Work/foxhunt && git add test_data/ && git commit -m "chore(tick): regenerate test fxcache with 20-dim OFI

Smoke test passes. compute-sanitizer: 0 errors. 899+ tests pass.
Feature vector: 42 + 20 = 62 dimensions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
```

---

## Self-Review

**Spec coverage:**
- ✅ 12 new features in MicrostructureState — Task 1
- ✅ fxcache OFI_DIM 8→20 — Task 2
- ✅ All hardcoded `8` literals enumerated — Task 2 (15 files)
- ✅ Precompute pipeline extension — Task 3
- ✅ constructor state_dim 74→86 — Task 2 Step 9
- ✅ Experience collector ofi_dim detection — Task 2 Step 4
- ✅ Atomic deploy (single commit for all OFI changes) — Task 2
- ✅ Smoke test + compute-sanitizer — Task 4
- ✅ O(1) per tick, no allocation — MicrostructureState uses only scalar running stats
- ✅ snapshot() is pure read, non-mutating — const &self

**Not in this plan (separate future tasks):**
- Databento live integration (TICK Task 5)
- H100 between-bar speculative compute (TICK Task 6)
- PVC regeneration via Argo workflow (operational)

**Placeholder scan:** No TBD, TODO, or "implement later" found.

**Type consistency:** `[f64; 20]` used consistently. `MicrostructureState` name consistent. `ExtendedOFIFeatures` used in Task 1, referenced in Task 3.
