# KSP-1 — the kill-switch day erases the sizer's memory of the crash: the crash holding period never reaches the Kelly ledger (the validated engine books it), and the risk-off flag every later vol estimate trusts is recorded intent, not the observed book

status: open · raised 2026-09-05 (external design review r2, finding 2 + gap 9; maintainer-verified by reading, magnitude reproduced independently through the production pool) · class: backtest/live parity + sizing (live path — the P0 shape: validated ≠ deployed exactly when it matters) · judgement: **YES** · effort ~0.5 pd incl. tests · horizon: latent until the first kill-switch firing (momentum −8.1% into a −20% trigger as of 09-03), then **certain** — it does not need a halt, a race, or bad luck

---

**Plain-language summary for an owner reading one paragraph.** The half-Kelly
sizer decides how much of the book to deploy from a ledger of past
holding-period returns. On the day the drawdown kill-switch fires, the live
run takes an early exit into "sell everything" *before* the line that writes
the just-finished period into that ledger, and the sell-everything path then
wipes the price snapshot the next run would need to reconstruct it. Result:
the one stretch of returns the sizer most needs to see — the crash that
tripped the switch — is never recorded. The backtest engine that validated
the strategy *does* record it. So on re-entry the live book sizes from a
ledger that has forgotten the crash, and re-enters roughly three to nine
times larger than the validated strategy would (table below). A second,
smaller leg: every daily snapshot after the switch is stamped "risk-off"
from the persisted state file rather than from the book's actual market
value, so if the liquidation ever HALTs (RWG-1 leg 3), the vol estimator
drops the highest-vol days the book actually owns. RWG-1 already owns the
halted-liquidation snapshot wipe; what is new here is that even a *clean*
liquidation loses the period, plus the parity comparison and the magnitude.

## Mechanism — verified at HEAD `cd571b2` (source unchanged through `3779020`)

**Leg 1 — the crash period is never booked.**
- `daily.py:1421-1423`: `if self._evaluate_kill_switch(today, equity): return
  self._liquidate_to_cash(...)` — upstream of `daily.py:1485`, which `grep -n
  _update_kelly_ledger_returns` confirms is the **only** call site of the
  booking method.
- `_liquidate_to_cash` clears the snapshot at `daily.py:1130`
  (`ledger["snapshot"] = {"date": today, "prices": {}}`) and only *then*
  calls `execute_orders` at `:1133`.
- Next selection day, `_update_kelly_ledger_returns` hits `daily.py:929`
  (`if not prev_prices: return`) and books nothing. The period from the last
  pre-crash snapshot to the crash day is gone.

**Leg 2 — the snapshot's `kill_switch` flag is intent.**
`_record_portfolio_snapshot` sets `snap["kill_switch"]` from
`read_kill_switch_state().active` (`daily.py:665-667`) when the observed
truth is eleven lines earlier in the same dict: `snap["long_mkt_value"]`
(`:648-649`). `_gross_returns` (`:720-721`) then `continue`s past every day
with the flag set — correct when the book went flat, wrong when it did not.
Modelled by the reviewer over a 63-day window with a 21-day crash regime:
realized vol 24.5% (truth) vs 13.9% (as computed) → a ×1.77 vol-target
scalar inflation applied on the re-entry run, which `max_leverage: 3.0`
does not clip (the current live scalar is 2.69). Modelled, not observed;
`confidence: medium` in the review, carried as such here.

