# Paper book: compounding equity-accounting fix — Implementation Plan (Phase 2F-A3f)

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

**Goal:** Make the paper-book equity curve reflect the true compounding mark-to-market of the held book
(fixing the bug where `unrealized≡0` discarded all held-position appreciation), so the cockpit shows the real
profitable curve.

**Architecture:** `equity_d = equity_{d-1} + Σ positions_before(d).qty·(close_d − entry)`; size each date's
throttled book off the running equity (compounding). Internal to `persist_paper_book` + `PaperBookService`
(+ two new repo reads); backfill/snapshot are unchanged callers. The shadow signal, `paper_risk_overlay`, and
sizing weights/leverage are untouched — only the throttled-book sizing base (capital→equity) and the nav
equity computation change.

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

**Verified context (current code):**
- `PaperBookService.derive_and_persist(run_date, sleeve_weights, symbol_weights_by_sleeve, prices, at,
  leverage=1.0)` sizes `target_positions(..., capital=self._capital * leverage ...)` then diff_trades +
  realized_delta + replace_positions/replace_trades/set_realized_for_run_date.
- `persist_paper_book(repo, *, capital, run_date, sleeve_weights, symbol_weights_by_sleeve, prices, at,
  enabled=True, leverage=1.0)`: calls derive_and_persist, then `positions=positions_at(run_date)`,
  `unrealized=Σ qty·(price−entry)` (≡0, the bug), `realized=realized_pnl_through(run_date)`,
  `equity=capital+realized+unrealized`, `upsert_nav(...)`.
- `PaperBookService.view`: `equity=self._capital + self._repo.realized_pnl() + unrealized` (line 388).
- `PaperRepo`: has `positions_before(run_date)`, `positions_at`, `nav_history() -> [NavPoint(run_date,equity,
  realized,unrealized)]`, `upsert_nav`. Engine pattern: `Session(self._engine)`, `select`, `PaperNavRow`
  imported.
- `realized_delta`/`set_realized_for_run_date`/`realized_pnl*` stay in place (unused for equity after this
  change — out-of-scope cleanup).

---

## Task 1: `nav_equity_before` + `latest_nav_equity` repo reads

**Files:** Modify `src/fxhnt/adapters/persistence/paper_repo.py`; Test `tests/integration/test_paper_nav_equity_reads.py`

- [ ] **Step 1: Failing test**
```python
# tests/integration/test_paper_nav_equity_reads.py
import datetime as dt
from fxhnt.adapters.persistence.paper_repo import PaperRepo

def _repo():
    r = PaperRepo("sqlite://"); r.migrate(); return r

def test_nav_equity_before_and_latest():
    r = _repo(); at = dt.datetime(2026, 1, 2, tzinfo=dt.UTC)
    assert r.nav_equity_before("2026-01-01") is None      # nothing yet
    assert r.latest_nav_equity() is None
    r.upsert_nav("2026-01-01", capital=100000.0, realized=0.0, unrealized=0.0, equity=100000.0, at=at)
    r.upsert_nav("2026-01-02", capital=100000.0, realized=0.0, unrealized=5000.0, equity=105000.0, at=at)
    assert r.nav_equity_before("2026-01-02") == 100000.0  # strictly-prior
    assert r.nav_equity_before("2026-01-01") is None
    assert r.nav_equity_before("2026-01-03") == 105000.0
    assert r.latest_nav_equity() == 105000.0
```
- [ ] **Step 2:** Run → FAIL.
- [ ] **Step 3:** Add to `PaperRepo` (after `nav_history`):
```python
    def nav_equity_before(self, run_date: str) -> float | None:
        """The most recent paper_nav equity STRICTLY BEFORE `run_date` (the prior equity to compound from)."""
        rd = dt.date.fromisoformat(run_date)
        with Session(self._engine) as s:
            return s.scalar(select(PaperNavRow.equity)
                            .where(PaperNavRow.run_date < rd)
                            .order_by(PaperNavRow.run_date.desc()).limit(1))

    def latest_nav_equity(self) -> float | None:
        """The most recent paper_nav equity (for the live view's curve baseline)."""
        with Session(self._engine) as s:
            return s.scalar(select(PaperNavRow.equity)
                            .order_by(PaperNavRow.run_date.desc()).limit(1))
```
- [ ] **Step 4:** Run → PASS. **Step 5:** Regression `uv run pytest tests/integration/test_paper_repo_nav.py -q`.
- [ ] **Step 6:** Commit `git commit -m "feat(paper): PaperRepo nav_equity_before + latest_nav_equity"`

