# Paper-Trading Cockpit (live MTM) — Implementation Plan (Phase 2F MVP)

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

**Goal:** Persist the positions + trades the book already computes, and add a `/paper` cockpit view that
marks them to live Binance prices (HTMX poll) → combined paper equity + per-sleeve PnL + trade log.

**Architecture:** Hexagonal, matching the repo. Pure domain math (`domain/paper.py`); persistence
(`paper_repo.py` + ORM rows in `cockpit_models.py`); a `LivePrice` port + ccxt adapter; a
`PaperBookService` that derives/persists on rebalance and builds the read model; web routes + templates.

**Tech Stack:** Python 3.12, SQLAlchemy, FastAPI + Jinja2 + HTMX, ccxt, pytest.

**Spec:** `docs/superpowers/specs/2026-06-21-paper-trading-cockpit-design.md`. **Repo:** `~/Work/fxhnt`,
branch `feat/paper-trading-cockpit`.

**Run tests:** `cd ~/Work/fxhnt && uv run pytest <path> -q`

---

## Task 1: `paper_capital` config

**Files:** Modify `src/fxhnt/config.py`; Test `tests/unit/test_config_paper.py`

- [ ] **Step 1: Failing test**
```python
# tests/unit/test_config_paper.py
from fxhnt.config import Settings
def test_paper_capital_default():
    s = Settings()
    assert s.paper_capital == 100_000.0
```
- [ ] **Step 2:** `uv run pytest tests/unit/test_config_paper.py -q` → FAIL (no attribute `paper_capital`).
- [ ] **Step 3:** In `src/fxhnt/config.py` add to `Settings` (pydantic):
```python
    paper_capital: float = 100_000.0   # starting paper book capital for the cockpit paper view
```
- [ ] **Step 4:** rerun → PASS.
- [ ] **Step 5:** `git add -A && git commit -m "feat(paper): paper_capital config (default 100k)"`

---

## Task 2: Pure domain — target positions, trade diff, mark-to-market

**Files:** Create `src/fxhnt/domain/paper.py`; Test `tests/unit/test_paper_domain.py`

- [ ] **Step 1: Failing test**
```python
# tests/unit/test_paper_domain.py
from fxhnt.domain.paper import Position, target_positions, diff_trades, mark_to_market

def test_target_positions_notional_split():
    # sleeve weight 0.5 of 100k = 50k; symbol weight -1.0 (short) at price 2.0 -> qty -25000
    pos = target_positions(sleeve="stablecoin_rotation", sleeve_weight=0.5, capital=100_000.0,
                           symbol_weights={"USDTUSD": -1.0}, prices={"USDTUSD": 2.0}, date="2026-06-21")
    assert pos[0].symbol == "USDTUSD"
    assert pos[0].qty == -25_000.0
    assert pos[0].entry_price == 2.0

def test_diff_trades_entry_exit_resize():
    prior = [Position("s","BTC", 1.0, 100.0, "2026-06-20")]
    target = [Position("s","BTC", 1.5, 110.0, "2026-06-21"), Position("s","ETH", 2.0, 50.0, "2026-06-21")]
    trades = diff_trades(prior, target, ts="2026-06-21T00:00:00Z")
    kinds = {(t.symbol, t.side, t.qty) for t in trades}
    assert ("BTC","BUY",0.5) in kinds        # resize up
    assert ("ETH","BUY",2.0) in kinds        # new entry
    # a full exit of a prior symbol yields a SELL of its qty
    trades2 = diff_trades([Position("s","BTC",1.0,100.0,"d")], [], ts="t")
    assert (trades2[0].symbol, trades2[0].side, trades2[0].qty) == ("BTC","SELL",1.0)

def test_mark_to_market():
    pos = [Position("s","BTC", 2.0, 100.0, "d")]
    mtm = mark_to_market(pos, prices={"BTC": 110.0})
    assert mtm.unrealized == 20.0     # 2*(110-100)
    assert mtm.per_symbol["BTC"] == 20.0
```
- [ ] **Step 2:** run → FAIL (module missing).
- [ ] **Step 3:** Implement `src/fxhnt/domain/paper.py` (pure, no I/O):
```python
"""Pure paper-book math: target positions from weights+capital, trade diff, mark-to-market. No I/O."""
from __future__ import annotations
from dataclasses import dataclass, field

@dataclass(frozen=True)
class Position:
    sleeve: str
    symbol: str
    qty: float
    entry_price: float
    entry_date: str

@dataclass(frozen=True)
class Trade:
    ts: str
    sleeve: str
    symbol: str
    side: str   # BUY | SELL
    qty: float
    price: float
    reason: str

@dataclass(frozen=True)
class MTM:
    unrealized: float
    per_symbol: dict[str, float] = field(default_factory=dict)

def target_positions(sleeve: str, sleeve_weight: float, capital: float,
                     symbol_weights: dict[str, float], prices: dict[str, float], date: str) -> list[Position]:
    notional = sleeve_weight * capital
    out: list[Position] = []
    for sym, w in symbol_weights.items():
        px = prices.get(sym)
        if not px or px <= 0:
            continue
        qty = (w * notional) / px
        if qty == 0.0:
            continue
        out.append(Position(sleeve, sym, qty, px, date))
    return out

def diff_trades(prior: list[Position], target: list[Position], ts: str, reason: str = "rebalance") -> list[Trade]:
    pri = {(p.sleeve, p.symbol): p for p in prior}
    tgt = {(p.sleeve, p.symbol): p for p in target}
    trades: list[Trade] = []
    for key in sorted(set(pri) | set(tgt)):
        sleeve, sym = key
        pq = pri[key].qty if key in pri else 0.0
        tq = tgt[key].qty if key in tgt else 0.0
        d = tq - pq
        if abs(d) < 1e-9:
            continue
        px = (tgt[key].entry_price if key in tgt else pri[key].entry_price)
        trades.append(Trade(ts, sleeve, sym, "BUY" if d > 0 else "SELL", abs(d), px, reason))
    return trades

def mark_to_market(positions: list[Position], prices: dict[str, float]) -> MTM:
    per: dict[str, float] = {}
    total = 0.0
    for p in positions:
        px = prices.get(p.symbol, p.entry_price)   # unknown -> entry (0 pnl)
        pnl = p.qty * (px - p.entry_price)
        per[p.symbol] = per.get(p.symbol, 0.0) + pnl
        total += pnl
    return MTM(unrealized=total, per_symbol=per)
```
- [ ] **Step 4:** run → PASS.
- [ ] **Step 5:** `git commit -m "feat(paper): pure domain — target positions, trade diff, MTM"`

