# Paper book: vol-target leverage + drawdown killswitch — Design (Phase 2F-A3d)

**Date:** 2026-06-22
**Status:** Design (approved)
**Repo:** fxhnt
**Follows:** A3c ISV-adaptive sleeve weighting ([[project_fxhnt_a3c_isv_adaptive_paper_weighting]]). Adds the
portfolio-level risk layer ON TOP of the (sum-to-1) sleeve weights.

## Motivation

A3c sizes the paper book by ISV-adaptive inverse-vol *sleeve weights* (sum 1) but applies **no
portfolio-level risk control** — no vol-target, no leverage, no drawdown control. The verified replay showed
a ≈−29% drawdown over the year precisely because the book is sleeve-weighted only. This adds the same
risk overlay the live `book_allocator.combine_book` already runs: **vol-target + fractional-Kelly leverage**
(normalizes risk contribution) and a **drawdown killswitch** (the piece that actually caps the drawdown).
Both must show in live `/paper` and `/paper/replay`.

## Decisions (from brainstorming)

- **Mirror `book_allocator` faithfully** (it is the trusted live implementation; do not refactor it):
  - vol-target: `lev = kelly_fraction · (target_vol_daily / trailing_port_vol)`, `target_vol_daily =
    target_vol/√periods_per_year`, capped `[0, MAX_LEVERAGE]`. Constants = live defaults: `target_vol=0.12`,
    `kelly_fraction=0.5`, `MAX_LEVERAGE=3.0`, `vol_lookback=60`, `periods_per_year=365`.
  - killswitch: flatten (stand in cash) once running drawdown of the **un-throttled shadow equity** exceeds
    `kill_dd=0.15`; re-enter when it recovers inside `reenter_dd=0.07` (hysteresis). Causal (decision uses
    strictly-prior days). Mirrors `apply_drawdown_killswitch`.
- **Leverage scales notional only.** Threaded as a `leverage` arg through `persist_paper_book` →
  `target_positions(... capital=capital·leverage ...)`. The `$100k` equity *base* stays unscaled, so equity =
  `capital + leverage·(realized+unrealized)` — the levered book on a fixed base. `leverage=0` (killed) → zero
  notional → flat book (exit to cash; realized booked, unrealized 0).
