# IBKR Paper Cockpit Parity — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Show the IBKR paper strategies' backtest (on the Backtests page) and their REAL DU-paper-account trading across the cockpit — an overview "real trades" line, a consolidated **Paper books** page showing both the crypto (simulated) and IBKR (real) books, and a click-through **holdings** detail — reconciled against the idealized sim ("how closely real trades follow the plan"), in plain language, scaling to multiple executing strategies by adding a row not a redesign.

**Architecture:** A faithful EXTENSION of the existing exec path. `IbkrBroker` already reads real NLV + positions; we (a) generalize the Backtests curve source from Bybit-only to any `backtest.kind`, (b) capture real fills → the existing empty `exec_fills` table + a new `ibkr_account_nav` snapshot at each `execute-multistrat` run, (c) compute ONE shared "behind/ahead of plan" signal rendered in plain language in three places (overview real-trades line, holdings-detail card, per-strategy breakdown), (d) consolidate `/paper` into a two-book landing (crypto simulated + IBKR real) that clicks through to a holdings detail. Plain-language labels throughout. Paper-only; gate/promotion/allocation untouched; the crypto book's data/behavior untouched.

**Tech Stack:** Python 3.12, SQLAlchemy 2.0 (offline), FastAPI + server-rendered Jinja templates, TimescaleDB/Postgres, ib-async (IBKR), pytest (`uv run pytest`, xdist `-n auto`).

## Global Constraints

- **PLAIN LANGUAGE, NO DEV JARGON** in any user-facing copy. A single `display_name` map (internal id → human
  name) is the source of every rendered name. Use: "Crypto 4-edge book", "Multi-asset ETF portfolio", "Options
  income (put spreads)", "60/40 benchmark", "real trades", "0.3% behind plan"/"ahead of plan", "cost to trade
  0.04%", "building", "not trading — options access pending", "no trades recorded yet". NEVER surface: raw ids
  (`bybit_4edge`, `multistrat`, `vrp`), the DU account number, `bps` (use %), "sim"/"recompute-replay"/
  "exec-twin"/"divergence". Keep only the fund's vocabulary (Sharpe, backtest, worst drop).
- **Badges:** `REAL PAPER` (blue, `.badge-exec.real`) marks the IBKR real-paper account; `PAPER` (grey,
  `.badge-exec.paper`) marks the simulated crypto book. Reuse the existing `.badge-exec` + the amber/green `.b`
  pill vocabulary; do NOT invent a palette.
- **Consolidated `/paper`:** one "Paper books" page shows BOTH books (crypto simulated + IBKR real), each
  clickable → its holdings detail. The Bybit book's underlying data/behavior is UNTOUCHED — only its landing is
  now shared. (No EU testnet keys → the crypto book stays simulated.)