---

## Task 2: compounding equity in `persist_paper_book` + `derive_and_persist(capital_base)`

**Files:** Modify `src/fxhnt/application/paper_book.py`; Test `tests/integration/test_paper_compounding.py`

- [ ] **Step 1: Failing test**
```python
# tests/integration/test_paper_compounding.py
import datetime as dt
from fxhnt.adapters.persistence.paper_repo import PaperRepo
from fxhnt.application.paper_book import persist_paper_book

def _p(repo, d, px, lev=1.0):
    persist_paper_book(repo, capital=100000.0, run_date=d, sleeve_weights={"s": 1.0},
                       symbol_weights_by_sleeve={"s": {"X": 1.0}}, prices={"X": px}, at=dt.datetime(2026,1,1,tzinfo=dt.UTC),
                       leverage=lev)

def test_equity_compounds_off_prior_equity():
    r = PaperRepo("sqlite://"); r.migrate()
    _p(r, "2026-01-01", 100.0)                          # day0: no prior -> equity = capital
    assert r.nav_history()[-1].equity == 100000.0
    _p(r, "2026-01-02", 110.0)                          # prior qty 1000 *(110-100)=+10000 -> 110000
    assert abs(r.nav_history()[-1].equity - 110000.0) < 1e-6
    pos = r.positions_at("2026-01-02")                  # sized off 110000 -> qty 110000/110=1000
    assert abs(pos[0].qty - 110000.0 / 110.0) < 1e-6
    _p(r, "2026-01-03", 121.0)                          # +10% again, compounded -> 121000
    assert abs(r.nav_history()[-1].equity - 121000.0) < 1e-6

def test_killed_flat_book_keeps_equity_flat():
    r = PaperRepo("sqlite://"); r.migrate()
    _p(r, "2026-01-01", 100.0)                          # equity 100000, book qty 1000
    _p(r, "2026-01-02", 110.0)                          # equity 110000
    _p(r, "2026-01-03", 130.0, lev=0.0)                 # kill: prior(qty 1000)*(130-110)=+20000 -> 130000, book FLAT
    assert abs(r.nav_history()[-1].equity - 130000.0) < 1e-6
    assert r.positions_at("2026-01-03") == []
    _p(r, "2026-01-04", 200.0)                          # prior book empty -> gain 0 -> equity flat 130000
    assert abs(r.nav_history()[-1].equity - 130000.0) < 1e-6

def test_rerun_is_idempotent():
    r = PaperRepo("sqlite://"); r.migrate()
    for d, px in [("2026-01-01",100.0),("2026-01-02",110.0),("2026-01-03",121.0)]:
        _p(r, d, px)
    first = [(n.run_date, n.equity) for n in r.nav_history()]
    for d, px in [("2026-01-01",100.0),("2026-01-02",110.0),("2026-01-03",121.0)]:
        _p(r, d, px)                                    # re-run ascending
    assert [(n.run_date, n.equity) for n in r.nav_history()] == first
```
- [ ] **Step 2:** Run → FAIL (current code: day2 equity stays ~100000 because unrealized≡0).
- [ ] **Step 3a:** In `PaperBookService.derive_and_persist`, add a `capital_base` param and size off it:
  signature → `def derive_and_persist(self, run_date, sleeve_weights, symbol_weights_by_sleeve, prices, at,
  leverage: float = 1.0, capital_base: float | None = None) -> None:`; at the top
  `base = self._capital if capital_base is None else capital_base`; change the `target_positions(...)` call to
  `capital=base * leverage`. Leave diff_trades / realized_delta / replace_* unchanged (trades + realized
  recording stay; realized no longer drives equity).
