# Paper-Snapshot Job — Design (Phase 2F-2)

**Date:** 2026-06-21
**Status:** Design (approved)
**Repo:** fxhnt
**Follows:** 2F MVP (paper-cockpit backend + `/paper` view, merged a4b806b). This feeds it real data.

## Motivation

The `/paper` cockpit view + `PaperBookService`/`PaperRepo`/`persist_paper_book` exist and are tested, but
nothing populates them: the nightly forward/book pipeline carries only daily *returns* — each sleeve
computes its positions/weights then discards them. This job re-exposes those weights (read-only) and
persists a daily paper book so `/paper` shows the **real** positions/trades/PnL of the live sleeves.

## Decision (from brainstorming)

Add a uniform **`current_weights(as_of) -> {symbol: weight}`** accessor to each live sleeve, reusing its
existing domain weight logic (NOT a standalone re-derivation — that would duplicate + drift). A new
`PaperSnapshotService` combines: allocator **sleeve** weights × each sleeve's **symbol** weights ×
`paper_capital` ÷ latest close → `persist_paper_book(...)`. Wired as a Dagster asset in the nightly run.
Purely additive/read-only — the return-producing hot path is untouched (the gate record is unaffected).

## Components

1. **`PositionSource` protocol** (`src/fxhnt/ports/position_source.py`):
   `current_weights(as_of: str) -> dict[str, float]` — signed per-symbol target weights for the sleeve as
   of `as_of` (the rebalance date). Sum of |weights| ≤ the sleeve's gross.
2. **Per-sleeve `current_weights()`** — implement on each live sleeve, reusing domain fns + the same
   warehouse reads the runner does:
   - stablecoin_rotation → `short_rich_weights(dev, thresh)`
   - unlock shorts → `inverse_vol_capped_weights(vols, max)` (negated — shorts)
   - funding carry (xsfunding) → `construction_weights(scores, mode, …)`
   - crypto ts-trend → `ts_trend_signal(closes, lookbacks)` → sign × vol-target weight per symbol
   - equity/multistrat sleeves (sixtyforty, growth_discipline, multistrat) → their fixed/!computed weight lists
   A sleeve that genuinely can't surface weights (or has no data yet) returns `{}` (skipped, logged) —
   never raises.
3. **Allocator sleeve-weight accessor** (`book_allocator`): expose the per-sleeve inverse-vol weights it
   computes for the current trailing window as `current_sleeve_weights(series_by_edge) -> {sleeve: weight}`
   (a thin public wrapper over the existing internal `_inverse_vol_weights`/`combine_book` logic).
4. **`PaperSnapshotService.snapshot(as_of, at)`** (`src/fxhnt/application/paper_snapshot.py`):
   - sleeve_weights = allocator current weights; for each sleeve `current_weights(as_of)`; prices = latest
     closes from the warehouse `price_provider` for all referenced symbols.
   - call `persist_paper_book(paper_repo, capital=settings.paper_capital, run_date=as_of,
     sleeve_weights=…, symbol_weights_by_sleeve=…, prices=…, at=…, enabled=settings.paper_enabled)`.
   - per-sleeve try/except: log + skip a failing sleeve; the snapshot still persists the rest.
5. **Dagster asset `paper_book_snapshot`** (`adapters/orchestration/assets.py` + `definitions.py`):
   depends on `combined_forward_nav`; runs `PaperSnapshotService.snapshot(...)`; added to
   `daily_combined_book_schedule` (23:30). Idempotent (persist_paper_book replaces per run_date).

## Data flow

23:30 Dagster → `combined_forward_nav` (existing, unchanged) → `paper_book_snapshot` (new): allocator
sleeve weights × per-sleeve `current_weights` × warehouse closes → `persist_paper_book` → `paper_positions`
/`paper_trades`/realized. `/paper` reads them + marks crypto to live Binance prices (MVP view).

## Risks / safety

- **Read-only / additive:** no change to the return-producing advance path; the forward gate record is
  untouched. A snapshot failure must NOT fail the nightly book — the asset catches per-sleeve + overall and
  logs (the combined_forward_nav asset is the source of truth; snapshot is observability).
- **Weight fidelity:** `current_weights` must reuse the SAME domain fn + inputs the runner uses, so the
  paper book matches the live strategy. Tested per sleeve against the domain fixtures.
- **Symbol/price coverage:** a symbol with no warehouse close is skipped (no position) + logged.
- **Capital/sizing:** mirrors `book_allocator`; paper only (no fills/slippage guarantee — by design).

## Out of scope
- A3 replay scrubber; B alerts panel (separate specs).
- Live equity price feed (equities still mark at last close in the view).
- Changing the live trading/gate logic.

## Acceptance criteria
- Each live sleeve has `current_weights(as_of)` reusing its domain logic; unit-tested per sleeve.
- `book_allocator` exposes current per-sleeve weights; unit-tested.
- `PaperSnapshotService.snapshot()` persists positions/trades for a multi-sleeve book from fake
  sleeves+warehouse; integration-tested; a failing sleeve is skipped (others persist).
- `paper_book_snapshot` Dagster asset wired into the daily schedule, depends on `combined_forward_nav`;
  wiring-tested.
- After a snapshot run, `/paper` renders real positions/trades/PnL (verified post-deploy).
- Nightly book (combined_forward_nav) behavior + gate record unchanged.