---

## Task 3: ORM rows for positions + trades

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

- [ ] **Step 1: Failing test**
```python
# tests/unit/test_paper_models.py
from fxhnt.adapters.persistence.cockpit_models import PaperPositionRow, PaperTradeRow
def test_paper_tables_exist():
    assert PaperPositionRow.__tablename__ == "paper_positions"
    assert PaperTradeRow.__tablename__ == "paper_trades"
```
- [ ] **Step 2:** run → FAIL (import error).
- [ ] **Step 3:** Add to `cockpit_models.py` (same `CockpitBase`, imports already present — add `Date`/`DateTime`/`Float`/`String`/`Integer` are imported):
```python
class PaperPositionRow(CockpitBase):
    __tablename__ = "paper_positions"
    run_date: Mapped[dt.date] = mapped_column(Date, primary_key=True)
    sleeve: Mapped[str] = mapped_column(String(32), primary_key=True)
    symbol: Mapped[str] = mapped_column(String(32), primary_key=True)
    qty: Mapped[float] = mapped_column(Float)
    entry_price: Mapped[float] = mapped_column(Float)
    entry_date: Mapped[str] = mapped_column(String(10))
    weight: Mapped[float] = mapped_column(Float)
    src_updated_at: Mapped[dt.datetime] = mapped_column(DateTime)

class PaperTradeRow(CockpitBase):
    __tablename__ = "paper_trades"
    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    ts: Mapped[dt.datetime] = mapped_column(DateTime)
    sleeve: Mapped[str] = mapped_column(String(32))
    symbol: Mapped[str] = mapped_column(String(32))
    side: Mapped[str] = mapped_column(String(4))
    qty: Mapped[float] = mapped_column(Float)
    price: Mapped[float] = mapped_column(Float)
    reason: Mapped[str] = mapped_column(String(64))
```
- [ ] **Step 4:** run → PASS.
- [ ] **Step 5:** `git commit -m "feat(paper): paper_positions + paper_trades ORM rows"`

---

## Task 4: PaperRepo (persistence)

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

