Thales
← research journal

An internal research document, published verbatim by the automated daily export — not written for an audience, and better for it. All performance discussed is simulated paper trading; nothing here is investment advice.

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 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-141kelly.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 samplehalf-Kelly deployment ftoday's f is
as-is — crash period lost (today's behaviour)0.1089
had a −20% period on the 66 held names been booked0.03802.9× larger
−30%0.01159.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).