# ISV-Driven Stop Controller — Design Spec

**Status:** Active
**Supersedes:** `2026-05-19-cbsw-cold-start-aggregator-design.md` (falsified — see §1).

---

## 1. Background

The deployability-sweep parallelism work (P1–P6) shipped 140-variant cluster
smokes that produced `n_trades = 0` despite p70/p80/p90 convictions of
0.66/0.77/0.87. Two diagnostic cycles were run:

* **Cycle A — convictions-log analysis** concluded that
  `decision_policy_default`'s linear-weighted-mean aggregator structurally
  diluted single-horizon strong signals (final position size
  `≤ max_h |ss[h]|` under normalised weights). Produced the CBSW spec
  + plan + Q1 stopgap commit `fef593955` (max-confidence bytecode
  upload path).

* **Cycle B — local repro on RTX 3050 (this spec)** falsified
  Cycle A. With the Q1 stopgap active, p=0.8 cold-start produced
  `market_target = (0, 1)` correctly on single-step and the multi-step
  repro showed `position_lots=199 trades_closed=0` after 200 events of
  persistent-bullish alpha. The bytecode VM was firing; the
  conviction-aggregation diagnosis was orthogonal to the actual problem.

The real root cause is the combination of two latent issues in main:

1. **`StopRules::default()` is all-zeros** — stop_loss_ticks=0,
   take_profit_ticks=0, max_hold_ns=0, all disabled. No kernel currently
   reads these fields anyway; the struct is pure dead data. With
   persistently-directional alpha and no counter-signal mechanism,
   positions open once and never close.
   `n_trades` (counted at `pnl_track.cu:61` as `prev_size != 0 && now_size == 0`
   closed round-trips) stays at zero.

2. **`seed_inflight_limits_batched` (resting_orders.cu:439–475)
   treats `market_targets[b]` as additive lots, not target position**
   — the kernel seeds a fresh limit order for `size_remaining = (float)size`
   every event without consulting `pos.position_lots`. The local repro
   showed `position_lots=199` after 200 events of "go long 1 lot."
   Even with a working stop controller, this bug would cap-violate
   within seconds.

The CBSW spec/plan and Q1 stopgap commit are therefore obsolete and
removed atomically by this spec (per
`feedback_single_source_of_truth_no_duplicates.md` +
`feedback_no_partial_refactor.md`).

## 2. Goal

Replace `StopRules` with an ISV-driven stop controller that runs inside
the existing decision kernel(s), and fix the order-target semantics so
the controller's `|pos.position_lots| ≤ max_lots` invariant holds.
Validation: same Q1 cluster smoke YAML (threshold=0, cost=0.125,
latency=200ms, 2M events) must pass `n_trades ≥ 100`,
`mean_trade_hold_time_ns ∈ (0, 30s)`, `∀b: |pos.position_lots| ≤ max_lots`.

No tuned constants. Bounds derive from per-horizon
`pnl_ema_win / pnl_ema_loss` (already in `IsvKellyState`) and a new
per-backtest ATR EMA on mid-price. Per
`feedback_isv_for_adaptive_bounds.md`,
`pearl_controller_anchors_isv_driven.md`,
`pearl_trail_stop_signal_driven.md`.

## 3. Architecture

Stop logic lives in a `__device__` helper function `stop_check_isv()`
called at the **top of the per-backtest dispatch** in both
`decision_policy_default` and `decision_policy_program`. Single source
of truth, statically guaranteed identical between the two kernels (the
`bytecode_and_default_kernel_agree_on_stops` test in §9.1 enforces this).
No separate `stop_check` kernel; no parallel implementation.

Per backtest, per event:

1. If `pos.position_lots == 0` → fall through to existing entry logic
   (unchanged from main). Stop check is a no-op when flat — there is
   nothing to stop.
2. Else: load open-position context (`pos.vwap_entry`,
   `pos.position_lots`, `open_horizon_masks[b]`), current best-bid/ask,
   per-horizon `pnl_ema_win` and `pnl_ema_loss` for the open
   horizons, per-backtest `atr_mid_ema[b]`. Check hard-SL and
   trail-TP triggers. If either fires, write
   `market_targets[b] = (3, 0)` (the "force flat" target under the
   §7 target-position semantics) and **return**, skipping the VM/sizing
   logic. If neither fires, fall through to the VM (alpha may decide
   to grow / flip / hold).

The decision kernel remains the sole writer of `market_targets`. The
stop close is realised through the same `seed_inflight_limits_batched`
→ resting-orders fill → cost-debit → `pnl_track_step` close-emission
pipeline as alpha-driven trades, so fees and latency are honest. No
side-channel close path; no synthetic fee bypass.

