# Paper book: ISV-adaptive vol budget — Implementation Plan (Phase 2F-A3h)

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or
> superpowers:executing-plans. TDD. Steps use checkbox (`- [ ]`).

**Goal:** Make the vol BUDGET state-adaptive — scaled by trailing book Sharpe, bounded [25%,35%], bootstrapped
to 30% — applied identically in both sizing paths (live == replay). Beats fixed 30/35% on Sharpe per the A/B.

**Architecture:** A pure `adaptive_target_vol(book_returns)` helper feeds the per-date target-vol into
`vol_target_leverage` inside `paper_book_returns` (per historical date, from the running book series it builds)
and `paper_risk_overlay` (latest date, from the prior book series `bret`). `vol_target_leverage` primitive
unchanged. Only the budget becomes adaptive; A3f accounting, ISV weights, killswitch, shadow signal untouched.

**Tech Stack:** Python 3.12, pytest. **Repo:** `~/Work/fxhnt`, branch `feat/paper-adaptive-vol-budget`.
**Spec:** `docs/superpowers/specs/2026-06-22-paper-adaptive-vol-budget-design.md`.

**Verified context (current code, paper_book.py):**
- Constants: `_TARGET_VOL=0.30, _KELLY_FRACTION=1.0, _MAX_LEVERAGE=3.0, _VOL_LOOKBACK=60,
  _PERIODS_PER_YEAR=365, _KILL_DD=0.25, _REENTER_DD=0.12`. `import math`, `import numpy as np`, `statistics`
  NOT imported (use `np`/`math` or add `statistics`).
- `paper_book_returns(...)` loop: sets `out[d]` (when `w_held` not None) at top, then computes
  `before_d`/`active`/`w_held`/`lev_held = vol_target_leverage(w_held, before_d, target_vol=target_vol, ...)`.
- `paper_risk_overlay(...)`: computes `weights`, then `leverage = vol_target_leverage(weights, returns,
  target_vol=target_vol, ...)`, then `bret = paper_book_returns(returns, target_vol=target_vol, ...)`, then
  replays `DrawdownKillSwitch` over `bret`. (Leverage is computed BEFORE bret today — Task 2 reorders.)

---

## Task 1: `adaptive_target_vol` helper + constants

**Files:** Modify `src/fxhnt/application/paper_book.py`; Test `tests/unit/test_adaptive_target_vol.py`

- [ ] **Step 1: Failing tests**
```python
# tests/unit/test_adaptive_target_vol.py
from fxhnt.application.paper_book import adaptive_target_vol

def test_bootstrap_below_min_obs_returns_base():
    assert adaptive_target_vol([0.01] * 5) == 0.30          # <20 obs -> base

def test_strong_sharpe_hits_vol_max():
    strong = [0.012, 0.010] * 40                            # positive mean, tiny vol -> huge Sharpe
    assert adaptive_target_vol(strong) == 0.35              # capped at vol_max

def test_negative_mean_floors_vol_min():
    weak = [0.010, -0.012] * 40                             # negative-ish mean -> Sharpe<=0 -> floor
    assert adaptive_target_vol(weak) == 0.25                # vol_min

def test_zero_vol_window_no_nan_floors_min():
    assert adaptive_target_vol([0.0] * 60) == 0.25          # sd=0 -> ts 0 -> frac 0 -> vol_min, no NaN

def test_monotone_and_bounded():
    lo = adaptive_target_vol([0.001, -0.0005] * 40)         # weak positive
    hi = adaptive_target_vol([0.012, 0.010] * 40)           # strong
    assert 0.25 <= lo <= hi <= 0.35
```
- [ ] **Step 2:** Run → FAIL (ImportError).
- [ ] **Step 3:** Add constants (near the A3g overlay constants) + the helper to `paper_book.py`. Add
  `import statistics as st` at the top if not present (else use numpy). Constants:
```python
_VOL_MIN = 0.25          # adaptive vol-budget floor (user ceiling concern: never "massive")
_VOL_MAX = 0.35          # adaptive vol-budget ceiling
_VOL_SH_REF = 2.0        # trailing book Sharpe at which the budget reaches vol_max
_VOL_MIN_OBS = 20        # min trailing book returns before adapting (else bootstrap to base)
```
  Helper (place after `vol_target_leverage`):
