# OFI Momentum Enrichment + Dense Micro-Reward Design Spec (v2)

## Goal

Provide dense per-bar gradient signal for the temporal pipeline (Mamba2 + attention) by:
1. Fixing the broken OFI data pipeline (features uploaded but never wired to kernels)
2. Computing OFI momentum features (temporal derivatives of order flow)
3. Feeding them into Mamba2 history AND attention input via a learned embedding
4. Using flow momentum as a dense micro-reward for positioned bars
5. Adding backward pass gradient flow through Mamba2 and attention to the OFI embed MLP

## Pre-existing Bugs This Spec Fixes

| Bug | Location | Impact |
|-----|----------|--------|
| `ofi_gpu` uploaded but never passed to any kernel | gpu_experience_collector.rs | OFI features always zero in env_step |
| `concat_ofi_features` uses indices 42-49 (portfolio) instead of 66-85 (OFI) | gpu_dqn_trainer.rs:2082 | Order/urgency branches read portfolio features, not OFI |
| `PORTFOLIO_STRIDE=30` — no room for prev-OFI storage | experience_kernels.cu:65 | Must expand to 38 |

## Architecture Overview

### Phase A: OFI Data Pipeline Fix

**Problem:** `ofi_gpu` buffer is uploaded to GPU via `upload_ofi_features()` but never passed to `state_gather_kernel` or `env_step_batch`. The `concat_ofi_features` kernel reads indices 42-49 (portfolio features) instead of 66-85 (actual OFI location in the 96-dim state vector).

**Fix:**
1. Wire `ofi_gpu` into `state_gather_kernel` so OFI features are written to `batch_states[66..74)` during experience collection
2. Pass the `batch_states` buffer (or a dedicated OFI slice) to `env_step_batch` so the micro-reward can read current-bar OFI values
3. Fix `concat_ofi_features` ofi_base from 42/45 to 66/69 in `gpu_dqn_trainer.rs`

**Data flow (fixed):**
```
fxcache (OFI precomputed) → ofi_gpu buffer → state_gather → batch_states[66..74)
                                                                    ↓
env_step_batch reads batch_states[b * state_dim + 66 + ofi_col] for micro-reward
                                                                    ↓
concat_ofi_features reads batch_states[b * SD + 66] for order branch (was 42)
```

### Phase B: PORTFOLIO_STRIDE Expansion

**Current:** `PORTFOLIO_STRIDE = 30`, slots 0-29 defined, slots 6 and 22 reserved.

**New:** `PORTFOLIO_STRIDE = 38`, slots 30-37 store previous bar's OFI for delta computation.

**Changes:**
- `experience_kernels.cu` line 65: `#define PORTFOLIO_STRIDE 38`
- All Rust allocation sites that use `PORTFOLIO_STRIDE` or `portfolio_stride` must match
- `gpu_experience_collector.rs`: portfolio_states allocation uses stride from config/constant

### Phase C: Dense Micro-Reward

**Location:** `experience_kernels.cu` env_step_batch, replacing lines 1715-1721.

**Targets buffer expansion (target_dim 4→6):**
```
Current: tgt[0]=preproc_close, tgt[1]=preproc_next, tgt[2]=raw_close, tgt[3]=raw_next
New:     tgt[0]=preproc_close, tgt[1]=preproc_next, tgt[2]=raw_close, tgt[3]=raw_next,
         tgt[4]=raw_open, tgt[5]=mid_price_open
```
- `raw_open`: OHLCV open price (already parsed in data_loading.rs:650)
- `mid_price_open`: MBP-10 best_bid/best_ask midpoint at bar formation — the true entry price

Requires fxcache recomputation (one-time Argo job: `precompute_features` binary).

