# Lifecycle B1 — Paper-Track Strategies as Clean Dagster Assets

**Status:** design (approved in substance 2026-06-15)
**Predecessor:** B0 (combined-book on Dagster) — see `2026-06-15-lifecycle-dagster-b0-design.md`
**Scope of THIS increment:** all 6 remaining strategies — `funding`, `crossvenue`, `multistrat`, `sixtyforty`,
`gd`, and `poc`. After this increment all 7 registry strategies have live forward NAV in the cockpit.

`poc` was briefly going to be a separate B1b increment; it folds into this increment. **CORRECTION
(2026-06-16):** poc is ported **faithfully with torch** + the real `signal_sweep`/`pit_sweep` + the real
`_vrp_calib`. An earlier draft proposed a numpy reimplementation that dropped torch and changed the VRP
calibration source — that is rejected: you do not change an algorithm to avoid a package. The image gains
torch; the helpers are vendored verbatim. See plan Task 8 for the authoritative approach (it supersedes the
"numpy-only / no torch / calibration deviation" wording in the poc subsection + Out-of-scope below).

## Goal

Each of the 6 paper strategies becomes a clean, tested fxhnt application service + Dagster asset that produces
its `*_state.json` into the shared surfer data dir (`/data/surfer`). The existing `cockpit_forward` asset —
which already walks the whole `STRATEGY_REGISTRY` and ingests any present state file — then surfaces all of
them in the cockpit. The foxhunt `scripts/surfer/*` paper-forward crons are retired (fxhnt becomes the single
home; no cross-repo drift). After this increment all 7 registry strategies show live forward NAV in the cockpit
(combined from B0 + these 6).

This is a port to clean hexagonal — NOT a vendor-copy — chosen for testability and a single source of truth,
consistent with the "every minor is a latent bug" discipline.

## Background: what already exists (do not rebuild)

- `cockpit_forward` asset → `ingest_forward_state(dsn, data_dir)` → `ForwardIngestService.ingest` walks
  `STRATEGY_REGISTRY`, reads each present `{state_file}.json` via `ForwardStateReader`, evaluates the gate, and
  upserts rows + summary into the cockpit DB. **The consuming side is complete and unchanged by this work.**
- `ForwardStateReader` understands three shapes: `days-list` (full per-day `[{date,ret,nav}]`),
  `welford-equity` (`equity`+`sum_r`/`sumsq_r`), `cumulative-funding` (`cum_net`+`positions`). Only `days-list`
  yields a real per-day history.
- `CombinedBookForwardTracker.step()` is the proven per-day forward-booking pattern: freeze inception on first
  run, book only days strictly after `last_date`, persist `{inception,last_date,nav,days:[{date,ret,nav}]}`.
- The 6 strategies have working reference harnesses in foxhunt `scripts/surfer/`
  (`crypto_funding_paper.py`, `cross_venue_funding.py`, `multistrat_paper.py`, `sixtyforty_paper.py`,
  `growth_discipline_paper.py`, `surfer_poc.py`). They are the behavioural reference for the port. For `poc`,
  only the `paper(cfg)` path is in scope — its `xs_weights` momentum signal is pure numpy; `backtest`/`regime`
  modes (which use torch + `pit_sweep`) are NOT ported.
- The Dagster netpol already allows egress on 443 to all external IPs (`0.0.0.0/0` except internal CIDRs) +
  DNS — so Yahoo / Bybit / Hyperliquid are reachable. **No netpol change is required.**

## State-shape decision: unify on `days-list`

All ported producers emit the `days-list` shape `{inception, last_date, nav, days:[{date,ret,nav}]}` — the only
shape that yields a real per-day cockpit history (sparkline + honest forward Sharpe + maxDD via the existing
`_days_list` reader path). The legacy `welford-equity` / `cumulative-funding` reader cases stay for back-compat
but the new producers do not use them. This means **no new `state_reader` case is needed for this increment.**

## Architecture (hexagonal)

```
domain/        pure return logic per strategy (data in → daily returns out), no I/O
ports/         market-data client Protocols + the ForwardStrategy port
adapters/data  urllib market-data clients (live) + deterministic fakes (tests)
application/   generic ForwardTracker + the 6 strategy services
adapters/orchestration  one @asset per strategy, wired into Definitions + job + schedule
```

### The generic `ForwardTracker` (foundation)

Generalize `CombinedBookForwardTracker` into a strategy-agnostic tracker that owns the `days-list` state file
and supports **both** strategy patterns through one port:

```python
class ForwardStrategy(Protocol):
    # Return the daily (date, return) rows the strategy can justify booking as-of now, plus its updated
    # private carry-state. The tracker books only rows strictly after last_date and persists `extra` opaquely.
    def advance(self, last_date: str | None, extra: dict) -> tuple[list[tuple[str, float]], dict]: ...
```