```python
def adaptive_target_vol(book_returns: list[float], *, vol_min: float = _VOL_MIN, vol_max: float = _VOL_MAX,
                        sh_ref: float = _VOL_SH_REF, base: float = _TARGET_VOL,
                        win: int = _VOL_LOOKBACK, min_obs: int = _VOL_MIN_OBS,
                        periods_per_year: int = _PERIODS_PER_YEAR) -> float:
    """ISV-adaptive vol budget: scale the target vol by the trailing book Sharpe, bounded [vol_min, vol_max].
    Eases toward vol_min in weak patches, rides vol_max when the edge is firing. Bootstrap to `base` until
    `min_obs` trailing book returns exist. Pure; causal (caller passes the strictly-prior book series)."""
    w = book_returns[-win:]
    if len(w) < min_obs:
        return base
    sd = st.pstdev(w)
    ts = (st.mean(w) / sd * math.sqrt(periods_per_year)) if sd > 0 else 0.0
    frac = max(0.0, min(1.0, ts / sh_ref))
    return vol_min + (vol_max - vol_min) * frac
```
- [ ] **Step 4:** Run → PASS (`uv run pytest tests/unit/test_adaptive_target_vol.py -q`).
- [ ] **Step 5:** Commit `git commit -m "feat(paper): adaptive_target_vol (trailing-Sharpe vol budget, bounded [25,35])"`

---

## Task 2: wire the adaptive budget into both sizing paths

**Files:** Modify `src/fxhnt/application/paper_book.py`; Test extend `tests/unit/test_paper_risk_overlay.py`

- [ ] **Step 1: Failing test** (append):
```python
def test_overlay_uses_adaptive_budget():
    from fxhnt.application.paper_book import (
        paper_risk_overlay, paper_book_returns, adaptive_target_vol, vol_target_leverage, isv_adaptive_weights)
    R = {"a": {i + 1: (0.012 if i % 2 else 0.010) for i in range(80)}}   # strong positive Sharpe
    w, lev, _killed = paper_risk_overlay(R, ["a"])
    bret = paper_book_returns(R)
    tv = adaptive_target_vol(list(bret.values()))
    expected = vol_target_leverage(isv_adaptive_weights(R, ["a"]), R, target_vol=tv)
    assert tv == 0.35                                # strong trailing Sharpe -> rides the 35% ceiling
    assert abs(lev - expected) < 1e-9               # overlay leverage uses the adaptive budget

def test_overlay_weak_history_eases_budget():
    from fxhnt.application.paper_book import paper_book_returns, adaptive_target_vol
    R = {"a": {i + 1: (0.010 if i % 2 else -0.012) for i in range(80)}}  # weak/negative
    tv = adaptive_target_vol(list(paper_book_returns(R).values()))
    assert tv == 0.25                                # eases to the 25% floor
```
- [ ] **Step 2:** Run → FAIL.
- [ ] **Step 3a:** In `paper_book_returns`, make the per-date budget adaptive. Replace the `lev_held = ...`
  line so the target vol comes from the running book series `out` (strictly prior):
```python
        w_held = isv_adaptive_weights(before_d, active)
        tv = adaptive_target_vol(list(out.values()), base=target_vol)
        lev_held = vol_target_leverage(w_held, before_d, target_vol=tv, kelly_fraction=kelly_fraction,
                                       vol_lookback=vol_lookback, periods_per_year=periods_per_year,
                                       max_leverage=max_leverage)
```
  (`out` holds book returns through the prior date at this point — causal. `target_vol` param is now the
  bootstrap base.) Update the docstring: budget is adaptive (trailing-Sharpe, [25,35]); `target_vol` = base.
- [ ] **Step 3b:** In `paper_risk_overlay`, REORDER so leverage uses the adaptive budget derived from `bret`:
```python
    weights = isv_adaptive_weights(returns_by_sleeve, active_sleeves)
    bret = paper_book_returns(returns_by_sleeve, target_vol=target_vol, kelly_fraction=kelly_fraction,
                              vol_lookback=vol_lookback, periods_per_year=periods_per_year,
                              max_leverage=max_leverage)
    tv = adaptive_target_vol(list(bret.values()), base=target_vol)
    leverage = vol_target_leverage(weights, returns_by_sleeve, target_vol=tv, kelly_fraction=kelly_fraction,
                                   vol_lookback=vol_lookback, periods_per_year=periods_per_year,
                                   max_leverage=max_leverage)
    ks = DrawdownKillSwitch(kill_dd=kill_dd, reenter_dd=reenter_dd)
    for d in sorted(bret):
        ks.observe(bret[d])
    return weights, leverage, ks.killed
```
  Update the overlay docstring: vol budget is ISV-adaptive (trailing-Sharpe, bounded [25%,35%], bootstrap 30%)
  rather than a fixed 30%.
