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:
@@ -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<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 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 = (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.
|
||||
Reference in New Issue
Block a user