- Paper only — NO live/real-capital path, NO arm step, NO gate/promotion/allocation semantic change (spec "Out").
- Capture is BEST-EFFORT + paper-only: a fills/snapshot persistence failure LOGS, never fails the rebalance or blocks execution (mirrors the existing best-effort ref-persist).
- No silent degradation: capture failures log loudly; a stalled exec track already surfaces via the health axis. Empty/absent data → clean empty state, NEVER a 500, NEVER a fabricated number.
- ONE divergence computation, reused by overview + detail + account (the "one signal, three places" rule).
- All data tagged by `strategy_id` so a second executor is a NEW ROW, not a redesign.
- DB migrations idempotent + Postgres-safe (no heavy startup DDL — follow `forward_nav`/`strategy_health` create-if-not-exists patterns).
- Reuse the existing badge/health palette + `exec_badge` macro; do NOT invent a new palette.
- Verify the cockpit LOCALLY in a real browser before deploy (read the rendered numbers).
- Commit messages end with `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.

---

## File Structure

| File | Responsibility | Task |
|---|---|---|
| `src/fxhnt/adapters/web/app.py` | `_SIM_BOOKS`→generalized book list; `_sim_returns` dispatch on `backtest.kind`; consolidated `/paper` both-books route; holdings-detail context | 1,3,4,5 |
| `src/fxhnt/application/sim_curves.py` (new) | `sim_returns_for(book, repo, store)` — dispatch a book's honest-cost curve by its registry `backtest.kind` (bybit→`bybit_sleeve_ret`; recompute-replay→precomputed IBKR curve) | 1 |
| `src/fxhnt/adapters/persistence/ibkr_account_repo.py` (new) | `IbkrAccountRepo` — migrate + replace/read `ibkr_account_nav` (per-run account snapshot) + read `exec_fills` by strategy | 2 |
| `src/fxhnt/adapters/persistence/cockpit_models.py` | new `IbkrAccountNavRow`; add `strategy_id` to `ExecFillRow` | 2 |
| `src/fxhnt/application/exec_capture.py` (new) | `capture_fills(trades)→list[FillDTO]`, `persist_execution(repo, strategy_id, rebalance_id, fills, account_snapshot, at)` (best-effort) | 2 |
| `src/fxhnt/adapters/broker/ibkr.py` | collect fills from placed trades; expose `account_snapshot()` (nlv/cash/gross/positions) | 2 |
| `src/fxhnt/application/multistrat_exec_record.py` | call `persist_execution` in `plan_and_record` (additive, best-effort, paper-only) | 2 |
| `src/fxhnt/application/exec_reconcile.py` (new) | `exec_vs_sim(strategy_id, repo)→ReconResult` — THE shared "behind/ahead of plan" signal (cum divergence, median cost-to-trade, fees) | 3 |
| `src/fxhnt/application/display_names.py` (new) | `display_name(id)→str` — the SINGLE internal-id→human-name map; every view uses it (plain-language rule) | 3 |
| `src/fxhnt/application/dashboard_service.py` | per-strat exec status + `exec_vs_sim` into fleet rows; `paper_books()`; holdings-detail context; per-strategy breakdown | 3,4,5 |
| `src/fxhnt/adapters/web/templates/_macros.html` | `.badge-exec.real` "REAL PAPER" state; `plan_pill(recon)` macro (plain words, no "vs sim"/bps) | 3,4 |
| `src/fxhnt/adapters/web/templates/strategy.html` | holdings detail: value-over-time (real vs plan), "what it's holding" table, recent trades, per-strategy breakdown | 3,5 |
| `src/fxhnt/adapters/web/templates/cockpit.html` | overview "real trades" sub-row + "behind plan" pill + REAL PAPER badge | 4 |
| `src/fxhnt/adapters/web/templates/paper.html` | consolidated `/paper` "Paper books" — BOTH books as clickable cards | 5 |

---

## Task 1: D1 — `/paper/sim` sources the IBKR backtest curve

**Files:**
- Create: `src/fxhnt/application/sim_curves.py`
- Create: `tests/unit/test_sim_curves.py`
- Modify: `src/fxhnt/adapters/web/app.py:388` (`_SIM_BOOKS`), `:402-413` (`_sim_returns`)

**Interfaces:**
- Consumes: `registry_backtest_kind(sid)` from `fxhnt.application.backtest_refs`; `PaperRepo.bybit_sleeve_returns()`; the precomputed IBKR recompute-replay curve (persisted like `bybit_sleeve_ret` — see Step 3).
- Produces: `sim_returns_for(book: str, *, bybit_returns: Callable[[], dict[str, dict[int,float]]], ibkr_curve: Callable[[str], dict[str, dict[int,float]]]) -> dict[str, dict[int, float]]` — a book's per-sleeve honest-cost return series keyed `{sleeve: {day_index: ret}}`, dispatched by `backtest.kind`.
- Produces: `SIM_BOOKS: tuple[str,...] = ("bybit_4edge","bybit_4edge_levered","multistrat","vrp")`.

- [ ] **Step 1: Write the failing test** (`tests/unit/test_sim_curves.py`)

```python
from fxhnt.application.sim_curves import sim_returns_for, SIM_BOOKS

def _bybit(): return {"tstrend": {0: 0.0, 1: 0.01}}
def _ibkr(book): return {book: {0: 0.0, 1: 0.02}}

def test_bybit_book_reads_bybit_sleeve_returns():
    out = sim_returns_for("bybit_4edge", bybit_returns=_bybit, ibkr_curve=_ibkr)
    assert out == {"tstrend": {0: 0.0, 1: 0.01}}

def test_ibkr_recompute_replay_book_reads_ibkr_curve():
    out = sim_returns_for("multistrat", bybit_returns=_bybit, ibkr_curve=_ibkr)
    assert out == {"multistrat": {0: 0.0, 1: 0.02}}