- [ ] **Step 3b:** Rewrite the equity block of `persist_paper_book` (replace the `positions=positions_at(...)`
  → `upsert_nav(...)` block, and move the sizing AFTER the equity computation):
```python
    if not enabled:
        return 0
    # Compounding mark-to-market: today's equity = prior equity + the PRIOR book marked to today's close.
    # (positions_before / nav_equity_before are strictly-prior, so this is causal + idempotent.)
    prior = repo.positions_before(run_date)
    gain = sum(p.qty * (prices.get(p.symbol, p.entry_price) - p.entry_price) for p in prior)
    e_prev = repo.nav_equity_before(run_date)
    if e_prev is None:
        e_prev = capital                                   # day 0 seed
    equity = e_prev + gain
    # Size today's throttled book off the COMPOUNDED equity (gains reinvest).
    PaperBookService(repo, capital).derive_and_persist(
        run_date=run_date, sleeve_weights=sleeve_weights,
        symbol_weights_by_sleeve=symbol_weights_by_sleeve, prices=prices, at=at,
        leverage=leverage, capital_base=equity)
    # realized = cumulative through prior day; unrealized = today's MTM gain; equity = capital+realized+unrealized.
    repo.upsert_nav(run_date, capital=capital, realized=e_prev - capital,
                    unrealized=gain, equity=equity, at=at)
    return len(repo.positions_at(run_date))
```
  Update the docstring (compounding MTM equity; sizes off running equity). Remove the now-stale comment block
  about `realized_pnl_through`/`unrealized≡0`.
- [ ] **Step 4:** Run → PASS (`uv run pytest tests/integration/test_paper_compounding.py -q`).
- [ ] **Step 5:** Regression — these will likely need assertion updates to compounding semantics (update
  preserving intent, REPORT each): `uv run pytest tests/integration/test_paper_hook.py
  tests/integration/test_paper_backfill.py tests/integration/test_paper_snapshot.py
  tests/integration/test_paper_repo_nav.py -q`. (`test_persist_paper_book_leverage_scales_notional` should
  still pass: day-0 gain 0 → equity = capital = 100k, qty = leverage·100k/px.) The backfill
  `*_realized_is_monotone` test asserts the OLD realized model — update it to assert idempotent equity
  reproduction (re-run gives identical nav) instead.
- [ ] **Step 6:** Commit `git commit -m "fix(paper): compounding mark-to-market equity (size book off running equity)"`

---

## Task 3: `PaperBookService.view` equity = latest nav + live intraday MTM

**Files:** Modify `src/fxhnt/application/paper_book.py`; Test extend `tests/integration/test_paper_web.py` or `test_paper_hook.py`

- [ ] **Step 1: Failing test** (add where PaperBookService.view is exercised; if a NullLivePrice/fake live
  fixture exists, reuse it — otherwise a tiny stub):
```python
def test_view_equity_is_nav_curve_plus_live_intraday():
    import datetime as dt
    from fxhnt.adapters.persistence.paper_repo import PaperRepo
    from fxhnt.application.paper_book import PaperBookService, persist_paper_book

    class FakeLive:
        def __init__(self, px): self.px = px
        def get_prices(self, syms): return {s: self.px for s in syms}

    r = PaperRepo("sqlite://"); r.migrate(); at = dt.datetime(2026, 1, 1, tzinfo=dt.UTC)
    persist_paper_book(r, capital=100000.0, run_date="2026-01-01", sleeve_weights={"s": 1.0},
                       symbol_weights_by_sleeve={"s": {"X": 1.0}}, prices={"X": 100.0}, at=at)
    persist_paper_book(r, capital=100000.0, run_date="2026-01-02", sleeve_weights={"s": 1.0},
                       symbol_weights_by_sleeve={"s": {"X": 1.0}}, prices={"X": 110.0}, at=at)
    # nav curve through last close = 110000; current book (qty 110000/110=1000, entry 110) marked live @ 120
    view = PaperBookService(r, 100000.0).view(FakeLive(120.0))
    # equity = latest_nav_equity (110000) + 1000*(120-110)=10000 -> 120000
    assert abs(view.equity - 120000.0) < 1e-6
```
- [ ] **Step 2:** Run → FAIL (current: `capital + realized_pnl() + unrealized`).
- [ ] **Step 3:** Change the `view` return equity (line ~388) to:
```python
            equity=(self._repo.latest_nav_equity() or self._capital) + unrealized,
```
  (`unrealized` is already the current book marked to live prices in the existing loop. Drop the
  `realized_pnl()` term — superseded by the nav curve baseline.)