**Formula:**
```c
if (!segment_complete && fabsf(position) > 0.001f && micro_reward_scale > 0.0f) {
    /* Read current OFI from batch_states (wired in Phase A) */
    const float* ofi_cur = batch_states + (long long)i * state_dim + 66;
    float sign_pos = (position > 0.0f) ? 1.0f : -1.0f;

    /* OFI momentum: delta from previous bar (stored in ps[30..37]) */
    float delta_depth = ofi_cur[1] - ps[31];  /* depth_imbalance_L1 */
    float delta_vpin  = ofi_cur[6] - ps[36];  /* VPIN */
    float delta_queue = ofi_cur[2] - ps[32];  /* queue_pressure */
    float delta_tarr  = ofi_cur[5] - ps[35];  /* trade_arrival_rate */

    float flow_momentum = sign_pos * 0.25f * (delta_depth + delta_vpin + delta_queue + delta_tarr);

    /* Price confirmation: intra-bar directional move from MBP-10 data */
    float raw_open = tgt[4];           /* bar open price from OHLCV */
    float mid_open = tgt[5];           /* mid-price at bar formation from MBP-10 */
    float bar_move = (raw_close - raw_open) / fmaxf(atr_abs, 1e-6f);
    float price_confirm = sign_pos * bar_move;

    /* Spread cost awareness: distance from mid to open = half-spread at entry */
    float spread_at_entry = fabsf(raw_open - mid_open);

    /* Adaptive cost tolerance: profitable models earn the right to trade more.
     * capital_ratio > 1 = profitable episode, Sharpe > 0 = consistent alpha.
     * High performance → lower effective cost → model can trade more freely.
     * Low performance → higher effective cost → model conserves capital.
     * Floor at 0.1 prevents division collapse at init. */
    float capital_ratio = equity / fmaxf(initial_capital, 1.0f);
    float sharpe_ema = ps[DSR_A_SLOT];  /* running Sharpe from DSR EMA */
    float cost_tolerance = fmaxf(capital_ratio * fmaxf(sharpe_ema, 0.0f), 0.1f);
    float spread_penalty = spread_at_entry / (fmaxf(atr_abs, 1e-6f) * cost_tolerance);

    /* Informed flow intensity */
    float vol_informed = ofi_cur[6] * ofi_cur[7];  /* VPIN × Kyle's_lambda */

    /* ── Pearl 2: Order book center of mass (aggression signal) ──
     * Uses all 10 MBP-10 depth levels to detect aggressive vs passive flow.
     * buy_com near 1 = aggressive buying (pressure at top of book).
     * Precomputed in fxcache as a single feature (book_aggression). */
    float book_aggression = (state_dim >= 83) ? batch_states[(long long)i * state_dim + 82] : 0.0f;

    /* Composite quality — performance-adaptive cost + book aggression */
    float quality = flow_momentum * (1.0f + vol_informed)
                  + 0.5f * price_confirm
                  + 0.3f * sign_pos * book_aggression  /* Pearl 2: aggressive flow in our direction */
                  - spread_penalty;  /* adaptive: costly when losing, cheap when winning */

    /* ── Pearl 3: Retrospective hold quality bonus ──
     * Evaluate whether the PREVIOUS bar's Hold decision was correct.
     * If model held and mid-price moved favorably > spread, bonus. */
    float prev_mid = ps[PREV_MID_SLOT];
    float mid_now = tgt[5];  /* MBP-10 mid-price at this bar's formation */
    if (prev_mid > 0.0f && mid_now > 0.0f) {
        float hold_return = (mid_now - prev_mid) / fmaxf(prev_mid, 1.0f);
        float hold_quality = sign_pos * hold_return / fmaxf(atr_pct, 1e-6f);
        quality += 0.2f * hold_quality;  /* retrospective: was holding correct? */
    }

    reward = micro_reward_scale * tanhf(quality / 3.0f);
}

/* Update prev-OFI and prev-mid in portfolio state for next bar's delta */
if (batch_states != NULL && state_dim >= 74) {
    const float* ofi_cur = batch_states + (long long)i * state_dim + 66;
    for (int k = 0; k < 8; k++) ps[30 + k] = ofi_cur[k];
}
ps[PREV_MID_SLOT] = tgt[5];     /* MBP-10 mid-price at this decision point */
ps[PREV_CLOSE_SLOT] = raw_close; /* for DSR actual per-bar returns */
```

