spec(ml-backtesting): ISV-driven stop controller — supersedes falsified CBSW

Replaces StopRules::default()-all-zeros (the actual cause of the cluster
smoke's n_trades=0, falsifying the CBSW dilution diagnosis) with an
ISV-driven SL + trail-stop controller folded into the existing decision
kernel(s). Per-backtest ATR EMA on mid-price + per-horizon pnl_ema_*
drive distances under max(real, floor) per
pearl_blend_formulas_must_have_permanent_floor. No new kernel; single
source of truth in step_decision*. Folds in the position-target
semantics fix (200-event local repro showed position_lots=199 from
additive-vs-target order semantics) — same commit.

Removes: StopRules, sl_tp_rules, Q1 stopgap field/branch,
use_cold_start_stopgap propagation. Marks CBSW spec + plan SUPERSEDED.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-19 23:06:37 +02:00
parent ef831ba598
commit 11d1279990

View File

@@ -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 (P1P6) 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<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 | Permanent floor for SL/trail distance (microstructure proxy) |
| `trail_hwm_d` | `CudaSlice<f32>` `[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 188207), 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 = (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.