# Paper Replay (history scrubber) — Implementation Plan (Phase 2F-A3)

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

**Goal:** Backfill the paper book over warehouse history into a `paper_nav` equity series + per-date
positions, and add a `/paper/replay` scrubber to watch the equity curve + book evolve.

**Architecture:** Reuse `persist_paper_book` (now also writing `paper_nav`); a backfill iterates past dates
ascending, recomputing as_of-aware sleeves' `current_weights(as_of)` + warehouse closes per date; a
`/paper/replay` HTMX view scrubs `paper_nav` dates. xsfunding excluded (live-only). Read-only.

**Tech Stack:** Python 3.12, SQLAlchemy, FastAPI+Jinja2+HTMX, Typer CLI, pytest. **Repo:** `~/Work/fxhnt`,
branch `feat/paper-replay`. **Spec:** `docs/superpowers/specs/2026-06-21-paper-replay-design.md`.
**Run tests:** `uv run pytest <path> -q`.

**Reuse (exists):** `persist_paper_book(repo, *, capital, run_date, sleeve_weights, symbol_weights_by_sleeve,
prices, at, enabled)`; `PaperRepo` with `realized_pnl()`, `latest_positions()`, `recent_trades()`,
`replace_positions`; `nav_sparkline(nav, ...)`; each as_of-aware sleeve's `current_weights(as_of)`;
`PaperSnapshotService`; `WarehousePriceProvider.fetch(market, start, end) -> PriceSeries`.

---

## Task 1: `paper_nav` ORM table

**Files:** Modify `src/fxhnt/adapters/persistence/cockpit_models.py`; Test `tests/unit/test_paper_nav_model.py`

- [ ] **Step 1: Failing test**
```python
# tests/unit/test_paper_nav_model.py
from fxhnt.adapters.persistence.cockpit_models import PaperNavRow
def test_paper_nav_table():
    assert PaperNavRow.__tablename__ == "paper_nav"
```
- [ ] **Step 2:** run → FAIL.
- [ ] **Step 3:** Add (same `CockpitBase`/imports as the other rows):
```python
class PaperNavRow(CockpitBase):
    __tablename__ = "paper_nav"
    run_date: Mapped[dt.date] = mapped_column(Date, primary_key=True)
    capital: Mapped[float] = mapped_column(Float)
    realized: Mapped[float] = mapped_column(Float)
    unrealized: Mapped[float] = mapped_column(Float)
    equity: Mapped[float] = mapped_column(Float)
    src_updated_at: Mapped[dt.datetime] = mapped_column(DateTime)
```
- [ ] **Step 4:** PASS. **Step 5:** `git commit -m "feat(replay): paper_nav ORM row"`

---

## Task 2: PaperRepo nav + history reads

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

- [ ] **Step 1: Failing test**
```python
# tests/integration/test_paper_repo_nav.py
import datetime as dt
from fxhnt.adapters.persistence.paper_repo import PaperRepo
def test_nav_history_and_lookups():
    repo = PaperRepo("sqlite://"); repo.migrate()
    at = dt.datetime(2026,6,21,tzinfo=dt.UTC)
    repo.upsert_nav("2026-06-20", capital=100000.0, realized=0.0, unrealized=10.0, equity=100010.0, at=at)
    repo.upsert_nav("2026-06-21", capital=100000.0, realized=5.0, unrealized=20.0, equity=100025.0, at=at)
    repo.upsert_nav("2026-06-21", capital=100000.0, realized=5.0, unrealized=25.0, equity=100030.0, at=at)  # idempotent overwrite
    hist = repo.nav_history()
    assert [h.run_date for h in hist] == ["2026-06-20", "2026-06-21"]   # ascending, deduped
    assert hist[-1].equity == 100030.0
```
- [ ] **Step 2:** run → FAIL.
- [ ] **Step 3:** Add to `PaperRepo`: `upsert_nav(run_date, *, capital, realized, unrealized, equity, at)`
  (delete-then-insert that run_date, or merge — idempotent); `nav_history() -> list[NavPoint]` ascending by
  run_date (define a small `NavPoint` dataclass with run_date/equity/realized/unrealized);
  `positions_at(run_date) -> list[Position]` (rows for that run_date); `trades_on(run_date) -> list[Trade]`
  (trades whose run_date matches — `paper_trades.run_date` was added in 2F-MVP fix). Mirror existing methods.