**GPU contract**: `stop_check_isv()` is pure-device — no host
branches, no scalar arg changes mid-kernel, no pointer swaps — so the
P5 CUDA Graph capture of `step_decision_with_latency` remains valid
(per `pearl_no_host_branches_in_captured_graph.md` +
`pearl_cudarc_disable_event_tracking_for_graph_capture.md`).

**Trail-mag is pre-action** (per `pearl_trail_fire_pre_vs_action_mag.md`):
the stop check runs BEFORE the VM modifies anything for this event, so
`pos.position_lots` it reads is the pre-decision magnitude. Trail
ratchets and fires against pre-action position, not the post-VM
intended position.

## 4. New State Slots

Three per-backtest `CudaSlice<f32>` slots, allocated in
`LobSimCuda::new()` as `alloc_zeros` (sentinel 0):

| Slot | Type | Updated by | Read by | Purpose |
|---|---|---|---|---|
| `prev_mid_d` | `CudaSlice<f32>` `[n_backtests]` | `apply_snapshot_kernel` end-of-step | `apply_snapshot_kernel` start-of-step | Δmid computation across consecutive snapshots |
| `atr_mid_ema_d` | `CudaSlice<f32>` `[n_backtests]` | `apply_snapshot_kernel`: Wiener-α=0.4 EMA with first-observation bootstrap | `step_decision*` stop-check (read-only) | Permanent floor for SL/trail distance (microstructure proxy) |
| `trail_hwm_d` | `CudaSlice<f32>` `[n_backtests]` | `step_decision*` stop-check ratchets each event while position open; `pnl_track_step` resets to 0 on close transition | `step_decision*` stop-check | Per-position high-water-mark of unrealized-P&L-per-lot |

No new fields on `IsvKellyState`. ATR is per-backtest (mid-price
volatility is a market property, not a strategy-horizon property).
Trail HWM is per-backtest because v1 has at most one open position
per backtest.

Combined memory: 12 bytes/backtest. Negligible.

### 4.1 Kernel arg additions

Explicit list — every kernel-launch site that touches one of the new
slots must be updated atomically (per `feedback_no_partial_refactor.md`):

| Kernel | New args (appended) | Reason |
|---|---|---|
| `apply_snapshot_kernel` (book_update.cu) | `float* prev_mid`, `float* atr_mid_ema` | Per-event ATR update |
| `decision_policy_default` (decision_policy.cu) | `const float* atr_mid_ema`, `float* trail_hwm`, `const float* bid_px`, `const float* ask_px` (latter two if not already present) | Inputs to `stop_check_isv()` |
| `decision_policy_program` (decision_policy.cu) | same as above | same |
| `pnl_track_step` (pnl_track.cu) | `float* trail_hwm` | Reset on close transition |
| `seed_inflight_limits_batched` (resting_orders.cu) | none — kernel reads `positions[b]` already (passed as `Pos*`) for the §7 target-delta computation | n/a |

All other kernels (resting_orders_step, seed_limit_slot, etc.) are
unchanged.

## 5. Controller Math

At the top of each decision-kernel per-backtest dispatch, when
`pos.position_lots != 0`:

```c
const float atr  = atr_mid_ema[b];

// Open-horizon EMAs. open_horizon_masks[b] is stamped on entry by the
// SAME decision kernel (decision_policy_default + OP_WRITE_ORDER) when
// pos transitions from 0 to non-zero.
const uint mask  = open_horizon_masks[b];
float ema_loss   = 0.0f;
float ema_win    = 0.0f;
float var_avg    = 0.0f;
int   n_open     = 0;
#pragma unroll
for (int h = 0; h < N_HORIZONS; ++h) {
    if (mask & (1u << h)) {
        ema_loss += isv[h].pnl_ema_loss;
        ema_win  += isv[h].pnl_ema_win;
        var_avg  += isv[h].realised_return_var;
        n_open   += 1;
    }
}
if (n_open > 0) {
    ema_loss /= (float)n_open;
    ema_win  /= (float)n_open;
    var_avg  /= (float)n_open;
}

// Trade-level vol floor (§12 amendment): per pearl_trade_level_vol_for_stop_distance.md.
// cost appears exactly once — as a bootstrap sentinel for var (cost² → cost at cold-start).
// Post-bootstrap sqrt(var_avg) dominates. No literal distance multiplier.
const float cost      = cost_per_lot_per_side[b];
const float trade_vol = sqrtf(fmaxf(var_avg, cost * cost));

// pearl_blend_formulas_must_have_permanent_floor.md — max, not blend.
// Triple-max: ema, atr, and trade-level vol floor.
const float sl_distance    = fmaxf(fmaxf(ema_loss, atr), trade_vol);
const float trail_distance = fmaxf(fmaxf(ema_win,  atr), trade_vol);

const bool   is_long = pos.position_lots > 0;
const float  close_px = is_long ? bid_px[0] : ask_px[0];
const float  entry_px = pos.vwap_entry;
const float  unrealized_pl_per_lot =
    is_long ? (close_px - entry_px) : (entry_px - close_px);

// Hard SL.
const bool sl_fired = unrealized_pl_per_lot <= -sl_distance;

// Trail TP with arming threshold.
const float new_hwm = fmaxf(trail_hwm[b], unrealized_pl_per_lot);
trail_hwm[b] = new_hwm;
const bool trail_fired =
       (new_hwm > trail_distance)
    && (unrealized_pl_per_lot <= new_hwm - trail_distance);

if (sl_fired || trail_fired) {
    // §7 target-position semantics: side=3 abs_sz=0 means "force flat";
    // seed_inflight_limits_batched computes delta = 0 - pos.position_lots
    // and seeds exactly |pos.position_lots| lots on the opposite side.
    // side=2 (preserved entry-time "no-op = leave position alone") is
    // semantically distinct — stops MUST use side=3 to override an
    // entry no-op decision on the same event.
    market_targets[b*2 + 0] = 3;
    market_targets[b*2 + 1] = 0;
    // open_horizon_masks left alone — its lifecycle is managed by the
    // existing entry/close wiring (set by OP_WRITE_ORDER on 0→nonzero
    // entry transition; the pre-existing closed_horizon_mask path
    // routes it into isv_kelly_update_on_close on close).
    return;
}
// Fall through to existing VM / default-sizing logic.
```

Asymmetry between hard-SL and trail-TP comes naturally from
`pnl_ema_loss >= pnl_ema_win` in profitable strategies (loss tail is
typically larger) — no explicit multiplier needed. Matches
`pearl_audit_unboundedness_for_implicit_asymmetry.md` (preserve
behavioural asymmetry; do not impose symmetric bounds).

**Cold-start symmetry caveat**: at sentinel state
(`n_trades_seen == 0`, both EMAs at 0), `sl_distance == trail_distance == atr`
— SL and trail fire at identical adverse-move magnitudes. The
asymmetric behavior emerges only after enough closed trades for
`pnl_ema_loss > pnl_ema_win` to diverge. This is acceptable v1
behavior: at cold-start the floor IS the controller, and the floor
is identical for both gates because there is no learned asymmetry yet
to preserve.

Multi-horizon mask handling: arithmetic mean across set bits. For
MaxConfidence aggregator (single bit) this reduces to the lookup of
one horizon's EMAs. For Mean / WeightedSharpe (multi-bit) it averages.
No bit-pick-first, no bit-pick-max — both would be observation-locked
shortcuts.

## 6. ATR EMA Update

Inside `apply_snapshot_kernel`, after the book has been written:

```c
const float mid       = 0.5f * (best_bid_px[0] + best_ask_px[0]);
const float prev      = prev_mid[b];
if (prev == 0.0f) {
    // First-observation bootstrap (pearl_first_observation_bootstrap):
    // store the mid; do NOT touch atr_mid_ema (stays 0 until event 2).
    prev_mid[b]      = mid;
} else {
    const float delta = fabsf(mid - prev);
    if (atr_mid_ema[b] == 0.0f) {
        // Second observation (first non-zero delta): replace directly.
        atr_mid_ema[b] = delta;
    } else {
        // Wiener-α=0.4 EMA, no floor (atr already has its own floor
        // role downstream).
        atr_mid_ema[b] = 0.4f * delta + 0.6f * atr_mid_ema[b];
    }
    prev_mid[b]      = mid;
}
```

Cold-start sequence:

| Event | `prev_mid[b]` after | `atr_mid_ema[b]` after | Stop-check usable? |
|---|---|---|---|
| 0 | `mid₀` | 0 | No (position is also zero by definition on event 0). |
| 1 | `mid₁` | `\|mid₁ − mid₀\|` | Yes — `atr > 0` so SL/trail floors are positive. |
| 2+ | `mid_t` | 0.4·Δ + 0.6·prev_ema | Yes — steady-state Wiener. |

## 7. Position-Target Semantics Fix

The local repro showed `position_lots=199` after 200 events of "target
long 1 lot." `seed_inflight_limits_batched` (resting_orders.cu:439–475)
seeds a fresh limit order for `size_remaining = (float)size` every
event without consulting `pos.position_lots`. Fix scope-in per §1's
two-issue framing and `feedback_no_deferrals_for_complementary_fixes.md`.

Change: define the canonical four-value encoding of
`market_targets[b*2..b*2+2]`:

| `side` | `abs_sz` | `target_signed` (derived) | Writer | Meaning |
|---|---|---|---|---|
| 0 | `n ≥ 1` | `+n` | `OP_WRITE_ORDER` | "be long n lots" |
| 1 | `n ≥ 1` | `−n` | `OP_WRITE_ORDER` | "be short n lots" |
| 2 | 0 | n/a (skip) | `OP_WRITE_ORDER` when `lots == 0` | "no-op — alpha is too weak, leave position alone" (preserves the pre-spec semantics so weak-alpha events don't churn open positions) |
| 3 | 0 | 0 | `stop_check_isv()` | "force flat — override any concurrent intent" |

`seed_inflight_limits_batched` becomes the canonical consumer.
**Critical**: with non-zero latency, an order seeded at event T does
not fill until event T+K (K = latency / event_dt). Between submission
and fill, `pos.position_lots` is unchanged. A naive `delta = target_signed
− pos.position_lots` would re-seed the same order on every event,
producing `pos = pre + K·delta` after fills — strictly worse than
the original additive bug. The fix must compute `effective_position`
by summing pos plus all *unfilled* (active∈{1=resting, 2=in-flight})
slot signed-sizes:

```c
const int side  = market_targets[b * 2 + 0];
const int abs_sz = market_targets[b * 2 + 1];
if (side == 2) return;   // no-op: do nothing, preserves entry-time semantics

int target_signed;
if      (side == 0) target_signed = +abs_sz;
else if (side == 1) target_signed = -abs_sz;
else                target_signed = 0;   // side == 3: force flat

Orders* orders = reinterpret_cast<Orders*>(orders_base + (size_t)b * orders_bytes);

// Effective position = filled position + all unfilled signed lots.
int effective = positions[b].position_lots;
#pragma unroll
for (int s_idx = 0; s_idx < MAX_LIMITS; ++s_idx) {
    const LimitSlot& s = orders->limits[s_idx];
    if (s.active == 0) continue;   // free slot
    // active == 1 (resting) or 2 (in-flight) — not yet matched.
    const int slot_lots = (int)s.size_remaining;
    effective += (s.side == 0) ? slot_lots : -slot_lots;
}

const int delta = target_signed - effective;
if (delta == 0) return;   // already at target (counting pending orders)

const int order_lots = (delta > 0) ? delta : -delta;
const int order_side = (delta > 0) ? 0 : 1;
// ... existing slot-seeding code, but with size_remaining = order_lots
//     and side = order_side ...
```

v1 does **not** cancel existing in-flight orders even if the target
direction reverses. A flip-decision seeds an offsetting order; both
orders execute, net position matches target, but the offset path
pays extra fees. Production cancellation logic is deferred (§11).
The smoke validates that effective-position-at-rest equals target —
the over-fee path is honest, just not optimal.

Examples:

* Long 1, target=(0, 1): `target_signed=+1`, `delta=0` → no order.
* Long 1, target=(0, 3): `target_signed=+3`, `delta=+2` → buy 2 lots.
* Long 1, target=(1, 2): `target_signed=−2`, `delta=−3` → sell 3 (flip).
* Long 3, target=(3, 0): `target_signed=0`, `delta=−3` → sell 3 (flatten).
* Flat, target=(2, 0): early-return on side==2, no order.

**Kernel postcondition (eventual, not per-event)**: once a sequence of
events stabilizes (no new market_target changes and all in-flight orders
have filled — at least `latency_ns / event_dt_ns` events with the same
target), `pos.position_lots == target_signed`.

**Per-event invariant**: `effective_position` (computed in the loop
above) reaches `target_signed` within the same event the target is
written, even though `pos.position_lots` itself catches up later.

**Cap invariant**: `|pos.position_lots| ≤ max_lots` cannot be violated
as long as `OP_WRITE_ORDER`'s `abs_sz` clamp to `max_lots` holds (kernel
line 159: `effective_cap = fminf(leaf_max_lots, max_lots)`). Stop-fire
uses `(3, 0)` which targets 0 — strictly within cap.

### 7.1 `pnl_track_step` trail_hwm reset

When `pnl_track_step` detects a close transition
(`prev_size != 0 && now_size == 0`, the same branch that emits the
TradeRecord), it additionally writes `trail_hwm[b] = 0.0f`. This is
two extra instructions in the existing branch — no new branches,
no host involvement. Without this reset, the next entry would inherit
a stale HWM from the previous position, potentially arming the trail
on event 0 of the new trade.

## 8. Removed Code (Atomic with this Spec)

**Added** (atomic with removals — single commit per
`feedback_single_source_of_truth_no_duplicates.md`):