**PREV_MID_SLOT:** Use slot 22 (currently reserved). Define `#define PREV_MID_SLOT 22`.
**PREV_CLOSE_SLOT:** Use slot 6 (currently reserved). Define `#define PREV_CLOSE_SLOT 6`.

**MFT mark-to-market:** The micro-reward uses MBP-10 mid-prices at bar BOUNDARIES (imbalance threshold crossings) — the model's actual decision points. This is the TRUE mark-to-market for MFT-style trading, not bar OHLCV. Each imbalance bar represents a burst of directional order flow; the mid-price at formation is where the model would actually execute.

**Note:** `batch_states` must be passed to `env_step_batch` as a new kernel parameter. Currently the kernel receives `features` (42-dim market features) but NOT the full `batch_states` (96-dim with OFI). Add `const float* __restrict__ batch_states` and `int state_dim` parameters.

### Phase D: Rank Normalization Fix

**Problem:** `reward_scatter_rank` zeroes all rewards with `|sharpe| < 0.001`. With `std_ema` dominated by sparse trade P&L (~1.0), micro-rewards at 0.1 scale have `|sharpe| ≈ 0.1/1.0 = 0.1` — above threshold. But if `std_ema` grows large (>100), micro-rewards fall below threshold.

**Fix:** Dynamic threshold proportional to micro_reward_scale:
```c
float is_trade_threshold = fminf(0.001f, micro_reward_scale * 0.01f);
int is_trade = (fabsf(sharpe) > is_trade_threshold) ? 1 : 0;
```

Or simpler: lower threshold to `1e-6f` (only filter true zeros). The original 0.001 threshold was for when rewards were only at trade exits — with dense micro-rewards, every positioned bar has a non-zero reward.

**Recommendation:** Lower to `1e-5f`. This preserves the zero-filtering for truly flat bars while passing all micro-rewards through.

### Phase D2: Novel Features — Pearls

**Pearl 1: Bar Duration Encoding in Mamba2**

Imbalance bars are variable-length. 8 bars spanning 30 seconds = volatile regime shift. 8 bars over 2 hours = calm accumulation. Encode `bar_duration_seconds` as a feature alongside the OFI embed in the Mamba2 history input.

- Precompute bar duration during fxcache creation: `duration = timestamp[bar] - timestamp[bar-1]`
- Store as a normalized feature in the state vector (log-scale, clipped): `log_duration = log(max(duration_secs, 0.1)) / 10.0`
- Add to state layout at index 83 (within existing 96-dim padding)
- Mamba2 input becomes `[h_s2(SH2); ofi_embed(8); log_duration(1)]` = SH2+9

The SSM's forget gate learns duration-dependent decay — short bars maintain state (fast regime), long bars decay state (calm, previous patterns less relevant). This is a continuous-time SSM for free.

**Pearl 2: Order Book Center of Mass (Book Aggression)**

Precompute from all 10 MBP-10 depth levels during fxcache creation:
```python
buy_com = sum(level_i * bid_depth_i for i in 1..10) / sum(bid_depth_i)
sell_com = sum(level_i * ask_depth_i for i in 1..10) / sum(ask_depth_i)
book_aggression = sell_com - buy_com  # positive = aggressive buying (pressure near L1)
```

- Store as feature at state index 82
- Used directly in micro-reward formula (see Phase C above)
- Also feeds into OFI embed MLP: extend input from 16-dim to 18-dim (add book_aggression + log_duration)

**Pearl 3: Retrospective Hold Quality Bonus**

At bar t, evaluate whether the PREVIOUS bar's Hold was correct using mid-price change between decision points. Implemented directly in the micro-reward formula (see Phase C above). Teaches Mamba2 which temporal patterns precede profitable holds.