def test_sim_books_includes_ibkr():
    assert "multistrat" in SIM_BOOKS and "bybit_4edge" in SIM_BOOKS
```

- [ ] **Step 2: Run test to verify it fails** — `uv run pytest tests/unit/test_sim_curves.py -q` → FAIL (module missing).

- [ ] **Step 3: Implement `sim_curves.py`.** Dispatch on `registry_backtest_kind(book)`: `report`/bybit sids → `bybit_returns()`; `recompute-replay` sids → `ibkr_curve(book)`. The IBKR curve must be a PRECOMPUTED read (the sim never recomputes on the request path). Persist the recompute-replay curve where the sim reads it cheaply: extend the nightly `_persist_track_backtest_ref` / the `fxhnt-backtest-refs` Job to also write the per-day return series for `multistrat`/`vrp` into a small table (reuse the `bybit_sleeve_ret` shape or a sibling `sim_curve_ret(strategy_id, day_index, ret)`), and have `ibkr_curve(book)` read it. Wire `ibkr_curve` in `app.py` to that repo read (cached like `_sim_returns`).

```python
"""Dispatch a sim book's honest-cost curve by its registry backtest.kind — bybit books read the precomputed
bybit_sleeve_ret table; recompute-replay books (multistrat/vrp) read their precomputed IBKR curve. The sim
NEVER recomputes an edge on the request path (both sources are nightly-precomputed)."""
from __future__ import annotations
from typing import Callable
from fxhnt.application.backtest_refs import registry_backtest_kind

SIM_BOOKS: tuple[str, ...] = ("bybit_4edge", "bybit_4edge_levered", "multistrat", "vrp")

def sim_returns_for(book: str, *, bybit_returns: Callable[[], dict], ibkr_curve: Callable[[str], dict]) -> dict:
    kind = registry_backtest_kind(book)
    if kind == "recompute-replay":
        return ibkr_curve(book)
    return bybit_returns()   # report-kind bybit books (the existing path)
```

- [ ] **Step 4: Wire `app.py`.** Replace `_SIM_BOOKS = (...)` with `from fxhnt.application.sim_curves import SIM_BOOKS` (use `SIM_BOOKS`). Change `_sim_returns(book)` to call `sim_returns_for(book, bybit_returns=_paper_repo().bybit_sleeve_returns, ibkr_curve=_ibkr_sim_curve)` where `_ibkr_sim_curve(book)` reads the precomputed IBKR curve (cached with the same 300s TTL). Keep the TTL cache keyed by book.

- [ ] **Step 5: Run tests** — `uv run pytest tests/unit/test_sim_curves.py tests/integration/test_bybit_sim_web.py tests/integration/test_paper_sim_web.py -q` → PASS (bybit sim unchanged; new dispatch covered). Then the sim web test asserting `/paper/sim?book=multistrat` returns 200 and renders a curve.

- [ ] **Step 6: Commit** — `git add -A && git commit -m "feat(D1): /paper/sim sources any book's curve by backtest.kind (adds IBKR multistrat/vrp)"` (+ Co-Authored-By).

---

## Task 2: D2.1 — capture the real DU account (fills + NAV snapshot)

**Files:**
- Modify: `src/fxhnt/adapters/persistence/cockpit_models.py` (new `IbkrAccountNavRow`; `ExecFillRow` + `strategy_id`)
- Create: `src/fxhnt/adapters/persistence/ibkr_account_repo.py`
- Create: `src/fxhnt/application/exec_capture.py`
- Modify: `src/fxhnt/adapters/broker/ibkr.py` (fills + `account_snapshot`)
- Modify: `src/fxhnt/application/multistrat_exec_record.py:89-97` (call capture)
- Create: `tests/integration/test_exec_capture.py`, `tests/unit/test_ibkr_account_repo.py`

**Interfaces:**
- Produces: `IbkrAccountRepo(dsn)` with `.migrate()`, `.replace_snapshot(run_date: str, nlv: float, cash: float, gross: float, positions: dict[str,float], at)`, `.nav_series() -> list[tuple[str,float]]` (date, nlv), `.latest_positions() -> dict[str,float]`, `.fills_for(strategy_id: str, limit: int) -> list[FillRow]`.
- Produces: `capture_fills(trades) -> list[FillDTO]` (FillDTO: symbol, side, qty, price, fee, status, shortfall_bps); `persist_execution(repo, *, strategy_id, rebalance_id, fills, snapshot, at)` — best-effort (catches+logs).
- Produces: `IbkrBroker.account_snapshot() -> AccountSnapshot(nlv, cash, gross, positions: dict[str,float])`.

- [ ] **Step 1: Write the failing test** (`tests/unit/test_ibkr_account_repo.py`) — migrate on sqlite, replace a snapshot, read it back:

```python
import datetime as dt
from fxhnt.adapters.persistence.ibkr_account_repo import IbkrAccountRepo