- [ ] **Step 1: Failing test**
```python
# tests/integration/test_paper_repo.py
import datetime as dt
from fxhnt.adapters.persistence.paper_repo import PaperRepo
from fxhnt.domain.paper import Position, Trade

def test_paper_repo_roundtrip():
    repo = PaperRepo("sqlite://"); repo.migrate()
    at = dt.datetime(2026,6,21,23,30, tzinfo=dt.UTC)
    repo.replace_positions("2026-06-21",
        [Position("stablecoin_rotation","USDTUSD",-25000.0,2.0,"2026-06-21")], weight_by_sleeve={"stablecoin_rotation":0.5}, at=at)
    repo.record_trades([Trade("2026-06-21T23:30:00Z","stablecoin_rotation","USDTUSD","SELL",25000.0,2.0,"rebalance")], at=at)
    pos = repo.latest_positions()
    assert pos[0].symbol == "USDTUSD" and pos[0].qty == -25000.0
    assert repo.weight_for("stablecoin_rotation") == 0.5
    assert repo.recent_trades(10)[0].side == "SELL"
```
- [ ] **Step 2:** run → FAIL.
- [ ] **Step 3:** Implement `paper_repo.py` mirroring `ForwardNavRepo` (engine/StaticPool for sqlite, `CockpitBase.metadata.create_all` in `migrate()`; `replace_positions(run_date, positions, weight_by_sleeve, at)` deletes that run_date's rows then inserts; `latest_positions()` returns the max run_date's rows as `Position`; `weight_for(sleeve)` from latest; `record_trades(trades, at)` inserts; `recent_trades(n)` newest-first). Use the same engine-construction block as `ForwardNavRepo.__init__`.
- [ ] **Step 4:** run → PASS.
- [ ] **Step 5:** `git commit -m "feat(paper): PaperRepo persistence (positions + trades)"`

---

## Task 5: LivePrice port + ccxt adapter + fake

**Files:** Create `src/fxhnt/ports/live_price.py`, `src/fxhnt/adapters/exchange/ccxt_live_price.py`;
Test `tests/unit/test_live_price_fake.py`

- [ ] **Step 1: Failing test (the fake used by later tests)**
```python
# tests/unit/test_live_price_fake.py
from fxhnt.ports.live_price import LivePrice, FakeLivePrice
def test_fake_live_price():
    lp: LivePrice = FakeLivePrice({"BTCUSDT": 110.0})
    assert lp.get_prices(["BTCUSDT","UNKNOWN"]) == {"BTCUSDT": 110.0}
```
- [ ] **Step 2:** run → FAIL.
- [ ] **Step 3:** `ports/live_price.py`:
```python
from __future__ import annotations
from typing import Protocol

class LivePrice(Protocol):
    def get_prices(self, symbols: list[str]) -> dict[str, float]: ...

class FakeLivePrice:
    def __init__(self, prices: dict[str, float]) -> None:
        self._p = prices
    def get_prices(self, symbols: list[str]) -> dict[str, float]:
        return {s: self._p[s] for s in symbols if s in self._p}
```
Then `adapters/exchange/ccxt_live_price.py` — a `CcxtLivePrice` using `ccxt.binance().fetch_tickers()`,
with an in-memory last-good cache so a failed fetch returns the previous prices (never raises to the web
layer); maps a sleeve symbol → binance pair where needed. (Covered by the integration test via the fake;
the real adapter is thin.)
- [ ] **Step 4:** run → PASS.
- [ ] **Step 5:** `git commit -m "feat(paper): LivePrice port + fake + ccxt adapter"`

---

## Task 6: PaperBookService — derive/persist + read model

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

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

def test_derive_persist_and_view():
    repo = PaperRepo("sqlite://"); repo.migrate()
    svc = PaperBookService(repo, capital=100_000.0)
    # one sleeve, weight 0.5, short USDTUSD weight -1.0 at price 2.0
    svc.derive_and_persist(run_date="2026-06-21",
        sleeve_weights={"stablecoin_rotation": 0.5},
        symbol_weights_by_sleeve={"stablecoin_rotation": {"USDTUSD": -1.0}},
        prices={"USDTUSD": 2.0}, at=dt.datetime(2026,6,21,tzinfo=dt.UTC))
    view = svc.view(live=FakeLivePrice({"USDTUSD": 1.9}))   # price dropped -> short profits
    assert view.capital == 100_000.0
    assert view.equity > 100_000.0                      # short gained as price fell
    assert any(p.symbol == "USDTUSD" for p in view.positions)
    assert view.sleeves["stablecoin_rotation"].pnl > 0
```
- [ ] **Step 2:** run → FAIL.
- [ ] **Step 3:** Implement `paper_book.py`:
  - `derive_and_persist(run_date, sleeve_weights, symbol_weights_by_sleeve, prices, at)`: for each sleeve,
    `target_positions(...)`; gather all → `diff_trades(prior=repo.latest_positions(), target)` →
    `repo.replace_positions(...)` + `repo.record_trades(...)`.
  - `view(live: LivePrice) -> PaperView`: load `repo.latest_positions()`, `live.get_prices([syms])`,
    `mark_to_market(...)`, build `PaperView(capital, equity=capital+unrealized, positions=[...with last+pnl],
    sleeves={sleeve: SleeveView(weight, pnl, n)}, trades=repo.recent_trades(25))`.
  - Define `PaperView`, `PositionView`, `SleeveView` dataclasses here.
- [ ] **Step 4:** run → PASS.
- [ ] **Step 5:** `git commit -m "feat(paper): PaperBookService derive/persist + live view model"`

---

## Task 7: Hook derive_and_persist into the daily rebalance

**Files:** Modify the combined-book/forward advance path (`src/fxhnt/application/forward_tracker.py` or the
cron entrypoint that runs the daily book); Test `tests/integration/test_paper_hook.py`

- [ ] **Step 1: Failing test** — assert that after the book advance runs, `PaperRepo.latest_positions()` is
  non-empty (seed a 1-sleeve book with a known weight + price via the existing test fixtures).
```python
# tests/integration/test_paper_hook.py  (skeleton — wire to the actual advance fixture)
def test_advance_persists_paper_positions(seeded_book_repo):
    # run the daily advance with paper persistence enabled; then:
    assert seeded_book_repo.paper.latest_positions()  # non-empty