**State vector layout (final):**
```
[0..42)   market features (OHLCV + technicals)
[42..50)  portfolio features (position, equity, etc.)
[50..66)  multi-timeframe features
[66..74)  OFI raw (8 features from MBP-10)
[74..82)  OFI delta (8 pre-computed temporal derivatives)
[82]      book_aggression (Pearl 2: order book center of mass)
[83]      log_bar_duration (Pearl 1: imbalance bar formation time)
[84..96)  padding (12 slots free for future)
```

This fits within the existing 96-dim padded state. No state_dim change needed. Mamba2 input widens from SH2+10 to SH2+10 (ofi_embed(8) + book_aggression(1) + log_duration(1)).

### Phase E: OFI Momentum Embedding MLP

**Purpose:** Compress [raw_ofi(8) ; delta_ofi(8) ; book_aggression(1) ; log_duration(1)] = 18-dim into 10-dim learned representation (8 for OFI embed + 2 for book/duration pass-through, or learned 10-dim compression).

**Implementation:** cuBLAS SGEMM `[10, 18] × [18, B] → [10, B]` + elementwise bias+ReLU.
- Same pattern as `trade_plan_forward` (already proven at 45ms/step)
- 190 trainable params (18×10 + 10 bias). Xavier init.
- Runs during training forward pass, NOT during experience collection
- Must be in graph-captured CUmodule (same isolation as other forward ops)

**Input construction kernel:** `ofi_embed_build_input`
- Reads `save_h_s2` (which contains the state after feature preprocessing)
- Extracts OFI features at state_dim offset 66..74 and computes deltas from a stored previous-bar buffer
- Writes 16-dim input [raw_ofi(8) ; delta_ofi(8)] to scratch buffer
- Grid: ceil(B/256), Block: 256

**Note on delta computation in training:** During training, the "previous bar" OFI for delta computation is NOT available per-sample (samples are drawn from replay buffer, different episodes/times). Instead, the delta should be pre-computed during experience collection and stored in the state vector, or the embedding MLP should learn to work with raw OFI only (no delta) during training. 

**Revised approach:** Pre-compute OFI deltas during experience collection (in `env_step_batch`) and store them in the state vector at indices 74-81 (expanding the OFI region from 8 to 16 features). Then the training forward pass reads the pre-computed deltas directly from `states_buf`. This avoids the "no previous bar" problem during training.

**State vector layout change:**
```
Current: [0..42) market | [42..50) portfolio | [50..66) MTF | [66..74) OFI_raw | [74..96) padding
New:     [0..42) market | [42..50) portfolio | [50..66) MTF | [66..74) OFI_raw | [74..82) OFI_delta | [82..96) padding
```

This fits within the existing 96-dim padded state. No state_dim change needed.

### Phase F: Mamba2 History Enrichment

**Current:** `h_history[B, K, SH2]` where SH2=256.
**New:** `h_history[B, K, SH2+10]` — OFI embed (8-dim) appended per timestep.

**Changes required:**
1. `mamba2_update_history` kernel: add `ofi_embed` pointer param, copy `[h_s2(SH2); ofi_embed(8)]` per sample
2. `mamba2_h_history` buffer: reallocate as `B × K × (SH2+10)`
3. W_A, W_B parameter buffers: grow from `[SH2, STATE_D]` to `[SH2+10, STATE_D]`
4. Mamba2 projection GEMM descriptors: destroy and recreate with k=SH2+10
5. `mamba2_scan_projected_fwd/bwd`: these operate on pre-projected values (A_proj, B_proj) — unchanged, STATE_D stays 16

### Phase G: Mamba2 Backward — Add d_h_history Gradient

**Problem:** Current `mamba2_backward()` computes dW_A, dW_B, dW_C but NOT d_h_history. Without this, the OFI embed MLP gets zero gradient from the Mamba2 path.