- [ ] **Step 4:** PASS. **Step 5:** `git commit -m "feat(replay): PaperRepo upsert_nav + nav_history/positions_at/trades_on"`

---

## Task 3: write `paper_nav` from `persist_paper_book`

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

- [ ] **Step 1: Failing test** — after `persist_paper_book(... run_date=D, prices=P ...)`, assert
  `repo.nav_history()[-1]` has `run_date==D` and `equity == capital + realized + Σ qty·(P[sym]-entry)`.
- [ ] **Step 2:** run → FAIL.
- [ ] **Step 3:** In `persist_paper_book`, after positions/trades/realized are persisted, compute
  `unrealized = Σ over persisted positions qty*(prices.get(sym, entry) - entry)`,
  `realized = repo.realized_pnl()` (cumulative ≤ run_date when backfill processes ascending),
  `equity = capital + realized + unrealized`, and `repo.upsert_nav(run_date, capital=capital,
  realized=realized, unrealized=unrealized, equity=equity, at=at)`. (Guarded by `enabled`.)
- [ ] **Step 4:** PASS. **Step 5:** `git commit -m "feat(replay): persist_paper_book writes paper_nav per run_date"`

---

## Task 4: backfill service

**Files:** Create `src/fxhnt/application/paper_backfill.py`; Test `tests/integration/test_paper_backfill.py`

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

class _Sleeve:                              # as_of-aware fake
    def current_weights(self, as_of): return {"BTCUSDT": 1.0}

def test_backfill_builds_nav_series():
    repo = PaperRepo("sqlite://"); repo.migrate()
    dates = ["2026-06-19","2026-06-20","2026-06-21"]
    closes_by_date = {"2026-06-19":{"BTCUSDT":100.0}, "2026-06-20":{"BTCUSDT":110.0}, "2026-06-21":{"BTCUSDT":121.0}}
    n = backfill_paper_book(
        repo, capital=100_000.0,
        sleeves={"crypto_tstrend": _Sleeve()}, sleeve_weights={"crypto_tstrend": 1.0},
        dates=dates, closes_by_date=closes_by_date, at=dt.datetime(2026,6,21,tzinfo=dt.UTC))
    hist = repo.nav_history()
    assert [h.run_date for h in hist] == dates          # one nav row per date, ascending
    assert n == len(dates)