```
- [ ] **Step 2:** run → FAIL.
- [ ] **Step 3:** In the daily advance path, after the book computes `book_allocator` sleeve weights +
  each sleeve's symbol weights + the day's closes, call `PaperBookService(paper_repo,
  settings.paper_capital).derive_and_persist(...)`. Inject `paper_repo` where `ForwardNavRepo` is built
  (same DSN). Guard with a setting `paper_enabled: bool = True` so it can be turned off.
- [ ] **Step 4:** run → PASS; also run the existing forward/book tests to confirm no regression:
  `uv run pytest tests/integration -q -k "forward or book"`.
- [ ] **Step 5:** `git commit -m "feat(paper): persist positions/trades on daily book rebalance"`

---

## Task 8: `/paper` + `/paper/live` routes + templates

**Files:** Modify `src/fxhnt/adapters/web/app.py`; Create
`src/fxhnt/adapters/web/templates/paper.html` + `_paper_live.html`; Test `tests/integration/test_paper_web.py`

- [ ] **Step 1: Failing test**
```python
# tests/integration/test_paper_web.py
from starlette.testclient import TestClient
from fxhnt.adapters.persistence.paper_repo import PaperRepo
from fxhnt.ports.live_price import FakeLivePrice
from fxhnt.adapters.web.app import create_app
# build app with a seeded PaperRepo + FakeLivePrice (extend create_app to accept paper_repo + live)

def test_paper_page_and_live(seeded_paper_app):
    c = TestClient(seeded_paper_app)
    r = c.get("/paper");      assert r.status_code == 200 and "Paper" in r.text
    r2 = c.get("/paper/live"); assert r2.status_code == 200 and "equity" in r2.text.lower()
```
- [ ] **Step 2:** run → FAIL.
- [ ] **Step 3:** Extend `create_app(...)` to accept `paper_repo` + `live: LivePrice` (default real ones in
  `create_app()` from settings; tests inject fakes). Add routes:
  - `GET /paper` → render `paper.html` with `PaperBookService(paper_repo, capital).view(live)`.
  - `GET /paper/live` → render `_paper_live.html` (the equity + positions partial), the HTMX poll target.
  `paper.html`: page shell + combined equity (reuse `nav_sparkline`) + sleeve cards + positions table +
  trades log; the live numbers wrapped in a div `hx-get="/paper/live" hx-trigger="every 45s"`.
  `_paper_live.html`: just the equity + positions table partial. Add a nav link to `/paper` from the fleet page.
- [ ] **Step 4:** run → PASS.
- [ ] **Step 5:** `git commit -m "feat(paper): /paper + /paper/live cockpit view (HTMX live MTM)"`

---

## Task 9: Full suite + finish

- [ ] **Step 1:** `uv run pytest -q` → all green (no regressions).
- [ ] **Step 2:** Use **superpowers:finishing-a-development-branch** — merge `feat/paper-trading-cockpit`
  → master, push to Gitea, then deploy via `./scripts/argo-deploy-cockpit.sh` and verify
  `dashboard.fxhnt.ai/paper` renders (note: the cockpit is served behind the dashboard; confirm route).

---

## Self-review notes
- Live equity feed for *equities* is out of MVP scope (marked at last close) — documented in spec.
- Symbol→price-source mapping (crypto vs equity) lives in `CcxtLivePrice` / the view; unknown→entry (0 PnL).
- A3 (replay) reuses `paper_positions` history; B (alerts) is separate. Not in this plan.

## Acceptance criteria (from spec)
- paper_positions + paper_trades populated by a rebalance; target-qty + diff math unit-tested. ✅ T2,T4,T7
- `/paper` shows equity + sleeves + positions + trades from seeded data (mocked prices). ✅ T8
- `/paper/live` returns updated MTM; crypto live via ccxt (equities last-close). ✅ T6,T8
- price-fetch failure degrades gracefully (last-good cache). ✅ T5
- read-only, no broker side effects. ✅ (derivation only; no execute path)