* `crates/ml-backtesting/cuda/decision_policy.cu` — new
  `__device__ stop_check_isv()` helper (Section 5 code); two callers
  in `decision_policy_default` and `decision_policy_program`.
* `crates/ml-backtesting/cuda/book_update.cu` — ATR update block
  (Section 6 code) at end of `apply_snapshot_kernel`.
* `crates/ml-backtesting/cuda/pnl_track.cu` — `trail_hwm[b] = 0.0f`
  in the close-transition branch (Section 7.1).
* `crates/ml-backtesting/cuda/resting_orders.cu` — target-delta
  computation in `seed_inflight_limits_batched` (Section 7).
* `crates/ml-backtesting/src/sim/mod.rs` — three new `CudaSlice<f32>`
  fields (`prev_mid_d`, `atr_mid_ema_d`, `trail_hwm_d`), allocation in
  `LobSimCuda::new`, args threaded through every kernel launch listed
  in §4.1.

**Removed**:

* `crates/ml-backtesting/src/policy/stop_rules.rs` — delete file.
* `crates/ml-backtesting/src/policy/mod.rs` — remove
  `pub use stop_rules::StopRules`, remove `sl_tp_rules: StopRules` field
  on `StrategyConfig`, remove all `sl_tp_rules: StopRules::default()`
  literals.
* `crates/ml-backtesting/src/harness.rs` — delete the entire Q1
  stopgap branch (lines 188–207), the
  `sim_config.use_cold_start_stopgap` propagation, and the
  `if !sim_config.use_cold_start_stopgap.get(b)...` guard on the
  legacy upload loop.
* `crates/ml-backtesting/src/sim/batched_config.rs` — remove the
  `use_cold_start_stopgap` field from `BatchedSimConfig`,
  `UniformSimParams`, `ResolvedSimVariant`.
* `bin/fxt-backtest/src/main.rs` — remove
  `use_cold_start_stopgap: Option<bool>` from `SweepBase.SimVariant`
  and the `.unwrap_or(false)` propagation.
* `config/ml/sweep_smoke.yaml` — remove `use_cold_start_stopgap: true`
  from the `t0c1l200_stopgap` variant; rename variant to
  `t0c1l200_isv_stops`.
* All test files — drop `use_cold_start_stopgap: false` from
  `UniformSimParams` literals.
* `docs/superpowers/specs/2026-05-19-cbsw-cold-start-aggregator-design.md`
  — append `**STATUS: SUPERSEDED** by
  `2026-05-19-isv-driven-stop-controller-design.md` (this file).
  Empirically falsified — see §1.` header. Do not delete (kept for
  audit trail per `feedback_trust_code_not_docs.md`).
* `docs/superpowers/plans/2026-05-19-cbsw-cold-start-aggregator.md`
  — same SUPERSEDED header.

The Q1 stopgap commit `fef593955` is NOT reverted in git — it stays
in branch history. The code surface is removed by this spec's
implementation commit.

## 9. Testing

Per `pearl_tests_must_prove_not_lock_observations.md` — assert
invariants and ranges, not observed values.

### 9.1 Unit tests (`crates/ml-backtesting/tests/`, `--ignored` CUDA-required)