```
- [ ] **Step 2:** run → FAIL.
- [ ] **Step 3:** Implement `backfill_paper_book(repo, *, capital, sleeves, sleeve_weights, dates,
  closes_by_date, at) -> int`: for each date ascending, build `symbol_weights_by_sleeve = {s:
  src.current_weights(date) for s,src in sleeves.items()}` under per-sleeve try/except (skip on
  error/empty), then `persist_paper_book(repo, capital=capital, run_date=date,
  sleeve_weights=<surviving>, symbol_weights_by_sleeve=<surviving>, prices=closes_by_date.get(date,{}),
  at=at)`. Return count of dates with a persisted nav. (xsfunding is simply not passed in `sleeves` by the
  caller — see Task 5.)
- [ ] **Step 4:** PASS. **Step 5:** `git commit -m "feat(replay): backfill_paper_book over a date range"`

---

## Task 5: `fxt paper-backfill` CLI

**Files:** Modify `src/fxhnt/cli.py`; Test `tests/integration/test_paper_backfill_cli.py`

- [ ] **Step 1: Failing test** — invoke the Typer command via `CliRunner` with a tmp sqlite DSN + a
  fake/seeded warehouse (or monkeypatched price provider) and assert it exits 0 + `PaperRepo(dsn).nav_history()`
  is non-empty.
- [ ] **Step 2:** run → FAIL.
- [ ] **Step 3:** Add a `paper_backfill` Typer command: assemble the as_of-aware sleeves (stablecoin,
  unlock, ts-trend — NOT xsfunding; reuse the `_live_sleeves` composition from the snapshot asset, filtered),
  `current_sleeve_weights(...)`, the date range (`--days N` default e.g. 365, capped to warehouse start),
  and per-date closes via `WarehousePriceProvider` (fetch each symbol's range once → `{date: close}` →
  `closes_by_date`). Call `backfill_paper_book(...)`. Print a summary (dates, positions persisted).
- [ ] **Step 4:** PASS. **Step 5:** `git commit -m "feat(replay): fxt paper-backfill CLI"`

---

## Task 6: `/paper/replay` view (scrubber + curve + book@date)

**Files:** Modify `src/fxhnt/adapters/web/app.py`; Create `templates/replay.html` + `_replay_at.html`;
Test `tests/integration/test_paper_replay_web.py`

- [ ] **Step 1: Failing test** — seed `paper_nav` + `paper_positions` for 2 dates; build app; TestClient:
  `GET /paper/replay` 200 + contains a range input + the curve; `GET /paper/replay/at?date=<d>` 200 + shows
  that date's positions. Empty history → `GET /paper/replay` 200 + "run `fxt paper-backfill`".
- [ ] **Step 2:** run → FAIL.
- [ ] **Step 3:** Add routes (reuse the `create_app` repo/svc wiring):
  - `GET /paper/replay` → `replay.html`: equity curve (`nav_sparkline` over `nav_history` equities), a
    `<input type=range min=0 max=N-1>` over the dates + Play/Pause (small JS or `hx-trigger`), and the
    `_replay_at.html` partial for the latest date by default. A note: "funding sleeve is live-only — excluded
    from replay." Empty `nav_history` → the backfill prompt.
  - `GET /paper/replay/at?date=<d>` → `_replay_at.html`: the book at `d` — `positions_at(d)` marked at d's
    stored entry/close (PnL vs entry) + `trades_on(d)` + that date's equity from `nav_history`.
  - Nav links `/paper` ↔ `/paper/replay`. Use `data-label` tables (mobile-friendly, matching the cockpit).
- [ ] **Step 4:** PASS. **Step 5:** `git commit -m "feat(replay): /paper/replay scrubber + curve + book@date"`

---

## Task 7: Full suite + finish + deploy + backfill run

- [ ] **Step 1:** `uv run pytest -q` → all green.
- [ ] **Step 2:** Use **superpowers:finishing-a-development-branch** — merge `feat/paper-replay` → master,
  push (auto-deploys via the Gitea webhook). Confirm the cockpit workflow Succeeded + the dashboard pod is
  healthy (boots clean).
- [ ] **Step 3:** Run the backfill once in-cluster: `kubectl exec deploy/dagster -c dagster -n foxhunt --
  fxt paper-backfill --days 365` (or via a one-shot Job using the cockpit image). Confirm it populates
  `paper_nav`.
- [ ] **Step 4:** Verify `https://dashboard.fxhnt.ai/paper/replay` renders the equity curve + scrubber + a
  real historical book; `/paper` shows the latest book. Report what renders.

---

## Self-review notes
- Backfill MUST process dates ascending so `realized_pnl()` accumulates correctly per nav row.
- xsfunding excluded from backfill (caller omits it); documented + UI-flagged.
- Read-only: recompute from warehouse + domain fns; no broker, no change to the nightly book except the
  added `paper_nav` write (idempotent).
- Don't `git add -A` — `*.dbn` on disk must never be staged (gitignored — verify).

## Acceptance criteria (from spec)
- `paper_nav` table + `upsert_nav`/`nav_history`/`positions_at`/`trades_on`; tested. ✅ T1,T2
- `persist_paper_book` writes the equity row per run_date; tested. ✅ T3
- `backfill_paper_book` + `fxt paper-backfill` recompute over a range, skip xsfunding/missing; tested. ✅ T4,T5
- `/paper/replay` curve + scrubber + book@date; empty + xsfunding notes; tested. ✅ T6
- idempotent re-run; nightly snapshot unchanged except paper_nav. ✅ T3 (upsert), T4
- post-deploy `/paper/replay` shows real history. ✅ T7