**Fix:** After the existing dW GEMMs, add:
```
d_h_history[SH2+10, B*K] = W_A^T @ d_gate + W_B^T @ d_x
```
This is 2 additional cuBLAS GEMMs:
- `d_h_from_A = W_A[SH2+10, STATE_D] @ d_gate[STATE_D, B*K]` → TRANSA=N, TRANSB=N
- `d_h_from_B = W_B[SH2+10, STATE_D] @ d_x[STATE_D, B*K]` → TRANSA=N, TRANSB=N
- `d_h_history = d_h_from_A + d_h_from_B` (elementwise add kernel)

Then extract the last 8 dims of d_h_history as `d_ofi_embed_from_mamba2`, and accumulate across K timesteps (sum reduce).

**New buffers:**
- `d_h_history[SH2+10, B*K]` — scratch for d_h gradient
- `d_ofi_embed_mamba2[8, B]` — accumulated OFI embed gradient from Mamba2

### Phase H: Attention Input Enrichment + Backward Gradient Exposure

**Current:** Attention input is `saved_input = h_s2[B, D=256]`.
**New:** Attention input is `[h_s2; ofi_embed] = [B, D+10=264]`.

**Forward changes:**
- W_Q, W_K, W_V: input dimension grows from D to D+10. Matrices become `[D, D+10]`.
- `saved_input` buffer grows from `[D, B]` to `[D+10, B]`
- All 4 forward GEMM descriptors (Q/K/V/O): W_Q/K/V get k=D+10, W_O stays k=D
- Layer norm input: stays D (operates on projected+residual, not raw input)

**Backward changes:**
- dW_Q/K/V GEMMs: k dimension of right-hand side changes from D to D+10
- `d_input_scratch` grows from `[D, B]` to `[D+10, B]`
- **NEW:** After `backward()` completes, expose `d_input_scratch` via a public accessor
- The caller (fused_training.rs) extracts `d_input_scratch[D..D+10, :]` as `d_ofi_embed_from_attn`

### Phase I: OFI Embed MLP Backward

**Gradient sources:**
1. `d_ofi_embed_mamba2[8, B]` — from Phase G
2. `d_ofi_embed_from_attn[8, B]` — from Phase H

**Total gradient:** `d_ofi_embed = d_ofi_embed_mamba2 + d_ofi_embed_from_attn` (elementwise add)

**MLP backward (16→8, single layer):**
- `dW = d_ofi_embed^T @ ofi_input` — cuBLAS GEMM [8,B] × [B,16] → [8,16]
- `db = sum_B(d_ofi_embed)` — 2-phase shared-memory reduce
- No dx needed (input is frozen features from state vector)
- Adam update on W_ofi params (136 params, shared with main Adam)

### Phase J: PopArt Interaction

**Concern:** Dense micro-rewards (scale 0.1) mixed with sparse trade rewards (0.5-5.0) create a bimodal distribution. PopArt's running mean/var will be pulled toward the dense small-value mode.

**Mitigation:** PopArt uses Welford online update which naturally handles mixed scales. The variance estimate will be dominated by the larger trade rewards (since variance = E[X²] - E[X]²), keeping the normalization appropriate for the trade signal. Micro-rewards will be normalized to ~0.02-0.05 in PopArt units — small but non-zero, exactly the desired dense gradient signal.

No code change needed — PopArt handles this correctly by design.

### Performance Impact

| Operation | Time | Note |
|-----------|------|------|
| OFI delta (in env_step) | ~0 | Elementwise, absorbed into existing kernel |
| OFI embed build input | 0.01ms | Elementwise kernel |
| OFI embed GEMM (16→8) | 0.05ms | cuBLAS, tiny matrices |
| Mamba2 proj GEMMs (k+8) | +0.02ms | Slightly wider k |
| Attention Q/K/V GEMMs (k+8) | +0.02ms | Slightly wider k |
| Mamba2 d_h_history (2 GEMMs) | +0.1ms | New backward GEMMs |
| Attention d_input extract | ~0 | Pointer arithmetic |
| OFI embed backward GEMM | 0.05ms | cuBLAS, tiny |
| **Total per-step** | **+0.25ms** | 45ms → 45.25ms (<1% regression) |