- [ ] **Step 4:** Run → PASS (`uv run pytest tests/unit/test_paper_risk_overlay.py -q`).
- [ ] **Step 5: Regression** — all green (these assert shape/sign/consistency, not a specific fixed budget;
  update any that hard-coded fixed-budget leverage, preserving intent, REPORT):
  `uv run pytest tests/unit/test_vol_target_leverage.py tests/unit/test_drawdown_killswitch.py
  tests/unit/test_isv_adaptive_weights.py tests/integration/test_paper_backfill.py
  tests/integration/test_paper_snapshot.py tests/integration/test_paper_compounding.py -q`.
- [ ] **Step 6:** Commit `git commit -m "feat(paper): apply adaptive vol budget in overlay + book-return replay"`

---

## Task 3: Full suite + finish + deploy + re-backfill + verify

- [ ] **Step 1:** `cd ~/Work/fxhnt && uv run pytest -q` → all green (update any remaining fixed-budget
  assertion, preserving intent; report it).
- [ ] **Step 2:** `git status --short | grep -i '\.dbn'` must print nothing.
- [ ] **Step 3:** **superpowers:finishing-a-development-branch** — merge `feat/paper-adaptive-vol-budget` →
  master, push (auto-deploys via Gitea→Argo). Confirm cockpit workflow Succeeded + pods rolled.
- [ ] **Step 4:** Re-run backfill in-cluster:
  `kubectl exec deploy/dagster -c daemon -n foxhunt -- fxhnt paper-backfill --days 365`.
- [ ] **Step 5: VERIFY** — curve ≈ the sweep's ADAPT[25→35] row (~+220% gross, Sharpe ~3.4, DD ~−14.5%),
  budget capped at 35%:
```bash
kubectl exec deploy/dagster -c daemon -n foxhunt -- python -c "
from sqlalchemy import create_engine, text
import statistics as st, math
from fxhnt.config import get_settings
e=create_engine(get_settings().operational_dsn)
with e.connect() as c:
    nav=[float(x[0]) for x in c.execute(text('select equity from paper_nav order by run_date'))]
    rets=[nav[i]/nav[i-1]-1 for i in range(1,len(nav)) if nav[i-1]]
    peak=0.0; mdd=0.0
    for v in nav:
        peak=max(peak,v); mdd=min(mdd, v/peak-1)
    sh=(st.mean(rets)/st.pstdev(rets)*math.sqrt(365)) if st.pstdev(rets)>0 else 0
    print(f'final/start {nav[-1]:.0f}/{nav[0]:.0f} = {(nav[-1]/nav[0]-1)*100:+.1f}%  Sharpe {sh:.2f}  maxDD {mdd*100:.1f}%')
"
```
  Confirm a strongly profitable curve (~Sharpe 3.4, ~+220%, ~−14.5% DD). Load `https://dashboard.fxhnt.ai/paper`
  + `/paper/replay`; report final equity, Sharpe, max-DD, and that `/paper` (live) agrees.

---

## Self-review notes
- Adaptive budget = pure fn of the book's own trailing returns; bounded [25%,35%] (never exceeds the user's
  ceiling); bootstrap 30%; eases in weak patches (the Sharpe-lifting half), rides 35% when strong.
- Same `adaptive_target_vol` call in `paper_book_returns` (per date, from `out`) AND `paper_risk_overlay`
  (latest, from `bret`) → live == replay. Causal (strictly-prior book series).
- A3f equity accounting, ISV weights, killswitch, shadow signal, vol_target_leverage primitive untouched.
- Procyclicality bounded by the narrow band + dormant kill; honest 1-sample caveat documented in the spec.
- NEVER `git add -A`; verify no `*.dbn` staged.

## Acceptance criteria (from spec)
- `adaptive_target_vol`: bootstrap/<min_obs→base, strong→vol_max, weak→vol_min, monotone, bounded, no NaN. ✅ T1
- both paths use it on the strictly-prior book series; consistent (live==replay); causal. ✅ T2
- full suite green. ✅ T2, T3
- re-backfill → ~+220% / Sharpe ~3.4 / ~−14.5% DD, capped at 35%; /paper agrees; verified. ✅ T3
- accounting/strategy mechanics read-only; no `*.dbn`. ✅ T3