def test_snapshot_roundtrip(tmp_path):
    repo = IbkrAccountRepo(f"sqlite:///{tmp_path}/t.db"); repo.migrate()
    at = dt.datetime(2026,7,14,14,35)
    repo.replace_snapshot("2026-07-14", nlv=1_028_594.0, cash=500.0, gross=1_000_000.0,
                          positions={"SPY": 10.0}, at=at)
    assert repo.nav_series() == [("2026-07-14", 1_028_594.0)]
    assert repo.latest_positions() == {"SPY": 10.0}
    # idempotent replace
    repo.replace_snapshot("2026-07-14", nlv=1_029_000.0, cash=1.0, gross=1.0, positions={"SPY": 11.0}, at=at)
    assert repo.nav_series() == [("2026-07-14", 1_029_000.0)]
```

- [ ] **Step 2: Run it** → FAIL (module missing).

- [ ] **Step 3: Implement the model + repo.** In `cockpit_models.py` add:

```python
class IbkrAccountNavRow(CockpitBase):
    __tablename__ = "ibkr_account_nav"
    run_date: Mapped[dt.date] = mapped_column(Date, primary_key=True)
    nlv: Mapped[float] = mapped_column(Float)
    cash: Mapped[float] = mapped_column(Float)
    gross: Mapped[float] = mapped_column(Float)
    positions_json: Mapped[str] = mapped_column(Text)
    at: Mapped[dt.datetime] = mapped_column(DateTime)