### VRAM Impact

| Buffer | Size | Note |
|--------|------|------|
| ofi_embed_buf [8, B] | 0.5MB | Forward scratch |
| ofi_input_buf [16, B] | 1MB | Forward scratch |
| d_h_history [SH2+10, B*K] | 17MB | Backward scratch |
| d_ofi_embed [8, B] × 2 | 1MB | Gradient accumulators |
| h_history growth (+8 per K step) | +2MB | Existing buffer wider |
| W_A, W_B growth (+8 rows) | +1KB | Params |
| W_Q/K/V growth (+8 cols) | +24KB | Params |
| W_ofi [8, 16] + bias | 0.5KB | New params |
| saved_input growth (+8) | +0.5MB | Attention buffer |
| **Total** | **~22MB** | 0.06% of 38GB free |

### Files Modified

| File | Change |
|------|--------|
| `experience_kernels.cu` | PORTFOLIO_STRIDE=38, micro-reward body with tgt[4]/tgt[5], OFI delta storage, batch_states param |
| `gpu_experience_collector.rs` | Wire ofi_gpu into state_gather, pass batch_states to env_step |
| `gpu_dqn_trainer.rs` | Fix concat_ofi indices 42→66, OFI embed MLP, wider Mamba2 params, d_h_history GEMMs |
| `mamba2_temporal_kernel.cu` | Update history kernel to copy SH2+10 dims with ofi_embed param |
| `gpu_attention.rs` | Wider W_Q/K/V (k=D+10), expose d_input_scratch, wider saved_input |
| `batched_forward.rs` | Concatenate ofi_embed to attention input |
| `batched_backward.rs` | Wider backward dimensions, extract d_ofi_embed |
| `fused_training.rs` | OFI embed forward/backward calls, gradient accumulation |
| `reward_shaping_kernel.cu` | Lower is_trade threshold to 1e-5 |
| `dqn-production.toml` | micro_reward_scale = 0.1 |
| `constructor.rs` | State layout docs, OFI delta indices 74-81 |
| `fxcache.rs` | target_dim 4→6, FXCACHE_VERSION 2→3, add raw_open + mid_price_open |
| `data_loading.rs` | Populate tgt[4]=raw_open, tgt[5]=mid_price_open from OHLCV + MBP-10 |
| `precompute_features` binary | Rebuild fxcache with 6-slot targets (auto via ensure-fxcache Argo step) |
| `train_baseline_rl.rs` / `evaluate_baseline.rs` | Update TARGET_DIM constant from 4 to 6 |

### fxcache Rebuild

The Argo `ensure-fxcache` step already handles automatic cache regeneration:
1. `precompute_features` checks `FXCACHE_VERSION` in the existing cache header
2. If version mismatch (2 ≠ 3), the step deletes old cache and regenerates
3. No manual intervention needed — just bump `FXCACHE_VERSION` from 2 to 3 in `fxcache.rs`
4. First training run after the code change automatically rebuilds the cache (~5-10 min on CPU node)

### Config Changes

```toml
[reward]
micro_reward_scale = 0.1  # was 0.0 (disabled)
```

### Success Criteria

1. Smoke tests 20/20 pass
2. Per-step time stays <50ms
3. OFI features verified non-zero in training (log check)
4. Atom utilization 40-60%
5. **Trade count drops below 500K** (model learns hold timing from dense signal)
6. **Val_Sharpe improves to > -200** within 50 epochs
7. Gradient through OFI embed MLP is non-zero (grad_norm check)
8. Mamba2 d_h_history gradient flows (not all-zero)

### Phase K: 4th Direction — Hold Action (b0_size 3→4)

**Problem:** Direction branch has 3 options: Short(-1), Flat(0), Long(+1). Flat means "close position to zero" — there's no "keep current position unchanged" option. The model is forced to actively re-decide every bar, biasing toward action. Every action except Flat has transaction cost.