- **Decouple the return signal from the throttled book (the key correctness fix).** A killswitch flattens
  the actual book, but the per-sleeve return *signal* must NOT vanish during kills — else re-entry can never
  be detected and the ISV vol estimate corrupts. `sleeve_daily_return` is already leverage- and
  weight-invariant (the scalars cancel in pnl/notional), so we compute it from a **unit "shadow" book** (the
  sleeve's `current_weights` composition at unit notional, `sleeve_weight=1, capital=1`) that is persisted
  EVERY date and NEVER flattened. `paper_sleeve_ret` is fed from this clean signal. On non-killed days this is
  identical to A3c's value; on killed days it keeps the signal alive. (Strictly improves A3c too.)
- **Both paths, consistent.** A single `paper_risk_overlay(returns_by_sleeve)` returns `(weights, leverage,
  killed)` for the latest date given its trailing window; backfill and snapshot both call it on their
  strictly-prior window → live `/paper` == `/paper/replay`.

## Components

1. **`vol_target_leverage(weights, returns_by_sleeve, *, target_vol=0.12, kelly_fraction=0.5,
   vol_lookback=60, periods_per_year=365, max_leverage=3.0) -> float`** (pure, `paper_book.py`): build the raw
   portfolio return over the last `vol_lookback` of the union date axis (`Σ wᵢ·rᵢ`, 0-fill missing days);
   `pv = std`; `lev = clip(kelly·target_vol/√ppy / pv, 0, max_leverage)`. **Cold-start (pv≤1e-12 or <2 obs) →
   `1.0`** (unlevered = A3c behavior; never flat from lack of data). Mirrors `combine_book` step 4.
2. **`DrawdownKillSwitch(kill_dd=0.15, reenter_dd=0.07)`** (small stateful class, `paper_book.py`): `.observe(
   book_return)` updates shadow_eq/peak/killed (hysteresis, mirrors `apply_drawdown_killswitch`); `.killed`
   read BEFORE observing the next date = the state to apply. Pure (no I/O).
3. **`paper_book_returns(returns_by_sleeve, *, <vol-target params>) -> dict[int, float]`** (pure,
   `paper_book.py`): the UN-throttled book return series. Ascending over the union date axis; at each date
   compute `w=isv_adaptive_weights(returns<d)`, `lev=vol_target_leverage(w, returns<d)` (held into the next
   date); `bret(d) = lev_held·Σ w_held·r_d`. Causal (weights/leverage use strictly-prior returns).
4. **`paper_risk_overlay(returns_by_sleeve, active_sleeves, *, <params>) -> (dict weights, float leverage,
   bool killed)`** (pure, `paper_book.py`): the single entry point for the latest date. `weights =
   isv_adaptive_weights(returns, active)`; `leverage = vol_target_leverage(weights, returns)`; `killed =`
   replay a `DrawdownKillSwitch` over `paper_book_returns(returns)`; returns the effective `(weights,
   leverage, killed)`. Backfill + snapshot both call this on their trailing window. (O(N²) over a ≤365-date
   book — negligible; keeps ONE code path → live == replay.)
5. **`paper_shadow_positions` table** (`cockpit_models.py`): `(run_date, sleeve, symbol)` PK, `qty`,
   `entry_price`, `src_updated_at` — the unit composition, never throttled. **PaperRepo**:
   `replace_shadow_positions(run_date, positions, *, at)` and `shadow_positions_before(run_date) ->
   list[Position]` (prior date's shadow book; mirrors `positions_before`).
6. **`persist_paper_book(..., leverage: float = 1.0)`** + `PaperBookService.derive_and_persist(...,
   leverage=1.0)`: notional scaled by `leverage` (`target_positions(... capital=self._capital·leverage ...)`).
   Equity base unscaled. Default `1.0` keeps every existing caller/test unchanged.
7. **`backfill_paper_book`** (`paper_backfill.py`): per date ascending — `returns_before_d =
   sleeve_returns(before=d)`; `w, lev_raw, killed = paper_risk_overlay(returns_before_d, active)`; `eff_lev =
   0.0 if killed else lev_raw`; `persist_paper_book(... sleeve_weights=w, leverage=eff_lev ...)`; persist the
   date's **unit shadow** book; compute the clean per-sleeve return from `shadow_positions_before(d)` and
   `upsert_sleeve_ret`. (Shadow + clean-signal replace A3c's persisted-position return source.)
8. **`PaperSnapshotService`** (`paper_snapshot.py`): same — `paper_risk_overlay` off `sleeve_returns(
   before=as_of)`, persist throttled book with `eff_lev`, persist unit shadow, book the clean return from
   `shadow_positions_before(as_of)` + `self._prices`.

## Data flow

Per date (both paths): trailing clean per-sleeve returns → `paper_risk_overlay` → `(weights, leverage,
killed)` → throttled actual book (`persist_paper_book` with `eff_lev`, flat if killed) + unit shadow book
(always) → clean per-sleeve return appended to `paper_sleeve_ret` for the next date. The shadow book keeps
the signal alive through kills so re-entry + vol estimates stay correct.

## Risks / safety

- **Read-only/additive.** No change to `book_allocator`, sleeve `run()`/`advance()`, `combine_book`, the gate
  record, or any broker/live-order path. New table + new pure fns + a `leverage` param (default 1.0).
- **Causality:** all of weights/leverage/kill use strictly-prior returns; `bret(d)` uses weights/leverage
  held from before d. The kill decision for d uses shadow drawdown through d-1.
- **No flat-from-starvation:** vol-target cold-start → leverage 1.0; killswitch starts un-killed; missing
  vol → unlevered. The book degrades to A3c, never to an unintended flat curve.
- **Leverage cap 3.0** bounds exposure ($300k on $100k). `leverage=0` only via an explicit kill.
- **Consistency:** one `paper_risk_overlay` + one clean signal → live `/paper` == `/paper/replay`.
- **Idempotent:** shadow positions replaced per run_date (like `replace_positions`); `paper_sleeve_ret`
  upsert per (run_date, sleeve); re-running the backfill reproduces identical state.
- **Known limitation:** vol-target can lever up in a low-vol downtrend; the killswitch is the backstop that
  caps the resulting drawdown (the reason both ship together).

## Out of scope

`marginal_gate`/`min_sleeve_sharpe` edge-quality pruning (live allocator has them OFF by default too);
correlation-aware sizing; per-sleeve leverage; tuning the constants beyond the live defaults; the
`_live_sleeves`↔backfill composition dedup (tech debt); cleaning up the now-dead `equal_weights`.

## Acceptance criteria

- `vol_target_leverage`: correct formula vs `combine_book`; cap at `max_leverage`; cold-start → 1.0; low
  trailing vol → higher leverage; unit-tested.
- `DrawdownKillSwitch`: kills past `kill_dd`, re-enters inside `reenter_dd` (hysteresis), causal; unit-tested
  (incl. a kill→recover→re-enter sequence).
- `paper_book_returns` / `paper_risk_overlay`: causal; `killed` reflects only strictly-prior returns; returns
  consistent `(weights, leverage, killed)`; unit-tested.
- `paper_shadow_positions` table + `replace_shadow_positions`/`shadow_positions_before`; idempotent; tested.
- `persist_paper_book(leverage=…)` scales notional, not the equity base; default 1.0 leaves existing tests
  green; tested (leverage=2 → double notional/positions, equity base still capital).
- backfill + snapshot persist the unit shadow book, feed `paper_sleeve_ret` from it, and apply
  `paper_risk_overlay`; integration-tested (a kill period flattens the actual book while the shadow signal
  continues and re-entry later restores positions; both paths identical).
- Re-run backfill in-cluster → `paper_nav` reflects leverage + a capped drawdown (shallower than A3c's −29%);
  `paper_shadow_positions` populated; `/paper` + `/paper/replay` render the levered, kill-aware curve;
  verified post-deploy.
- Full suite green; read-only/additive; no `*.dbn` staged.
