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 ofdaily.py:1485, whichgrep -n _update_kelly_ledger_returnsconfirms is the only call site of the booking method._liquidate_to_cashclears the snapshot atdaily.py:1130(ledger["snapshot"] = {"date": today, "prices": {}}) and only then callsexecute_ordersat:1133.- Next selection day,
_update_kelly_ledger_returnshitsdaily.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 continues 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)
- Book before branching. Call
_update_kelly_ledger_returns(prices_df, today)— or a snapshot-only variant that needs noprices_dfload — at the top of the kill-switch branch, before_liquidate_to_cash. Thereturns_booked_formarker (PR #120) already makes this idempotent across retries. - Publish after success. Move the
active: Truepersist (:1024-1026) and the snapshot clear (:1130) to afterexecute_ordersreturns without a HALT, in the same atomicwrite_kelly_ledgercall as the booking — the shape the 2026-08-29returns_booked_forfix established. On a HALT: state staysactive: False(or a distinctpending), snapshot survives, and the run exits red (RWG-1 leg 3's deliverable). - Observe, don't recall.
snap["kill_switch"] = long_mv <= eps(or carry both:kill_switch_intentandkill_switch_observed, and let_gross_returnsconsume the observed one). - Confirm the engine's HWM-reset-on-re-entry semantics match
daily.py:1047-1048(hwm = equityat 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)
- Kill-switch day with a
prices_dffixture 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). - Halted liquidation (inject a safety HALT via
SimulatedBroker/SafetyLimits): snapshot survives,kill_switch_state.activeis notTrue, that day's portfolio snapshot haskill_switch=Falsebecauselong_mkt_value > 0. Revert fix 2 → red; revert fix 3 → red. - Parity: a two-rebalance synthetic path through
run_backtestand through the live wrapper withSimulatedBrokerbooks 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 thereturns_booked_formarker 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_switchno longer writesactive: Trueon the trigger day; it stages the state on_kill_switch_pending, and_liquidate_to_cashwrites it — and clears the Kelly snapshot — only whenexecute_ordersreturned without a safety HALT. On a HALT: state untouched, snapshot preserved,CRITICALlog, and the run summary carrieskill_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 setskill_switch— the flag_gross_returnsconsumes — to intent ANDgross_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 = equityon 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).