```

Add `strategy_id: Mapped[str] = mapped_column(String(64), index=True, default="")` to `ExecFillRow` (tag fills per strategy; default "" is back-compat). `IbkrAccountRepo` follows the `ForwardNavRepo` pattern: `migrate()` = `CockpitBase.metadata.create_all(engine)` (checkfirst — no ALTER, no heavy DDL) + a guarded idempotent `ADD COLUMN strategy_id` on `exec_fills` for pre-existing Postgres tables (mirror the paper_repo ALTER pattern, Postgres-only). `replace_snapshot` = delete-then-insert the run_date row; positions stored as `json.dumps`.

- [ ] **Step 4: Run the repo test** → PASS.

- [ ] **Step 5: Broker + capture (failing test first)** (`tests/integration/test_exec_capture.py`): a fake trade with `.fills` (each fill: contract.symbol, execution.side/shares/price/avgPrice, commissionReport.commission) → `capture_fills` returns FillDTOs with the right fields; `persist_execution` writes them to `exec_fills` tagged `strategy_id="multistrat"` and the snapshot to `ibkr_account_nav`; a repo that RAISES on write → `persist_execution` logs and does NOT raise (best-effort). Then implement `capture_fills` (map ib-async trade fills → FillDTO, `shortfall_bps` = (fill.price − intended)/intended·1e4 when the intended price is available, else 0.0) and `persist_execution` (wrap all writes in try/except → log.warning, never raise). Add `IbkrBroker.account_snapshot()` reading `accountSummary()` + `positions()` (reuse the existing `_SUMMARY_TAGS` read at ibkr.py:39-40) and make the placed-order path return the trades so fills are collectible.

- [ ] **Step 6: Wire `plan_and_record`** (multistrat_exec_record.py:89-97): after `record_exec_track(...)`, when `account_is_paper` and not blocked, call `persist_execution(account_repo, strategy_id="multistrat", rebalance_id=<plan.rebalance_id or today>, fills=capture_fills(plan.trades), snapshot=broker.account_snapshot(), at=at)`. Best-effort; thread the `account_repo` + broker through (the CLI constructs them — it already builds the broker). Do NOT change the `positions_after` return basis (gate-relevant).

- [ ] **Step 7: Run tests** — `uv run pytest tests/integration/test_exec_capture.py tests/unit/test_ibkr_account_repo.py tests/unit/test_multistrat_exec_helpers.py -q` → PASS.

- [ ] **Step 8: Commit** — `feat(D2.1): capture real IBKR fills->exec_fills (strategy-tagged) + ibkr_account_nav snapshot (best-effort, paper-only)`.

---

## Task 3: D2.2/2.3 — holdings detail page + the shared "behind plan" signal + display names

> Also create `src/fxhnt/application/display_names.py` — the SINGLE `display_name(id) -> str` map (internal id → human name) used by EVERY view (per the plain-language global constraint). Test it covers all books/strategies.

**Files:**
- Create: `src/fxhnt/application/exec_reconcile.py`, `tests/unit/test_exec_reconcile.py`
- Modify: `src/fxhnt/application/dashboard_service.py` (feed detail), `src/fxhnt/adapters/web/app.py` (detail context), `src/fxhnt/adapters/web/templates/strategy.html`, `_macros.html`
- Test: `tests/integration/test_ibkr_account_detail_web.py`

**Interfaces:**
- Produces: `exec_vs_sim(strategy_id: str, *, exec_repo, forward_repo) -> ReconResult` where `ReconResult` = dataclass(has_exec: bool, cum_exec_return: float, cum_sim_return: float, divergence: float, median_slippage_bps: float, total_fees: float). THE one shared signal (reused by D3, D4).
- Consumes: `IbkrAccountRepo` (Task 2), `ForwardNavRepo` (existing `multistrat` + `multistrat_exec` series).

- [ ] **Step 1: Failing test** (`tests/unit/test_exec_reconcile.py`): given a `multistrat_exec` executed cum return and a `multistrat` sim cum return + fills with known slippage, `exec_vs_sim` returns the expected `divergence` (= exec − sim) and `median_slippage_bps`; a strategy with NO exec data → `has_exec=False`, zeros. Use fakes for the two repos.

```python
def test_divergence_and_slippage():
    r = exec_vs_sim("multistrat", exec_repo=FakeExec(cum=0.031, fills_bps=[3.0,5.0,4.0], fees=12.0),
                    forward_repo=FakeFwd(exec_cum=0.031, sim_cum=0.038))
    assert round(r.divergence, 4) == -0.007      # exec 3.1% vs sim 3.8%
    assert r.median_slippage_bps == 4.0
    assert r.has_exec is True

def test_no_exec_data():
    r = exec_vs_sim("sixtyforty", exec_repo=FakeExec(cum=None, fills_bps=[], fees=0.0),
                    forward_repo=FakeFwd(exec_cum=None, sim_cum=0.05))
    assert r.has_exec is False