`ForwardTracker.step(strategy) -> ForwardStatus`:
1. Load state `{inception, last_date, nav, days:[...], extra:{}}` (or init on first run; freeze inception).
2. `rows, new_extra = strategy.advance(state["last_date"] if loaded else None, state.get("extra", {}))`.
3. Book each `(d, r)` with `d > state["last_date"]`: `nav *= 1+r`; append `{date,ret,nav}`; advance `last_date`.
4. `state["extra"] = new_extra`; save; return `ForwardStatus` (reuse the existing dataclass + Sharpe/maxDD calc).

This cleanly covers the two patterns:
- **Recomputable-series** (`multistrat`, `sixtyforty`, `gd`): `advance()` rebuilds the full deterministic
  series from Yahoo history and returns all rows; the tracker books only the new tail. `extra` unused.
- **Live-booking-with-positions** (`funding`, `crossvenue`): `advance()` reads `extra["positions"]`, books one
  realized-carry row for today from live funding, rebalances, returns `[(today, ret)]` + updated positions in
  `extra`. Idempotent per UTC day (returns `[]` if already booked today).

`CombinedBookForwardTracker` is refactored to a `ForwardStrategy` over `CombinedBook` so combined and the 6 new
strategies share one tracker (no behavioural change to combined — covered by its existing tests).

### Market-data ports + adapters

- `DailyBarClient` — adjusted daily closes (Yahoo `query1.finance.yahoo.com/v8/finance/chart`). Used by
  `multistrat`, `sixtyforty`, `gd`.
- `BinanceFundingClient` — funding history, spot/perp prices, 24h volume (`fapi`/`api.binance.com`). Used by
  `funding`.
- `CrossVenueFundingClient` — per-venue funding + volume across Binance/Bybit/Hyperliquid. Used by `crossvenue`.
- `CryptoPerpBarClient` — Binance top-liquid perp OHLCV panel (close/open + 24h volume) + current funding.
  Used by `poc` (`_live_panel`).
- `VolIndexClient` — Deribit `get_volatility_index_data` (BTC/ETH DVOL). Used by `poc`'s VRP sleeve (`_dvol`).

Each port has a urllib live adapter and a deterministic in-memory fake for tests. No live network in tests.

### Per-strategy detail

| Strategy | Pattern | Data source | Daily return logic (domain) | gate (existing registry) |
|---|---|---|---|---|
| `sixtyforty` | recomputable | Yahoo SPY/IEF | 0.6·r_SPY + 0.4·r_IEF, daily rebalance | days≥20, sharpe≥0 |
| `multistrat` | recomputable | Yahoo SPY/IEF/GLD/PDBC/DBMF/BTC | vol-normalized combo, edge-decay trust θ, EMA vol-target + Kelly floor + DD de-lever, ≤1.0x | days≥20, sharpe≥0.3 |
| `gd` | recomputable | Yahoo QQQ + multistrat | w·r_QQQ + (1−w)·r_multistrat, w=`GD_EQUITY_WEIGHT` (0.70) | days≥20, sharpe≥0.3 |
| `funding` | live-booking | Binance funding | qualify coins funding>5bp/day & liq>$5M; equal-weight; book yesterday's positions at today's funding − costs | days≥28, sharpe≥0 |
| `crossvenue` | live-booking | Binance/Bybit/Hyperliquid | top-K funding spreads (short high-venue / long low-venue), 10bp entry / 5bp exit hysteresis, ≤10 positions, −costs | days≥28, sharpe≥0 |
| `poc` | live-booking | Binance perps + Deribit DVOL | residual-momentum top-30 market-neutral weights (numpy `xs_weights`), weekly rebalance; VRP short-vol sleeve (tail-gated) scaled to momentum vol at 30% risk; combined book | days≥20, sharpe≥0.3 |

`gd`'s service composes `multistrat`'s domain functions (the foxhunt original imports them); the port preserves
that — `gd` depends on the `multistrat` domain module, not a duplicate.

### poc detail (the heavy one)

`poc`'s paper path is a two-sleeve live-booking strategy:
- **Momentum sleeve:** `_live_panel` fetches a Binance perp OHLCV panel; `compute_weights` builds residual
  (market-beta-stripped) 20-day momentum, takes the cross-sectional z-score market-neutral unit-gross weights
  (`xs_weights`, pure numpy), top-30, EWMA-smoothed, **weekly** rebalance on a fixed calendar phase. Each MARK
  books the held book's price return minus funding since the last mark.