**Fix:** Add Hold as a 4th direction. Action space: `direction(4) × magnitude(3) × order(3) × urgency(3) = 108` (was 81).

| Direction | idx | Position Effect | Cost |
|-----------|-----|-----------------|------|
| Short | 0 | Open/maintain short | Spread + commission |
| Hold | 1 | Keep current position unchanged | **Zero** |
| Long | 2 | Open/maintain long | Spread + commission |
| Flat | 3 | Close position to zero | Spread + commission |

**Changes required:**
1. `config`: `branch_0_size` from 3 to 4 in production config
2. `trade_physics.cuh`: `compute_target_position_4branch` — Hold (dir_idx=1) returns current position unchanged
3. `experience_kernels.cu`: `env_step_batch` — Hold action skips trade execution, zero tx cost
4. `gpu_action_selector.rs`: epsilon-greedy handles 4 directions. Action encoding: `dir*27 + mag*9 + ord*3 + urg` (was `dir*27`, now dir range 0-3)
5. `gpu_dqn_trainer.rs`: direction branch head output changes from 3 to 4 (affects w_b0 weight tensor)
6. Counterfactual: direction mirror for 4 actions — mirror of Short(0) is Long(2), mirror of Hold(1) is Hold(1), mirror of Flat(3) is Flat(3)
7. `evaluate_baseline.rs`: backtest action decoding for 4 directions
8. IQL: per-branch advantage now has 4 direction values
9. `ExposureLevel`: needs Hold variant

**Synergy with dense micro-reward:** Hold + micro-reward creates a natural feedback loop:
- Micro-reward says "this position has positive OFI quality" → Hold (zero cost)
- Micro-reward says "negative quality" → Flat (close, pay spread once)
- Without Hold, the model must re-choose Long/Short every bar, paying spread implicitly through magnitude re-evaluation

### Phase L: Fix DSR Sharpe EMA (always zero)

**Problem:** `price_change_dsr = 0.0f` at line 1733 of experience_kernels.cu. The running Sharpe estimate `ps[DSR_A_SLOT]` is permanently zero, making the adaptive cost tolerance always 0.1 (floor).

**Fix:** Compute actual per-bar return using stored prev_close:
```c
float prev_close_val = ps[PREV_CLOSE_SLOT];  /* slot 6 */
float price_change_dsr = (prev_close_val > 0.0f)
    ? (raw_close - prev_close_val) / prev_close_val
    : 0.0f;
```
Store `ps[PREV_CLOSE_SLOT] = raw_close` after DSR update (already in the spec for micro-reward).

### Phase M: Fix Counterfactual Magnitude/Order Sign with do_flip

**Problem:** We fixed `cf_cycle==0` (direction) for do_flip interaction. But `cf_cycle==1` (magnitude) does `cf_reward = reward * (alt_mag / actual_mag)` and `cf_cycle==2` (order) does `cf_reward = reward + cost_delta`. When `do_flip=true`, `reward` is already negated. Both magnitude scaling and order cost addition preserve the wrong sign.

**Fix:**
```c
// cf_cycle==1 (magnitude): use original reward sign
float base_reward = do_flip ? -reward : reward;  /* undo flip for CF base */
cf_reward = base_reward * (alt_mag_frac / actual_mag_frac);
if (do_flip) cf_reward = -cf_reward;  /* re-apply flip */

// cf_cycle==2 (order): same treatment
float base_reward_ord = do_flip ? -reward : reward;
cf_reward = base_reward_ord + cost_delta_scaled;
if (do_flip) cf_reward = -cf_reward;
```

Or more simply: compute counterfactual reward from the PRE-FLIP reward, then apply flip at the end.

### Non-Goals

- No new exploration mechanisms beyond Hold
- No hyperopt integration (fixed micro_reward_scale=0.1 initially)
- No changes to C51 atom support (PopArt handles scaling)
- No walk-forward window changes (separate investigation)