```

- [ ] **Step 2: Run it** → FAIL.

- [ ] **Step 3: Implement `exec_reconcile.py`.** `has_exec` = the `*_exec` forward track has ≥1 record. `divergence` = cum exec − cum sim (both from `forward_summary`/nav). `median_slippage_bps` = median of `exec_fills.shortfall_bps` for the strat. `total_fees` = sum `exec_fills.fee`. All reads via the repos; no compute on unavailable data (return zeros + `has_exec=False`).

- [ ] **Step 4: Run** → PASS.

- [ ] **Step 5: `_macros.html` — plain-language macros.** Add `plan_pill(recon)` (renders `has_exec` → the amber/green `.b` pill "{abs(divergence):.1%} behind plan" / "ahead of plan"; `has_exec=False` → nothing) and add the `.badge-exec.real` "REAL PAPER" state. Reuse the existing `.b`/`.badge-exec` classes. NO "vs sim"/"divergence"/`bps` in any rendered string — plain words only.

- [ ] **Step 6: holdings detail page (`strategy.html` + detail context).** `dashboard_service.detail()` attaches, for the IBKR book/strategy: `holdings` (from `IbkrAccountRepo.latest_positions()` → each asset with `display_name`, a plain "what it is" description, shares, value, %-of-book), `recent_trades` (`fills_for(sid, 20)` → time, asset, action Buy/Sell, shares, price, **cost-to-trade as a %** (not bps), fee), `account_nav` (`nav_series()`), `recon` (`exec_vs_sim(...)`). `strategy.html` renders: a "Following the plan" card + `plan_pill`, the **value-over-time** curve with the plan overlaid (dashed), a **"What it's holding right now"** holdings table, and a **"Recent trades"** table — all plain-language (see the asset→description map: SPY→"US stocks", IEF→"US Treasuries", GLD→"Gold", PDBC→"Commodities", DBMF→"Managed futures"). Empty/no-exec → "no trades recorded yet" (no 500).

- [ ] **Step 7: Web test** (`test_holdings_detail_web.py`): seed `ibkr_account_nav` + `exec_fills` + the two forward tracks; GET the IBKR book's detail renders the "what it's holding" table (with plain asset descriptions), a recent-trades row with cost-to-trade as a %, the value-over-time curve, and the "behind plan" pill; assert NO raw ids / `bps` / "vs sim" strings appear in the HTML. GET a strat with no exec → 200 with "no trades recorded yet", no 500.

- [ ] **Step 8: Commit** — `feat(D2.2/2.3): IBKR paper-account section on the strategy detail + shared exec-vs-sim divergence signal`.

---

## Task 4: D3 — overview "real trades" sub-line + "behind plan" pill

**Files:**
- Modify: `src/fxhnt/application/dashboard_service.py` (`fleet()`/`overview()` — per-row exec status + rollup), `src/fxhnt/adapters/web/app.py`, `src/fxhnt/adapters/web/templates/cockpit.html`
- Test: `tests/integration/test_overview_exec_signal.py`

**Interfaces:**
- Consumes: `exec_vs_sim` (Task 3), `IbkrAccountRepo` (Task 2).
- Produces: `FleetRow` gains `exec_status: str` ("" | "EXEC") and `recon: ReconResult | None`; an `IbkrAccountRollup` (total_nav, n_executing, agg_divergence) on the overview model.

- [ ] **Step 1: Failing test** (`test_overview_exec_signal.py`): with a seeded executing `multistrat` (exec data present) and a pure-sim strat, `fleet()` marks `multistrat.exec_status == "EXEC"` with a non-None `recon`, and the sim strat has `exec_status == ""`. `overview()` yields an `IbkrAccountRollup` with `total_nav` = the latest `ibkr_account_nav` and `n_executing == 1`.

- [ ] **Step 2: Run it** → FAIL.

- [ ] **Step 3: Implement.** In `dashboard_service.fleet()` set `exec_status="EXEC"` + `recon=exec_vs_sim(sid,...)` when the strat has exec data (its `*_exec` track exists AND has records); else "". Build `IbkrAccountRollup` from `IbkrAccountRepo` (`nav_series()[-1]`, count of executing strats, aggregate divergence = mean of per-strat divergences). One computation via `exec_vs_sim` (no duplicate math).

- [ ] **Step 4: Run** → PASS.

- [ ] **Step 5: `cockpit.html`** — mark an executing strategy's row with the `REAL PAPER` badge, and render a nested **"real trades"** sub-row (reuse the existing `.exec-tag` + `tr.exrow` style) showing: what actually filled (real return, "N trades · M holdings →" linking to its holdings detail), the `plan_pill(recon)` ("0.3% behind plan"), and a plain "cost to trade X% · no fees" note. Only for `exec_status=="EXEC"`. No separate rollup card (the consolidated Paper-books page is the account home). Plain language only.

- [ ] **Step 6: Web test** — GET `/` renders the `REAL PAPER` badge + the "real trades" sub-row + the "behind plan" pill on the executing row, NOT on a sim-only row; assert plain language (no raw ids / `bps` / "vs sim"); no exec data anywhere → no sub-row, no 500.

- [ ] **Step 7: Commit** — `feat(D3): overview real-trades sub-line + behind-plan pill (plain language, REAL PAPER badge)`.

---

## Task 5: D4 — consolidated `/paper` "Paper books" page (both books) + per-strategy breakdown

**Files:**
- Modify: `src/fxhnt/adapters/web/templates/paper.html` (the existing `/paper` — make it the two-book landing), `src/fxhnt/adapters/web/app.py` (`/paper` route → both books), `src/fxhnt/application/dashboard_service.py` (`paper_books()` + the IBKR book detail's per-strategy breakdown)
- Test: `tests/integration/test_paper_books_web.py`

**Interfaces:**
- Consumes: `IbkrAccountRepo`, `exec_vs_sim` (per executing strat).
- Produces: `dashboard_service.ibkr_account_view() -> IbkrAccountView(total_nav_series, all_positions, recent_fills, per_strategy: list[StratExecRow])` where `StratExecRow` = (strategy_id, display_name, executed_return, divergence, position_count, fill_count).

- [ ] **Step 1: Failing test** (`test_paper_books_web.py`): GET `/paper` renders BOTH book cards — the crypto book (`PAPER`) and the IBKR account (`REAL PAPER`) — each linking to its detail. `paper_books()` returns both books' summaries. The IBKR book DETAIL: `per_strategy` list with ONE row today; a **scale test** seeds a synthetic SECOND executing strat → `per_strategy` has TWO rows (proves "new row, not redesign"). Assert plain language throughout (no raw ids / `bps`).

- [ ] **Step 2: Run it** → FAIL.

- [ ] **Step 3: Implement `paper_books()` + per-strategy breakdown.** `paper_books()` returns both books' summaries (crypto: existing simulated stats; IBKR: `IbkrAccountRepo` account value + N strategies + `plan_pill` data). The `/paper` route renders the two-book `paper.html`. The IBKR book detail's `per_strategy` = for each strat with exec data, a row (human `display_name`, `exec_vs_sim` return + behind/ahead-of-plan, its position/trade counts from strategy-tagged `exec_fills`). All plain-language.

- [ ] **Step 4: Run** → PASS.

- [ ] **Step 5: `paper.html` — the consolidated "Paper books" landing.** Render BOTH books as clickable cards: the **Crypto 4-edge book** (`PAPER`, its existing simulated summary — value/since/Sharpe/worst-drop/status + mini curve) and the **IBKR paper account** (`REAL PAPER` — account value / N strategies trading / holdings count / `plan_pill` / mini curve with the plan overlaid). Each card links to that book's holdings detail (Task 3). The IBKR book's DETAIL page (Task 3's `strategy.html`) additionally shows the **per-strategy breakdown** table — one row per executing strategy (human name, real return, `plan_pill`, holdings/trade counts); renders a SET → 1 row today, N later, no layout change; a dimmed "not started yet" row shows the scale path. Plain language; empty account → clean empty state, no 500.

- [ ] **Step 6: Web test** — the scale test above passes (2-strat fixture → 2 rows); empty account → empty state, no 500.

- [ ] **Step 7: Commit** — `feat(D4): consolidated /paper Paper-books page (both books) + per-strategy breakdown (scales by row)`.

---

## Verify (whole feature)

- Full suite green FOREGROUND (`uv run pytest -q`) + `from fxhnt.cli import app`.
- Deploy via `mode=deploy` (git-sync); the `ibkr_account_nav` table + the `exec_fills.strategy_id` column create/ALTER on startup (idempotent).
- Browser-verify LOCALLY (port-forward prod pg → 15432, `create_app`): `/` shows the "real trades" sub-line + "behind plan" pill; `/paper` shows BOTH book cards (crypto `PAPER` + IBKR `REAL PAPER`); clicking the IBKR book shows the holdings + trades + value-over-time + per-strategy breakdown; the Backtests page shows the IBKR tracks' curves. Read the numbers; confirm NO dev jargon / raw ids / `bps` appear anywhere.
- After the next `execute-multistrat` run, confirm `exec_fills` + `ibkr_account_nav` populate with real DU-account data.

## Self-Review

- **Spec coverage:** D1 (backtests incl. IBKR tracks)→Task1, D2.1→Task2, D2.2/2.3 (holdings detail + behind-plan signal)→Task3, D3 (overview real-trades line)→Task4, D4 (consolidated Paper books)→Task5. One "behind plan" signal (`exec_vs_sim`) used by Task3/4/5. Plain-language rule → `display_names.py` (Task3) + the "no raw ids/bps/jargon" assertions in the Task3/4/5 web tests. Multi-strat scaling → strategy-tagged `exec_fills` (Task2) + the scale test (Task5). ✓
- **Placeholder scan:** none — each task has concrete code/tests.
- **Type consistency:** `sim_returns_for`, `IbkrAccountRepo`, `capture_fills`/`persist_execution`, `exec_vs_sim`/`ReconResult`, `FleetRow.exec_status`/`recon`, `IbkrAccountView`/`StratExecRow` — names consistent across tasks.