| Test name | File | Asserts | Falsifies |
|---|---|---|---|
| `atr_ema_bootstrap_first_observation` | new `stop_controller.rs` | After 1 snapshot: `atr_mid_ema == 0`; after 2: `atr_mid_ema == \|Δmid\|`; after 200: `atr_mid_ema > 0`, finite, bounded by max observed Δ. | Zero-bias-warmup regression. |
| `sl_fires_when_unrealized_breaks_distance` | `stop_controller.rs` | Open long at mid=5500. Evolve mid down past `5500 − max(pnl_ema_loss, atr)`. Single event later: `market_target == (3, 0)` (force-flat encoding per §7); after enough events to drain in-flight orders, `n_trades_closed > 0` and `pos.position_lots == 0`. | SL gate-direction inversion; floor collapse; wrong force-flat encoding. |
| `trail_arms_then_fires` | `stop_controller.rs` | Open long, ratchet HWM past `trail_distance`, then drop unrealized by `trail_distance`: trail fires. Open long, evolve without clearing arming threshold: trail must NOT fire. | Premature trail-fire on entry-event microswings. |
| `multi_horizon_mask_averages_emas` | `stop_controller.rs` | `isv[0].pnl_ema_loss=2.0`, `isv[1].pnl_ema_loss=4.0`, `open_horizon_masks=0b11`, `atr=0`: stop fires at unrealized=-3.0, not -2.0 or -4.0. | Bit-pick-first / bit-pick-max regressions. |
| `position_target_not_additive` | `stop_controller.rs` | With latency=0: open long with target size 3. On next event the decision kernel writes target=3 again: position stays 3, not 6. After 10 events of "target 3": `position_lots == 3`. | The 199-lot accumulation bug with naive zero-latency assumption. |
| `position_target_not_additive_with_latency` | `stop_controller.rs` | With latency=200ms and event_dt=1ms: 200 events of target=(0, 3), checked at event 0, 50, 100, 199, and after a final 300-event drain (all orders filled). After drain: `pos.position_lots == 3`, NOT 600. Asserts effective-position-with-inflight gating. | The latency-bypass variant of the accumulation bug — the bug §7 actually fixes. |
| `cold_start_persistent_bullish_now_closes` | retarget existing `decision_floor_coldstart.rs` test of same name (currently named `..._never_closes`) | After 200 events of p=0.8 bullish + constant book: `n_trades_closed > 0` AND `\|pos.position_lots\| <= max_lots`. | The previous behaviour (cluster smoke's `n_trades=0`). |
| `bytecode_and_default_kernel_agree_on_stops` | `stop_controller.rs` | Same isv state + book + entry → both kernels produce identical `market_target` on the same event. | Single-source-of-truth violation between the two decision kernels. |
| `stop_check_skipped_when_flat` | `stop_controller.rs` | With `pos.position_lots == 0`, even with strong stop-trigger conditions (sentinel-zero `atr` plus zero-EMAs), the stop-check `__device__` helper does not write `market_target` — entry path runs through. | Stop check firing on flat → spurious force-flat overwriting alpha entries. |
| `alpha_noop_side_2_preserved` | `stop_controller.rs` | When `OP_WRITE_ORDER` writes `(2, 0)` (weak-alpha no-op) on an event where position is already long 3 and no stop fires, `seed_inflight_limits_batched` submits ZERO orders and `pos.position_lots` stays 3. | Force-flat (`side=3`) and no-op (`side=2`) collapsed to the same semantics — would churn open positions on every weak-alpha event. |
| `trail_hwm_reset_on_close` | `stop_controller.rs` | Open long, ratchet HWM to a high value (e.g. 5.0), force-flatten via the stop, observe `trail_hwm[b] == 0` after `pnl_track_step` runs, re-open long, observe trail does NOT fire on event 1 of the new trade (would have if stale HWM > trail_distance). | `pnl_track_step` reset miss — second trade arms trail prematurely. |

Each test MUST fail on the current `main`-branch code before any
controller implementation is written (TDD discipline per
`superpowers:writing-plans`). Failure modes count broadly:

* **Functional failure** — assertion trips on main's behavior
  (e.g. `cold_start_persistent_bullish_now_closes` asserts
  `n_trades_closed > 0`, main produces 0).
* **API failure** — test references a slot/method/encoding that does
  not exist on main (e.g. `LobSimCuda::read_trail_hwm()`, `side=3`).
  Compilation fails before any assertion runs. This counts.
* **Setup failure** — test setup writes to a slot that doesn't exist.

Regression-prevention tests (e.g. `alpha_noop_side_2_preserved` —
asserts main's side=2 early-return behavior is preserved post-spec)
satisfy the discipline via the API-failure path: the test depends on
the new `read_trail_hwm` / `read_atr_mid_ema` accessors to verify
that the no-op event did NOT touch those slots. If a test cannot
fail on current code via any of the three paths, it's
observation-locked — rewrite it.

### 9.2 Integration smoke

`config/ml/sweep_smoke.yaml` reused unchanged except for the variant
rename and the removal of the `use_cold_start_stopgap` field. Pass
criteria for the smoke (all three must hold simultaneously):

| Metric | Threshold | Reason |
|---|---|---|
| `n_trades` (closed round-trips) | `≥ 100` | Closure machinery proof. |
| `p95(trade_hold_time_ns)` | `< 600·10⁹` (600 s = 10 min) | Catches the long tail (e.g. 263985s gp74n pathology). Allows alpha-driven exit timing up to ~10 min — the median hold reflects natural alpha timing, not stop churning. Replaces the `mean < 30s` criterion from the pre-pearl baseline (which reflected sub-cost churn, not a feature). |
| `max(trade_hold_time_ns)` | `≤ max_hold_ns` configured in sim_variant | Force-close gate honored. |
| `max_b \|pos.position_lots\|` at smoke end | `≤ max_lots` | Position-target-semantics fix is honest. |

Cluster smoke is rerun only after all §9.1 unit tests pass locally
on RTX 3050 sm_86.

## 10. ISV Discipline Audit Checklist

Per `feedback_isv_for_adaptive_bounds.md` and
`pearl_controller_anchors_isv_driven.md` — every numeric anchor must
trace to an ISV-tracked signal, with `max(real, floor)` blending.

| Anchor | Source | Floor | Floor source |
|---|---|---|---|
| `sl_distance` | `pnl_ema_loss` (per-horizon, avg over open mask) | `atr_mid_ema` / `trade_vol` | mid-price Δ-EMA, Wiener-α=0.4 / `sqrt(realised_return_var)` avg with `cost²` bootstrap |
| `trail_distance` | `pnl_ema_win` (per-horizon, avg over open mask) | `atr_mid_ema` / `trade_vol` | same |
| Trail arming threshold | `trail_distance` (same signal) | — (uses real distance) | — |
| `trade_vol` | `sqrt(realised_return_var)` (avg over open mask) | `cost` (via `cost²` in `fmaxf`) | bootstrap sentinel only — not a distance multiplier |
| Wiener-α for ATR | 0.4 (fixed) | — | See note below |

**Honest note on `cost` usage**: `cost_per_lot_per_side` appears exactly once in the stop-distance
computation — inside `sqrtf(fmaxf(var_avg, cost * cost))` as a bootstrap sentinel. At cold-start
(`var_avg = 0`), `trade_vol = cost`, giving a structural minimum equal to the one-way fee (anything
smaller is sub-fee noise). After `MIN_TRADES_FOR_VAR_CAP` closes, `sqrt(var_avg)` dominates. The
literal `2.0f` distance multiplier from the first draft (§12) is eliminated; `cost` is never
used as a scaling multiplier.

**Honest note on ATR α**: `pearl_wiener_alpha_floor_for_nonstationary.md`
strictly applies to control loops where the target drifts via a
co-adapting policy. ATR-on-mid is not such a loop — it's a passive
observation of an exogenous, non-stationary price process. Using
α=0.4 here is a pragmatic choice: it's the non-stationary floor from
the same pearl, it matches the `isv_kelly_update_on_close` α (so the
controller has exactly one Wiener-α constant project-wide, not two),
and a Wiener-optimal α-estimator on |Δmid| would require two new
state slots (sample_var, diff_var) for marginal benefit. If a future
smoke shows ATR over- or under-tracking mid-vol, the upgrade path is
to add the estimator and replace the constant.

No other standalone constants. All thresholds derive from
`max(real, floor)` per
`pearl_blend_formulas_must_have_permanent_floor.md`.

## 11. Out of Scope + Pre-existing Wiring Assumptions

* **Per-direction asymmetric EMAs** (separate
  `pnl_ema_loss_long` / `pnl_ema_loss_short`). The current
  outcome-keyed `pnl_ema_loss` aggregates both directions; asymmetry
  comes geometrically from which side of `pos.vwap_entry` the stop
  sits, not from the magnitude. Splitting only matters if loss
  distributions diverge by direction — to be revisited if a future
  smoke shows long-vs-short directional bias.
* **Stop-controller hyperparameter sweep**. There are no
  hyperparameters to sweep. The smoke validates a single design
  point.
* **`pnl_track_step` direct-flip handling**. `pnl_track_step` today
  emits a close TradeRecord only on `prev != 0 && now == 0`
  transitions. With the new target-position semantics, a flip
  (e.g. long 3 → short 2 in one fill cycle, target=(1, 2)) goes
  `prev=+3, now=-2` — neither pure-open nor pure-close — so no
  TradeRecord is emitted. In the smoke configuration (MaxConfidence
  aggregator + persistent-directional convictions in ES futures) flips
  are not expected. If a future smoke shows flips, the fix is to split
  `pnl_track_step` into a two-phase emit (close prev, open new)
  inside the same call — orthogonal scope.

**Pre-existing wiring assumed correct** (not reverified by this spec,
trusting the P2/P3 deployability-sweep work):

* `open_horizon_masks[b]` is set by `OP_WRITE_ORDER` on the 0→nonzero
  entry transition and overwritten on the next entry. Stop check
  reads it during the position lifetime.
* `closed_horizon_mask` is populated by the existing close-detection
  wiring (P3 `detect_close_transitions_batched`) and consumed by
  `isv_kelly_update_on_close`. The stop close flows through this same
  path because it produces a real `prev≠0 → now=0` transition via
  `seed_inflight_limits_batched` → fill → `pnl_track_step`.
* `MIN_TRADES_FOR_VAR_CAP=10` gates `realised_return_var` use in the
  cap-lots computation. The stop controller also reads `realised_return_var`
  (as `var_avg`) but applies no such gate — at cold-start `var_avg=0` and
  `trade_vol=cost`, giving a structural floor. The gate is not required here
  because the bootstrap (`cost²`) already provides a safe cold-start value.

## 12. Amendment 2026-05-20 — trade-level vol floor (replaces draft 2×cost)

Initial cluster smoke (`lob-backtest-sweep-w7b4p`) revealed 81,178 closed
round-trips in 2M events at 200ms latency — mean wall-clock hold time
below the latency floor, physically impossible without sub-cost churn.
Root cause: `sl_distance ≈ max(0, atr=0.05) = 0.05`, but round-trip cost =
0.25. Every stop-out a guaranteed loss > 5× distance.

**First draft** (commit `da6e88717`) added `2.0f * cost` as a third arg to
the triple-max floor. This eliminated sub-cost stops but introduced a tuned
constant (`2.0f`) — violating `feedback_isv_for_adaptive_bounds.md`.

**Final formulation** per `pearl_trade_level_vol_for_stop_distance.md`:

```c
const float cost      = cost_per_lot_per_side[b];
const float trade_vol = sqrtf(fmaxf(var_avg, cost * cost));
const float sl_distance    = fmaxf(fmaxf(ema_loss, atr), trade_vol);
const float trail_distance = fmaxf(fmaxf(ema_win,  atr), trade_vol);
```

`var_avg` is `realised_return_var` averaged across `open_horizon_masks` bits
in the same loop as `ema_loss`/`ema_win`. Cost appears exactly once, inside
the sqrt's `fmaxf` as a bootstrap sentinel (`cost²` → `cost` at cold-start).
Post-bootstrap (after `MIN_TRADES_FOR_VAR_CAP=10` closes), `sqrt(var_avg)`
dominates — the controller learns its own scale from realized returns.
No hardcoded multipliers; the literal `2.0f` from the first draft is
eliminated.

Both decision kernel signatures include `cost_per_lot_per_side_per_b`
as a parameter after `threshold_per_b` (wired in the Task 12 commit;
unchanged here). The Rust launch sites thread `self.cost_per_lot_per_side_d`
at the matching position in both `step_decision_with_latency` launch builders.

Test: `trade_vol_floor_prevents_sub_cost_stops` validates Δ < `trade_vol`
does NOT fire SL, and Δ > `trade_vol` DOES fire, under cost=0.125 + var=0
cold-start (so `trade_vol = cost = 0.125`). Boundary points straddle 0.125:
no-fire Δ=0.08, fire Δ=0.20.

## 13. Amendment 2026-05-20 — three follow-ups from cluster smoke gp74n

The trade_vol pearl smoke produced n_trades=1024, mean_hold=820s with one
263,985s pathological hold (position held across a 3-day session boundary).
Three orthogonal issues:

### 13.1 Max-hold force-close
Added `max_hold_ns` as a per-backtest config (default 0 = disabled) in
`BatchedSimConfig`, `UniformSimParams`, and `ResolvedSimVariant`. Device
buffer `max_hold_ns_d` allocated in `LobSimCuda::new` and uploaded each
`step_decision_with_latency`. Both decision kernels receive `max_hold_ns_per_b`,
`current_ts_ns` (already a kernel param), and `open_trade_state_per_b` as
new arguments forwarded to `stop_check_isv`.

Inside `stop_check_isv`, BEFORE the SL/trail check, the controller fires
force-flat `(3, 0)` when `max_hold > 0 AND entry_ts > 0 AND
(current_ts_ns - entry_ts) >= max_hold`. Max-hold is the highest-priority
exit. `entry_ts` is read from `open_trade_state[b][0..8]` (maintained by
`pnl_track_step`). Tested via `max_hold_forces_close` (12th stop_controller
test): opens at t=1ms, max_hold=100ms; asserts no-fire at +50ms and fire at
+150ms. Sweep YAML sets `max_hold_ns: 60_000_000_000` (60s) to bound the
long tail.

### 13.2 Defensive `exit_px` sanity check
500/1024 trades in gp74n reported `exit_px = ±i32::MAX/100` (sentinel from
float→int saturation). Root cause is likely a NaN/Inf in `pos.realized_pnl`
from a boundary case in the fill kernel — to be traced separately. Defensive
fix in `pnl_track_step`: if computed `exit_px` is non-finite or diverges from
`entry_px` by more than 10%, write `entry_px` as fallback and zero the
`realised_pnl_usd_fp` field. Surfaces as zero-pnl trades instead of fake
±$21M losses in summary metrics.

### 13.3 Spec §9.2 criteria revision
Replaced `mean_trade_hold_time_ns < 30s` (calibrated to the pre-pearl
sub-cost-churning baseline) with:
- `p95(trade_hold_time_ns) < 600s`: catches long-tail pathology, allows
  alpha-driven exits up to ~10 min.
- `max(trade_hold_time_ns) <= max_hold_ns`: force-close gate honored.

The 30s threshold reflected the bug rate, not a feature.
