diff --git a/docs/superpowers/specs/2026-05-19-isv-driven-stop-controller-design.md b/docs/superpowers/specs/2026-05-19-isv-driven-stop-controller-design.md new file mode 100644 index 000000000..3f3a9e1d2 --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-isv-driven-stop-controller-design.md @@ -0,0 +1,353 @@ +# 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. **Order/match path treats `market_targets[b]` as additive lots, + not target position** — 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 at the **top of the per-backtest dispatch** in both +`decision_policy_default` and `decision_policy_program`. Single source +of truth; 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). +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] = (2, 0)` (the "be 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 resting-orders → fill → +cost-debit → `pnl_track_step` close-emission pipeline as alpha-driven +trades, so fees and latency are honest. + +## 4. New State Slots + +Three per-backtest `CudaSlice` slots, allocated in +`LobSimCuda::new()` as `alloc_zeros` (sentinel 0): + +| Slot | Type | Updated by | Read by | Purpose | +|---|---|---|---|---| +| `prev_mid_d` | `CudaSlice` `[n_backtests]` | `apply_snapshot_kernel` end-of-step | `apply_snapshot_kernel` start-of-step | Δmid computation across consecutive snapshots | +| `atr_mid_ema_d` | `CudaSlice` `[n_backtests]` | `apply_snapshot_kernel`: Wiener-α=0.4 EMA with first-observation bootstrap | `step_decision*` stop-check | Permanent floor for SL/trail distance (microstructure proxy) | +| `trail_hwm_d` | `CudaSlice` `[n_backtests]` | `step_decision*` stop-check (each event when position open); cleared by `pnl_track_step` 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. + +## 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; +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; + n_open += 1; + } +} +if (n_open > 0) { + ema_loss /= (float)n_open; + ema_win /= (float)n_open; +} + +// pearl_blend_formulas_must_have_permanent_floor.md — max, not blend. +const float sl_distance = fmaxf(ema_loss, atr); +const float trail_distance = fmaxf(ema_win, atr); + +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=2 abs_sz=0 means "be flat"; + // the order-kernel delta then becomes (0 - pos.position_lots), + // which fully flattens regardless of |position_lots|. + market_targets[b*2 + 0] = 2; + market_targets[b*2 + 1] = 0; + // open_horizon_masks is left alone — its lifecycle is managed by + // the existing entry/close wiring (set by OP_WRITE_ORDER on + // 0→nonzero entry; the pre-existing closed_horizon_mask path + // routes it into isv_kelly_update_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). + +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." The order/match path treats `market_targets[b]` as +additive. Fix scope-in per Section 1's two-issue framing and +`feedback_no_deferrals_for_complementary_fixes.md`. + +Change: define the canonical encoding of `market_targets[b*2..b*2+2]` as: + +| `side` | `abs_sz` | `target_signed` (derived) | Meaning | +|---|---|---|---| +| 0 | `n ≥ 0` | `+n` | "be long n lots" | +| 1 | `n ≥ 0` | `−n` | "be short n lots" | +| 2 | 0 | 0 | "be flat" | + +The order/match kernel (the consumer of `market_targets`) computes the +*delta order* as `order_lots = target_signed − pos.position_lots` +and submits exactly that many lots (positive ⇒ buy, negative ⇒ sell) +through the existing resting-orders/fill path. Examples: + +* Long 1 currently, target=(0, 1): `order_lots = +1 − +1 = 0` — no + order submitted, position stays at 1. +* Long 1 currently, target=(0, 3): `order_lots = +3 − +1 = +2` — buy + 2 lots. +* Long 1 currently, target=(1, 2): `order_lots = −2 − +1 = −3` — sell + 3 lots (flip). +* Long 3 currently, target=(2, 0): `order_lots = 0 − +3 = −3` — sell + 3 lots to flatten. *This is the stop-controller's flatten path.* + +`OP_WRITE_ORDER`'s entry writes `(side, abs_sz)` where `abs_sz` is the +*desired* position magnitude on the chosen side — unchanged from +today's intent, now correctly interpreted as a target rather than an +additive delta. The current `(2, 0)` "no-op" encoding becomes +"target flat" — semantically the same for entry (skip if already +flat → delta 0) but newly meaningful for exit (delta = −pos). + +## 8. Removed Code (Atomic with this Spec) + +* `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` 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 = (1, position_lots)`; after one resting-orders pass, `n_trades_closed > 0`. | SL gate-direction inversion; floor collapse. | +| `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` | 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. | +| `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. | + +Each test MUST fail on the current `main`-branch code before any +controller implementation is written (TDD discipline per +`superpowers:writing-plans`). If a test cannot fail on current code, +it's observation-locked, not invariant-asserting — 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 `n_trades=100` to validate the spec: + +| Metric | Threshold | Reason | +|---|---|---| +| `n_trades` (closed round-trips) | `≥ 100` | Closure machinery proof. | +| `mean_trade_hold_time_ns` | `> 0 AND < 30·10⁹` (30 s) | Excludes same-event close (over-tight floor) AND barely-ever-close (under-tight floor). | +| `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` | mid-price Δ-EMA, Wiener-α=0.4 | +| `trail_distance` | `pnl_ema_win` (per-horizon, avg over open mask) | `atr_mid_ema` | same | +| Trail arming threshold | `trail_distance` (same signal) | — (uses real distance) | — | +| Wiener-α for ATR | 0.4 | — | Matches `pearl_wiener_alpha_floor_for_nonstationary.md` non-stationary policy P&L floor; reused as policy-loop α to avoid introducing a second tuning knob | + +No standalone constants. The lone literal `0.4` is shared with the +existing `isv_kelly_update_on_close` alpha (same pearl rationale). + +## 11. Out of Scope + +* **`max_hold_ns`** time-bounded stops. Deferred to a follow-up if + empirically needed; would require a new per-horizon + `entry_to_close_ns_ema` field in `IsvKellyState`. +* **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.