- [ ] **Step 4:** Run → PASS. **Step 5:** Regression `uv run pytest tests/integration/test_paper_web.py tests/integration/test_paper_hook.py -q` (update any view-equity assertion to the new basis).
- [ ] **Step 6:** Commit `git commit -m "fix(paper): live view equity = nav curve + live intraday MTM"`

---

## Task 4: Full suite + finish + deploy + re-backfill + verify the real curve

- [ ] **Step 1:** `cd ~/Work/fxhnt && uv run pytest -q` → all green (update any remaining old-equity-model
  assertions to compounding semantics, intent preserved).
- [ ] **Step 2:** `git status --short | grep -i '\.dbn'` must print nothing.
- [ ] **Step 3:** **superpowers:finishing-a-development-branch** — merge `feat/paper-equity-accounting` →
  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** — the corrected curve is now profitable:
```bash
kubectl exec deploy/dagster -c daemon -n foxhunt -- python -c "
from sqlalchemy import create_engine, text
from fxhnt.config import get_settings
e=create_engine(get_settings().operational_dsn)
with e.connect() as c:
    print('nav: start/min/max/final/n:', list(c.execute(text('select round((select equity from paper_nav order by run_date asc limit 1)::numeric,0) start, round(min(equity)::numeric,0) lo, round(max(equity)::numeric,0) hi, round((select equity from paper_nav order by run_date desc limit 1)::numeric,0) final, count(*) n from paper_nav'))))
    print('max drawdown:', list(c.execute(text('with e as (select run_date,equity,max(equity) over (order by run_date) pk from paper_nav) select round(min((equity-pk)/pk)::numeric,3) maxdd from e'))))
"
```
  Confirm `final` ≫ `start` (the curve reflects the +92%/+195% edges, compounded, after the risk overlay —
  profitable, not −8%). Load `https://dashboard.fxhnt.ai/paper` + `/paper/replay` and report the new final
  equity, max drawdown, and whether `/paper` (live) agrees with the curve.

---

## Self-review notes
- equity = prior equity + prior-book-marked-to-today → compounding falls out because the book is sized off the
  running equity. Killed/flat days → empty prior book → 0 gain → flat equity. Day 0 → equity = capital.
- Causal: `positions_before` + `nav_equity_before` are strictly-prior. Idempotent: ascending recompute /
  per-run_date upsert.
- Live view = nav curve baseline + live intraday MTM (no double count: curve is through last close, intraday
  is since last close).
- Strategy untouched: shadow signal, paper_risk_overlay, weights/leverage/kill all unchanged; only the
  throttled-book sizing base + nav equity change.
- realized_delta/realized_pnl* left in place (recorded but unused for equity) — bounded blast radius.
- NEVER `git add -A`; `*.dbn` gitignored — verify nothing stray staged before each commit.

## Acceptance criteria (from spec)
- `nav_equity_before`/`latest_nav_equity` strictly-prior/latest, None-when-empty. ✅ T1
- compounding equity (rising sleeve grows multiplicatively; killed→flat; day0=capital; idempotent). ✅ T2
- `derive_and_persist` sizes off `capital_base`. ✅ T2
- live view equity = nav curve + live intraday MTM. ✅ T3
- full suite green; re-backfill → profitable curve; /paper agrees; verified. ✅ T4
- strategy read-only/additive; no `*.dbn`. ✅ T4