**Leg 3 (owned elsewhere, listed for the fix-sitting).** `active: True` is
persisted at `daily.py:1024-1026` *before* the liquidation is attempted, and
the snapshot wipe at `:1130` precedes `execute_orders` — a HALTed liquidation
(RWG-1 leg 3: the >150-order count guard blocks any large book's flat-to-cash)
leaves the book fully held while every layer downstream believes it is cash.
RWG-1 owns that ruling; SAC-1's census lists the `active: True` site as of
2026-09-05. Not re-litigated here.

## Magnitude — reproduced independently (2026-09-05)

Recomputed from `data/processed/kelly_ledger.json` (5,000 observations,
production window = last `24 × 50 = 1200`, `construct.py:129-141` →
`kelly.py:91-92`) with the production `fraction: 0.5`; matches
`thales inspect kelly` (f 0.109, 90% CI [0.017, 0.191]) and the reviewer's
figures through `build_target_weights` to five significant figures:

| pooled sample | half-Kelly deployment f | today's f is |
|---|---|---|
| as-is — crash period lost (today's behaviour) | **0.1089** | — |
| had a −20% period on the 66 held names been booked | **0.0380** | 2.9× larger |
| −30% | **0.0115** | 9.5× larger |
| −40% | **0.0** — the fail-closed zero-deployment branch (`kelly.py:117-124`) | unbounded |

The first review of the same day priced this from the raw 5,000-row file and
got an 18–38% band; the production truncation makes it ~10× worse. Lesson
recorded in the verification memo: price a sizing finding through the
production entry point, never by re-deriving the arithmetic.

## Parity — what the validated engine does

`engine.py:711` gates the rebalance block on `not kill_switch_active`, and
`last_rebal_idx` only advances inside that block (`:936`), so the first
rebalance after re-entry calls `track_holding_returns(..., last_rebal_idx + 1,
global_idx)` over the whole span from the last pre-crash rebalance to
re-entry: the crash is booked. (Nuance for the ruling: that span also
includes the in-cash stretch, during which the engine books the *stocks'*
returns as if held. Imperfect — but it is the validated reference, and live
currently books nothing at all. Decide which convention is the reference
before writing the test; the engine is the one CPCV signed off.)

## Fix shape (propose-only)

1. **Book before branching.** Call `_update_kelly_ledger_returns(prices_df,
   today)` — or a snapshot-only variant that needs no `prices_df` load — at
   the top of the kill-switch branch, before `_liquidate_to_cash`. The
   `returns_booked_for` marker (PR #120) already makes this idempotent across
   retries.
2. **Publish after success.** Move the `active: True` persist
   (`:1024-1026`) and the snapshot clear (`:1130`) to *after*
   `execute_orders` returns without a HALT, in the same atomic
   `write_kelly_ledger` call as the booking — the shape the 2026-08-29
   `returns_booked_for` fix established. On a HALT: state stays
   `active: False` (or a distinct `pending`), snapshot survives, and the run
   exits red (RWG-1 leg 3's deliverable).
3. **Observe, don't recall.** `snap["kill_switch"] = long_mv <= eps` (or carry
   both: `kill_switch_intent` and `kill_switch_observed`, and let
   `_gross_returns` consume the observed one).
4. Confirm the engine's HWM-reset-on-re-entry semantics match
   `daily.py:1047-1048` (`hwm = equity` at re-entry) in the same sitting;
   record either parity or the deliberate divergence.

## Test design + negative control (CQA-1 doctrine — every gate reverted reds only its own test)

1. Kill-switch day with a `prices_df` fixture and a non-empty snapshot: after
   the run, the ledger carries one return per held name spanning snapshot
   date → crash day, `returns_booked_for` == snapshot date, and the
   snapshot is cleared. Revert fix 1 → red (ledger unchanged, exactly today's
   behaviour).
2. Halted liquidation (inject a safety HALT via `SimulatedBroker` /
   `SafetyLimits`): snapshot survives, `kill_switch_state.active` is not
   `True`, that day's portfolio snapshot has `kill_switch=False` because
   `long_mkt_value > 0`. Revert fix 2 → red; revert fix 3 → red.
3. Parity: a two-rebalance synthetic path through `run_backtest` and through
   the live wrapper with `SimulatedBroker` books the same period returns to
   within fill-timing noise. Red today.

## Kill criterion — pre-registered

If test 3 shows the engine does **not** book the crash period either (i.e.
parity already holds in the direction of both dropping it), the parity leg
dies and the row narrows to leg 2 plus the publish-after-success reorder;
record which. There is no wrong-diagnosis kill for leg 1 — it is three line
numbers and a `grep -c` of one.

## Cap note for triage

`queue/open/` is over the 12-cap. Rank this against the tree explicitly: it is
the one row in the set whose manifestation is certain rather than
probabilistic, in the scenario the entire risk layer exists for. If something
must yield, the fold-partner is **SAC-1** (same class; its census would
enumerate these sites anyway) — never RWG-1, which is deliberately narrowed.

---

## Built — recorded 2026-09-05 (the night the row was raised)

**Shipped (code PR #151, follows the records PR #150):**

- **Leg 1 — book before branching.** `run()`'s kill-switch branch now calls
  `_book_kelly_returns_before_liquidation(today, prices_df)` before
  `_liquidate_to_cash`. The helper reads only the snapshot's names (a few
  dozen parquets, not the universe) when no panel is loaded, books through the
  existing `_update_kelly_ledger_returns` (so the `returns_booked_for` marker
  keeps retries idempotent), and is fail-soft: a booking failure is logged at
  ERROR and never blocks the liquidation.
- **Leg 2 — publish after success.** `_evaluate_kill_switch` no longer writes
  `active: True` on the trigger day; it stages the state on
  `_kill_switch_pending`, and `_liquidate_to_cash` writes it — and clears the
  Kelly snapshot — only when `execute_orders` returned without a safety HALT.
  On a HALT: state untouched, snapshot preserved, `CRITICAL` log, and the run
  summary carries `kill_switch_liquidation_halted: true`; tomorrow
  re-evaluates the drawdown and retries.
- **Leg 3 — observe, don't recall.** The daily snapshot records
  `kill_switch_intent` (the state file) and sets `kill_switch` — the flag
  `_gross_returns` consumes — to intent AND `gross_leverage <= 0.05`
  (`_KILL_SWITCH_FLAT_GROSS`; residual unsellable dust still counts as flat).
- **Leg 4 — HWM reset parity:** confirmed by reading, no change needed. The
  engine sets `hwm = equity` on re-entry (`engine.py:652`); live does the same
  in the re-entry branch of `_evaluate_kill_switch`.

**Kill criterion — the parity leg survives:** `engine.py:711` gates the
rebalance on `not kill_switch_active` and `:936` advances `last_rebal_idx`
only there, so the engine's first rebalance after re-entry books the span
that includes the crash; live now books the crash period on the trigger day.
Not built: the row's test 3 (a synthetic engine-vs-live path); the live side
is pinned by unit tests and the engine side by reading, recorded as such.

**Tests — negative-controlled both ways** (`tests/test_execution/test_kill_switch_booking.py`,
5 tests, plus the trigger-day contract in `test_kill_switch.py`): a kill-switch
day with a −20 % in-flight period books `−0.2` into the pool, clears the
snapshot and publishes `active: True` only after the flat; a HALTed
liquidation books once, preserves the snapshot, publishes nothing, stamps the
day invested, and a retry the next session books nothing twice; lifting the
halt completes the liquidation; a dry run mutates nothing; the snapshot flag
needs an observed flat book (0.5× gross → invested; 0 or 3 % dust → flat).
With the source reverted and the tests kept, the KSP tests red on exactly
the assertions the row predicted.

**Route — recorded honestly:** built on the owner's direct instruction in the
2026-09-05 interactive session (the same session that verified the review),
NOT via `queue/approved/`; the guard denies the open→built rename, so a human
merges. Live path touched: `daily.py` only; engine untouched (no re-baseline).