- **VRP sleeve:** `VolIndexClient` (Deribit DVOL for BTC/ETH) drives a tail-managed short-vol return
  (`vrp_ccy`: don't sell into rising vol; full size only below the trailing-window median), scaled to the
  momentum sleeve's vol and blended at `vrp_risk_frac=0.30`.
- **Combined book:** `comb_ret = (1−f)·mom_ret + f·vrp_scaled`; this is the daily return the tracker books.
- **Calibration:** the original sizes the VRP sleeve via `_vrp_calib` over `pit_sweep` cached history. The port
  derives `vrp_mu` (momentum daily vol) and `vrp_vu` (VRP daily vol) from the **live panel's own lookback
  window** instead — removing the `pit_sweep`/warehouse + torch dependency. Documented behavioural deviation
  from the original; covered by tests with fixed panels.
- **State:** emits `days-list` (`ret=comb_ret`, `nav=combined_equity`); the momentum/VRP sub-equities,
  `positions`, `prices`, and `vrp_mu`/`vrp_vu` calibration live in the tracker's `extra` blob. poc keys marks by
  epoch-day integer in the original; the port converts to ISO date strings for days-list consistency.
- **No torch, no pit_sweep, no signal_sweep import** — `xs_weights`/`sharpe` helpers are reimplemented in the
  poc domain module (numpy).

### Orchestration

One `@asset` per strategy in `adapters/orchestration/assets.py`, mirroring `combined_forward_nav`: build the
service (live adapters from settings), run `ForwardTracker(strategy, state_path).step()`, log the status,
return a small status dict. Wire all 5 into `Definitions.assets`, the asset job's selection, and the daily
schedule. `cockpit_forward` gains `deps=[...]` on all 5 (so it ingests after every producer).

### Data flow & parallelism

The 5 assets fetch live HTTP APIs and write JSON state — they do **not** touch the DuckDB warehouse, so unlike
`crypto_bars`/`futures_bars` they need no single-writer serialization and run in parallel. `cockpit_forward`
depends on all 7 producers (combined's chain + these 6) and ingests last. Each asset is best-effort: a transient
API failure logs a warning and the asset still writes/keeps its state (one bad feed must not blind the cockpit).

## Infra & migration

- **Netpol:** no change (egress already permits external 443 + DNS).
- **Cron retirement:** suspend the foxhunt `scripts/surfer/*` paper crons that this increment replaces
  (notably the funding cron at 01:17 UTC). Reversible: re-enable the cron if an asset regresses. The
  2026-07-05 funding Phase-1 review still applies — same data, now Dagster-produced. (The retirement edits live
  in the foxhunt repo; this fxhnt spec lists them as a checklist item, applied as part of the increment.)
- **Inception:** the 6 start fresh (no cluster history exists for them in `/data/surfer`; only `combined` does).
  Paper-forward restarts from now — acceptable and honest for an OOS-in-time track.

## Testing

- **Domain:** pure-function tests per strategy — fixed input bars/funding → exact expected daily returns.
- **ForwardTracker:** inception-freeze, forward-only booking (`d > last_date`), idempotency (re-run books
  nothing), catch-up over a gap, `extra` round-trip for the live-booking pattern.
- **Services:** with fake data adapters (deterministic), assert the produced state file shape + values.
- **Round-trip:** produce state → `ForwardStateReader.read` → assert `ForwardSummary` (nav, days, sharpe, maxdd)
  and that `evaluate_gate` returns the expected status.
- **No live network** in any test. strict-mypy clean, matching the rest of the codebase.

## Out of scope (this increment)

- `poc`'s `backtest` and `regime` modes (Sharpe sweeps, capacity curves, per-year breakdown) — only the
  `paper(cfg)` forward path is ported. By extension: torch, `pit_sweep`, and the `signal_sweep` import.
- Any change to `cockpit_forward`, `ForwardStateReader` consuming logic, the cockpit DB schema, or the dashboard
  (the days-list unification means no new reader case is needed, including for poc).
- Real-capital execution. These remain paper-forward, gathering OOS track record only.

## Build order (subagent-driven)

1. **Foundation:** generic `ForwardTracker` + `ForwardStrategy` port; refactor `CombinedBookForwardTracker` onto it (combined tests stay green).
2. **Data ports + adapters:** `DailyBarClient` (Yahoo), `BinanceFundingClient`, `CrossVenueFundingClient`, `CryptoPerpBarClient` (Binance perps), `VolIndexClient` (Deribit) + fakes.
3. **Yahoo trio:** `sixtyforty`, `multistrat`, then `gd` (composes multistrat).
4. **Funding pair:** `funding`, `crossvenue` (live-booking + positions in `extra`).
5. **poc:** numpy momentum signal + VRP sleeve domain, service, live-from-lookback calibration.
6. **Orchestration:** 6 assets + Definitions/job/schedule wiring + `cockpit_forward` deps.
7. **Cron retirement** (foxhunt) + deploy + verify the cockpit shows all 7 strategies advancing.
