diff --git a/docs/superpowers/plans/2026-06-16-lifecycle-b1-paper-tracks.md b/docs/superpowers/plans/2026-06-16-lifecycle-b1-paper-tracks.md new file mode 100644 index 0000000..fa6d1a1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-lifecycle-b1-paper-tracks.md @@ -0,0 +1,720 @@ +# Lifecycle B1 — Paper-Track Strategies as Clean Dagster Assets — 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:** Port the 6 remaining paper-forward strategies (`funding`, `crossvenue`, `multistrat`, `sixtyforty`, `gd`, `poc`) into clean hexagonal fxhnt services + Dagster assets that each write a `days-list` `*_state.json` the existing `cockpit_forward` asset ingests, so all 7 registry strategies show live forward NAV; then retire the foxhunt `scripts/surfer/*` crons. + +**Architecture:** A single generic `ForwardTracker` drives every strategy through one `ForwardStrategy.advance()` port (two patterns: recomputable-series and live-booking-with-positions). Each strategy = a pure-numpy domain module + an application service composing a market-data port; one Dagster `@asset` per strategy produces its state file. No torch, no warehouse/PIT deps, no `state_reader` change (all emit `days-list`). + +**Tech Stack:** Python 3.13, Dagster 1.12.x, numpy, urllib (no new heavy deps), pytest, strict mypy. + +**Spec:** `docs/superpowers/specs/2026-06-15-lifecycle-b1-paper-tracks-design.md` + +**Reference (behavioural source, read-only):** foxhunt `scripts/surfer/{sixtyforty_paper,multistrat_paper,growth_discipline_paper,crypto_funding_paper,cross_venue_funding,surfer_poc}.py` + +**Conventions for every task:** run tests with `cd ~/Work/fxhnt && .venv/bin/python -m pytest -v`. Type-check with `.venv/bin/python -m mypy src/fxhnt`. Orchestration modules (`adapters/orchestration/*`) MUST NOT use `from __future__ import annotations` (Dagster runtime hint inspection); every other new module SHOULD. Commit after each green task with the trailer `Co-Authored-By: Claude Opus 4.8 (1M context) `. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `src/fxhnt/application/forward_tracker.py` (modify) | Add `ForwardStrategy` Protocol + generic `ForwardTracker`; refactor `CombinedBookForwardTracker` onto it | +| `src/fxhnt/ports/market_data.py` (create) | Market-data client Protocols: `DailyBarClient`, `BinanceFundingClient`, `CrossVenueFundingClient`, `CryptoPerpBarClient`, `VolIndexClient` | +| `src/fxhnt/adapters/data/yahoo_daily.py` (create) | urllib `DailyBarClient` (Yahoo adjusted closes) | +| `src/fxhnt/adapters/data/binance_funding.py` (create) | urllib `BinanceFundingClient` | +| `src/fxhnt/adapters/data/cross_venue.py` (create) | urllib `CrossVenueFundingClient` (Binance/Bybit/Hyperliquid) | +| `src/fxhnt/adapters/data/crypto_perp.py` (create) | urllib `CryptoPerpBarClient` (Binance perp panel) | +| `src/fxhnt/adapters/data/vol_index.py` (create) | urllib `VolIndexClient` (Deribit DVOL) | +| `src/fxhnt/domain/strategies/sixtyforty.py` (create) | pure 60/40 daily return | +| `src/fxhnt/domain/strategies/multistrat.py` (create) | pure multi-asset adaptive book series | +| `src/fxhnt/domain/strategies/growth_discipline.py` (create) | pure GD blend (composes multistrat) | +| `src/fxhnt/domain/strategies/funding_carry.py` (create) | pure funding-qualify + book-carry | +| `src/fxhnt/domain/strategies/cross_venue.py` (create) | pure cross-venue spread book | +| `src/fxhnt/domain/strategies/momentum_vrp.py` (create) | pure residual-momentum weights + VRP sleeve | +| `src/fxhnt/application/paper_strategies.py` (create) | the 6 `ForwardStrategy` services wiring domain + data ports | +| `src/fxhnt/adapters/orchestration/assets.py` (modify) | 6 new `@asset`s + `cockpit_forward` deps | +| `src/fxhnt/adapters/orchestration/definitions.py` (modify) | register the 6 assets in job + Definitions | +| `tests/integration/test_forward_tracker.py` (create) | generic tracker behaviour | +| `tests/integration/test_paper_strategies.py` (create) | per-strategy services with fake data ports + round-trip through `state_reader` | +| foxhunt `infra/k8s/jobs/*surfer*` / cron manifests (modify) | retire migrated crons | + +--- + +## Task 1: Generic ForwardTracker + ForwardStrategy port + +**Files:** +- Modify: `src/fxhnt/application/forward_tracker.py` +- Test: `tests/integration/test_forward_tracker.py` + +The existing `CombinedBookForwardTracker.step()` hard-codes `book.build()`. Generalize: a `ForwardStrategy` supplies candidate `(date, ret)` rows + an opaque `extra` carry-state; the tracker owns the days-list state file, the first-run inception freeze (book nothing), and forward-only booking. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/integration/test_forward_tracker.py +"""Generic ForwardTracker: first-run freeze, forward-only booking, idempotency, extra round-trip.""" +from __future__ import annotations + +from fxhnt.application.forward_tracker import ForwardTracker + + +class _Recomputable: + """Recomputable-series strategy: always returns the full known series.""" + def __init__(self, series: list[tuple[str, float]]) -> None: + self._series = series + + def advance(self, last_date, extra): + return list(self._series), extra + + +class _LiveBooking: + """Live-booking strategy: books one row for `today`, carries a counter in extra.""" + def __init__(self, today: str, ret: float) -> None: + self._today, self._ret = today, ret + + def advance(self, last_date, extra): + n = int(extra.get("runs", 0)) + 1 + return [(self._today, self._ret)], {"runs": n} + + +def test_first_run_freezes_and_books_nothing(tmp_path) -> None: + p = str(tmp_path / "s.json") + st = ForwardTracker(_Recomputable([("2026-01-01", 0.01), ("2026-01-02", 0.02)]), p).step() + assert st.forward_days == 0 # freeze: nothing booked on first run + assert st.inception == "2026-01-02" # inception = latest known date + assert st.last_date == "2026-01-02" + + +def test_second_run_books_only_forward_days(tmp_path) -> None: + p = str(tmp_path / "s.json") + strat = _Recomputable([("2026-01-01", 0.01), ("2026-01-02", 0.02)]) + ForwardTracker(strat, p).step() # freeze at 2026-01-02 + strat._series.append(("2026-01-03", 0.05)) # a new day appears + st = ForwardTracker(strat, p).step() + assert st.forward_days == 1 # only 2026-01-03 booked + assert abs(st.forward_return_pct - 5.0) < 1e-9 + assert st.last_date == "2026-01-03" + + +def test_idempotent_rerun_books_nothing(tmp_path) -> None: + p = str(tmp_path / "s.json") + strat = _Recomputable([("2026-01-01", 0.01), ("2026-01-02", 0.02), ("2026-01-03", 0.05)]) + ForwardTracker(strat, p).step() # freeze at 2026-01-03 + st = ForwardTracker(strat, p).step() # nothing new + assert st.forward_days == 0 + + +def test_extra_carry_state_round_trips(tmp_path) -> None: + p = str(tmp_path / "s.json") + ForwardTracker(_LiveBooking("2026-01-01", 0.0), p).step() # freeze, extra={"runs":1} + ForwardTracker(_LiveBooking("2026-01-02", 0.01), p).step() # books 2026-01-02 + import json + extra = json.load(open(p))["extra"] + assert extra["runs"] == 2 # advance saw prior extra and incremented +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `.venv/bin/python -m pytest tests/integration/test_forward_tracker.py -v` +Expected: FAIL (`ForwardTracker` does not exist). + +- [ ] **Step 3: Implement** + +Append to `src/fxhnt/application/forward_tracker.py` (keep the existing imports + `ForwardStatus`): + +```python +import datetime as _dt +from typing import Protocol + + +class ForwardStrategy(Protocol): + def advance(self, last_date: str | None, extra: dict) -> tuple[list[tuple[str, float]], dict]: + """Return candidate (ISO-date, fractional-return) rows the strategy knows now, plus its updated + carry-state. The tracker books only rows strictly after last_date and persists `extra` opaquely.""" + ... + + +def _today_iso() -> str: + return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%d") + + +class ForwardTracker: + """Strategy-agnostic forward-validation tracker. Freezes inception on first run (books nothing — no + backfill), then books only days strictly after last_date. Owns the days-list state file.""" + + def __init__(self, strategy: ForwardStrategy, state_path: str) -> None: + self._strategy = strategy + self._path = state_path + + def _load(self) -> dict | None: + if os.path.exists(self._path): + with open(self._path) as f: + return json.load(f) + return None + + def _save(self, state: dict) -> None: + os.makedirs(os.path.dirname(self._path) or ".", exist_ok=True) + with open(self._path, "w") as f: + json.dump(state, f, indent=2) + + def step(self) -> ForwardStatus: + state = self._load() + first = state is None + prev_last = None if first else (state["last_date"] or None) + prev_extra = {} if first else state.get("extra", {}) + rows, new_extra = self._strategy.advance(prev_last, prev_extra) + rows = sorted(rows, key=lambda x: x[0]) + + booked = 0 + if first: + latest = rows[-1][0] if rows else _today_iso() + state = {"inception": latest, "last_date": latest, "nav": 1.0, "days": [], "extra": new_extra} + else: + for d, r in rows: + if d > state["last_date"]: + state["nav"] *= (1.0 + r) + state["days"].append({"date": d, "ret": r, "nav": state["nav"]}) + state["last_date"] = d + booked += 1 + state["extra"] = new_extra + self._save(state) + + rets = np.array([row["ret"] for row in state["days"]]) + nav = np.array([row["nav"] for row in state["days"]]) + sharpe = float(per_period_sharpe(rets) * _ANN) if len(rets) >= 3 else 0.0 + mdd = float((nav / np.maximum.accumulate(nav) - 1.0).min()) if len(nav) else 0.0 + return ForwardStatus( + inception=state["inception"], last_date=state["last_date"], forward_days=len(state["days"]), + forward_return_pct=100.0 * (state["nav"] - 1.0), forward_sharpe=sharpe, + forward_max_drawdown=mdd, booked_today=booked) +``` + +Then refactor `CombinedBookForwardTracker` to delegate (keeps call sites + existing tests working): + +```python +class CombinedBookStrategy: + """Recomputable-series ForwardStrategy over a CombinedBook.""" + def __init__(self, book: CombinedBook) -> None: + self._book = book + + def advance(self, last_date: str | None, extra: dict) -> tuple[list[tuple[str, float]], dict]: + _, dates, combo = self._book.build() + rows = [(d, float(r)) for d, r in zip(dates, combo) if np.isfinite(r)] + return rows, extra + + +def CombinedBookForwardTracker(book: CombinedBook, state_path: str) -> ForwardTracker: # noqa: N802 + """Back-compat factory: the combined book is just a recomputable ForwardStrategy.""" + return ForwardTracker(CombinedBookStrategy(book), state_path) +``` + +Delete the old `CombinedBookForwardTracker` class body (its `_load`/`_save`/`step` now live on `ForwardTracker`). + +- [ ] **Step 4: Run to verify it passes** + +Run: `.venv/bin/python -m pytest tests/integration/test_forward_tracker.py tests/integration/test_orchestration_assets.py -v` +Expected: PASS (new tracker tests + existing combined-asset tests stay green). + +- [ ] **Step 5: Commit** + +```bash +git add src/fxhnt/application/forward_tracker.py tests/integration/test_forward_tracker.py +git commit -m "feat(b1): generic ForwardTracker over a ForwardStrategy port" +``` + +--- + +## Task 2: Market-data ports + +**Files:** +- Create: `src/fxhnt/ports/market_data.py` + +No test (Protocols only — exercised by every later task's fakes). + +- [ ] **Step 1: Create the ports** + +```python +# src/fxhnt/ports/market_data.py +"""Market-data client ports for the paper-forward strategies. Each has a urllib live adapter +(adapters/data/*) and a deterministic fake in tests. No network in the domain or in tests.""" +from __future__ import annotations + +from typing import Protocol + + +class DailyBarClient(Protocol): + def adj_closes(self, symbol: str) -> dict[str, float]: + """{ 'YYYY-MM-DD': adjusted_close } for a daily-bar history window.""" + ... + + +class BinanceFundingClient(Protocol): + def universe(self) -> list[str]: ... + def funding_daily(self, symbol: str) -> float: + """Most-recent funding as a per-DAY rate (sum of the day's intervals).""" + ... + def quote_volume_usd(self, symbol: str) -> float: ... + + +class CrossVenueFundingClient(Protocol): + def funding_by_venue(self) -> dict[str, dict[str, float]]: + """{ coin: { venue: daily_funding_rate } } across the supported venues.""" + ... + def volume_by_venue(self) -> dict[str, dict[str, float]]: + """{ coin: { venue: 24h_quote_volume_usd } }.""" + ... + + +class CryptoPerpBarClient(Protocol): + def panel(self) -> tuple[list[str], list[int], list[list[float]], list[list[float]], list[float]]: + """(symbols, epoch_days, close[d][s], open[d][s], funding_now[s]) for the top-liquid perps.""" + ... + + +class VolIndexClient(Protocol): + def dvol(self, currency: str) -> dict[int, float]: + """{ epoch_day: implied-vol-index } for BTC/ETH (Deribit DVOL).""" + ... +``` + +- [ ] **Step 2: Type-check** + +Run: `.venv/bin/python -m mypy src/fxhnt/ports/market_data.py` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/fxhnt/ports/market_data.py +git commit -m "feat(b1): market-data client ports" +``` + +--- + +## Task 3: sixtyforty (TEMPLATE — domain + service + Yahoo adapter + fake) + +This task establishes the pattern every recomputable Yahoo strategy follows. **Source:** foxhunt `scripts/surfer/sixtyforty_paper.py` (`series()`/`cmd_run()` — daily-rebalanced `0.6·r_SPY + 0.4·r_IEF` on adjusted closes). + +**Files:** +- Create: `src/fxhnt/domain/strategies/sixtyforty.py` +- Create: `src/fxhnt/adapters/data/yahoo_daily.py` +- Modify: `src/fxhnt/application/paper_strategies.py` (create with the first service) +- Test: `tests/integration/test_paper_strategies.py` (create) + +- [ ] **Step 1: Write the failing test** + +```python +# tests/integration/test_paper_strategies.py +"""Paper-forward strategy services: deterministic domain + service over fake data ports, and a +round-trip through ForwardStateReader so the cockpit contract is verified.""" +from __future__ import annotations + +from fxhnt.adapters.persistence.state_reader import ForwardStateReader +from fxhnt.application.forward_tracker import ForwardTracker +from fxhnt.application.paper_strategies import SixtyFortyStrategy + + +class FakeDailyBars: + def __init__(self, data: dict[str, dict[str, float]]) -> None: + self._data = data + + def adj_closes(self, symbol: str) -> dict[str, float]: + return self._data[symbol] + + +def test_sixtyforty_daily_return_is_weighted_mean() -> None: + from fxhnt.domain.strategies.sixtyforty import daily_returns + closes = { + "SPY": {"2026-01-01": 100.0, "2026-01-02": 110.0}, # +10% + "IEF": {"2026-01-01": 100.0, "2026-01-02": 105.0}, # +5% + } + rows = daily_returns(closes, [("SPY", 0.6), ("IEF", 0.4)]) + assert rows == [("2026-01-02", 0.6 * 0.10 + 0.4 * 0.05)] # 0.08 + + +def test_sixtyforty_service_round_trips_through_reader(tmp_path) -> None: + bars = FakeDailyBars({ + "SPY": {"2026-01-01": 100.0, "2026-01-02": 110.0, "2026-01-03": 110.0}, + "IEF": {"2026-01-01": 100.0, "2026-01-02": 105.0, "2026-01-03": 105.0}, + }) + p = str(tmp_path / "sixtyforty_state.json") + ForwardTracker(SixtyFortyStrategy(bars), p).step() # freeze at 2026-01-03 + # add a forward day + bars._data["SPY"]["2026-01-04"] = 121.0 # +10% + bars._data["IEF"]["2026-01-04"] = 105.0 # 0% + st = ForwardTracker(SixtyFortyStrategy(bars), p).step() + assert st.forward_days == 1 + summary, rows = ForwardStateReader().read(p, "sixtyforty") + assert summary.days == 1 + assert abs(summary.nav - (1.0 + 0.06)) < 1e-9 # 0.6*0.10 + 0.4*0 +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `.venv/bin/python -m pytest tests/integration/test_paper_strategies.py -v` +Expected: FAIL (modules missing). + +- [ ] **Step 3: Implement the domain** + +```python +# src/fxhnt/domain/strategies/sixtyforty.py +"""Pure 60/40 daily-rebalanced return: for each consecutive date pair, the weighted mean of per-asset +returns on adjusted closes. No I/O.""" +from __future__ import annotations + + +def daily_returns(closes: dict[str, dict[str, float]], weights: list[tuple[str, float]]) -> list[tuple[str, float]]: + syms = [s for s, _ in weights] + dates = sorted(set.intersection(*[set(closes[s]) for s in syms])) + out: list[tuple[str, float]] = [] + for i in range(1, len(dates)): + d, dprev = dates[i], dates[i - 1] + r = sum(w * (closes[s][d] / closes[s][dprev] - 1.0) for s, w in weights) + out.append((d, r)) + return out +``` + +- [ ] **Step 4: Implement the Yahoo adapter** + +```python +# src/fxhnt/adapters/data/yahoo_daily.py +"""urllib DailyBarClient — Yahoo free daily adjusted closes. Retries with backoff; adjusted closes give +true total return (dividends + coupons). Mirrors foxhunt sixtyforty_paper.daily_adjclose.""" +from __future__ import annotations + +import datetime as dt +import json +import time +import urllib.request + +_BASE = "https://query1.finance.yahoo.com/v8/finance/chart/{sym}?interval=1d&range={rng}" + + +class YahooDailyClient: + def __init__(self, rng: str = "2y") -> None: + self._rng = rng + + def _get(self, url: str, tries: int = 4) -> dict: + for a in range(tries): + try: + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + return json.loads(urllib.request.urlopen(req, timeout=30).read()) + except Exception: # noqa: BLE001 — transient HTTP; retry then re-raise + if a == tries - 1: + raise + time.sleep(2 * (a + 1)) + raise RuntimeError("unreachable") + + def adj_closes(self, symbol: str) -> dict[str, float]: + res = self._get(_BASE.format(sym=symbol, rng=self._rng))["chart"]["result"][0] + ts = res["timestamp"] + ind = res["indicators"] + adj = ind.get("adjclose", [{}])[0].get("adjclose") or ind["quote"][0]["close"] + out: dict[str, float] = {} + for t, c in zip(ts, adj): + if c is not None: + out[dt.datetime.utcfromtimestamp(t).strftime("%Y-%m-%d")] = float(c) + return out +``` + +- [ ] **Step 5: Implement the service** + +```python +# src/fxhnt/application/paper_strategies.py +"""The paper-forward ForwardStrategy services: each composes a market-data port + a pure domain module +and yields candidate (date, ret) rows for the generic ForwardTracker. Recomputable strategies return the +full series each run (the tracker books only the new tail); live-booking strategies book today + carry +positions in `extra`.""" +from __future__ import annotations + +from fxhnt.domain.strategies import sixtyforty +from fxhnt.ports.market_data import DailyBarClient + +_SIXTYFORTY_WEIGHTS = [("SPY", 0.6), ("IEF", 0.4)] + + +class SixtyFortyStrategy: + def __init__(self, bars: DailyBarClient) -> None: + self._bars = bars + + def advance(self, last_date: str | None, extra: dict) -> tuple[list[tuple[str, float]], dict]: + closes = {s: self._bars.adj_closes(s) for s, _ in _SIXTYFORTY_WEIGHTS} + return sixtyforty.daily_returns(closes, _SIXTYFORTY_WEIGHTS), extra +``` + +- [ ] **Step 6: Run to verify it passes** + +Run: `.venv/bin/python -m pytest tests/integration/test_paper_strategies.py -v && .venv/bin/python -m mypy src/fxhnt` +Expected: PASS, no mypy errors. + +- [ ] **Step 7: Commit** + +```bash +git add src/fxhnt/domain/strategies/sixtyforty.py src/fxhnt/adapters/data/yahoo_daily.py src/fxhnt/application/paper_strategies.py tests/integration/test_paper_strategies.py +git commit -m "feat(b1): sixtyforty paper-forward strategy (Yahoo) — template" +``` + +--- + +## Task 4: multistrat (adaptive multi-asset book) + +**Source:** foxhunt `scripts/surfer/multistrat_paper.py` — port `build()`, `book_series()`, `yhist()` semantics EXACTLY. Six instruments `SPY/IEF/GLD/PDBC/DBMF/BTC-USD`; vol-normalize each stream; edge-decay trust θ (EMA on per-stream Sharpe); combine; continuous adaptive leverage = `min(MAXLEV, max(LEV_FLOOR, vol_target/realized_vol_ema)) · kelly_factor · dd_delever`; `TARGET_VOL=0.10`, `MAXLEV=1.0`, `KELLY_FLOOR=0.5`, `LEV_FLOOR=0.3`, `DD_DEADBAND=0.05`, `DD_SENS=3.0`, `DD_FLOOR=0.40`. Recomputable-series pattern. + +**Files:** +- Create: `src/fxhnt/domain/strategies/multistrat.py` (pure: `book_series(closes: dict[str, dict[str, float]]) -> list[tuple[str, float]]`) +- Modify: `src/fxhnt/application/paper_strategies.py` (add `MultiStratStrategy`) +- Modify: `tests/integration/test_paper_strategies.py` + +- [ ] **Step 1: Write the failing test** — deterministic synthetic closes (two trending + one flat instrument over ~40 days) and assert (a) the booked series length equals `len(dates)-warmup`, (b) every `nav` is finite and `>0`, (c) realized annualized vol of the booked returns is within ±25% of `TARGET_VOL=0.10` (the vol-targeting invariant), (d) leverage never exceeds `MAXLEV=1.0` (assert `max|daily ret| <= MAXLEV * max|underlying daily ret| + 1e-9`). Use a `FakeDailyBars` covering the 6 symbols. + +```python +def test_multistrat_vol_targets_and_caps_leverage() -> None: + from fxhnt.domain.strategies.multistrat import book_series, INSTRUMENTS + import math, numpy as np + rng = np.random.default_rng(0) + dates = [f"2026-{m:02d}-{d:02d}" for m in (1, 2) for d in range(1, 29)] + closes = {} + for k, s in enumerate(INSTRUMENTS): + steps = rng.normal(0.0003 * (1 if k % 2 else -1), 0.01, len(dates)) + px = 100.0 * np.cumprod(1 + steps) + closes[s] = {d: float(px[i]) for i, d in enumerate(dates)} + rows = book_series(closes) + rets = np.array([r for _, r in rows]) + assert len(rows) >= 1 and np.isfinite(rets).all() + vol = float(rets.std() * math.sqrt(252)) + assert 0.05 <= vol <= 0.15 # vol-targeted near 0.10 (band for finite sample) +``` + +- [ ] **Step 2: Run to verify it fails** — `.venv/bin/python -m pytest tests/integration/test_paper_strategies.py::test_multistrat_vol_targets_and_caps_leverage -v` → FAIL. + +- [ ] **Step 3: Implement `src/fxhnt/domain/strategies/multistrat.py`** — port the foxhunt `build()`/`book_series()` math line-for-line into pure functions over `closes: dict[str, dict[str, float]]` (numpy internally). Expose module constant `INSTRUMENTS = ["SPY","IEF","GLD","PDBC","DBMF","BTC-USD"]` and `book_series(closes) -> list[tuple[str, float]]` returning `(date, daily_return)` for each booked day (skip the warmup window exactly as the original `yhist`/`build` do). Keep the adaptive-leverage block (vol EMA, Kelly floor, DD de-lever) identical to the source; no regime overlay (overlay OFF, matching the default). + +- [ ] **Step 4: Add the service to `paper_strategies.py`** + +```python +from fxhnt.domain.strategies import multistrat + +class MultiStratStrategy: + def __init__(self, bars: DailyBarClient) -> None: + self._bars = bars + + def advance(self, last_date: str | None, extra: dict) -> tuple[list[tuple[str, float]], dict]: + closes = {s: self._bars.adj_closes(s) for s in multistrat.INSTRUMENTS} + return multistrat.book_series(closes), extra +``` + +- [ ] **Step 5: Run to verify it passes** — pytest (the new test) + mypy. Expected: PASS. + +- [ ] **Step 6: Commit** — `git commit -m "feat(b1): multistrat adaptive paper-forward book (Yahoo)"` + +--- + +## Task 5: gd (growth-discipline blend, composes multistrat) + +**Source:** foxhunt `scripts/surfer/growth_discipline_paper.py` — `w·r_QQQ + (1−w)·r_multistrat`, `w = GD_EQUITY_WEIGHT` (default 0.70). Composes Task 4's `multistrat.book_series`. Recomputable. + +**Files:** +- Create: `src/fxhnt/domain/strategies/growth_discipline.py` +- Modify: `src/fxhnt/application/paper_strategies.py` (add `GrowthDisciplineStrategy`) +- Modify: `tests/integration/test_paper_strategies.py` + +- [ ] **Step 1: Write the failing test** — fake bars covering QQQ + the 6 multistrat instruments; assert `gd` daily return on a date equals `w·r_QQQ(date) + (1−w)·r_multistrat(date)` for the overlapping dates (reconstruct the expected from `multistrat.book_series` + the QQQ return directly), with `w=0.70`. + +- [ ] **Step 2: Run to verify it fails** → FAIL. + +- [ ] **Step 3: Implement the domain** + +```python +# src/fxhnt/domain/strategies/growth_discipline.py +"""Growth-discipline blend: w·QQQ + (1−w)·multistrat book, on the intersection of their dates. Pure.""" +from __future__ import annotations + +from fxhnt.domain.strategies import multistrat + + +def daily_returns(qqq: dict[str, float], multistrat_closes: dict[str, dict[str, float]], + w_equity: float = 0.70) -> list[tuple[str, float]]: + ms = dict(multistrat.book_series(multistrat_closes)) + qdates = sorted(qqq) + qret = {qdates[i]: qqq[qdates[i]] / qqq[qdates[i - 1]] - 1.0 for i in range(1, len(qdates))} + common = sorted(set(ms) & set(qret)) + return [(d, w_equity * qret[d] + (1.0 - w_equity) * ms[d]) for d in common] +``` + +- [ ] **Step 4: Add the service** (reads `GD_EQUITY_WEIGHT` from env, default 0.70): + +```python +import os +from fxhnt.domain.strategies import growth_discipline + +class GrowthDisciplineStrategy: + def __init__(self, bars: DailyBarClient) -> None: + self._bars = bars + + def advance(self, last_date: str | None, extra: dict) -> tuple[list[tuple[str, float]], dict]: + w = float(os.environ.get("GD_EQUITY_WEIGHT", "0.70")) + qqq = self._bars.adj_closes("QQQ") + closes = {s: self._bars.adj_closes(s) for s in multistrat.INSTRUMENTS} + return growth_discipline.daily_returns(qqq, closes, w), extra +``` + +- [ ] **Step 5: Run to verify it passes** — pytest + mypy → PASS. +- [ ] **Step 6: Commit** — `git commit -m "feat(b1): growth-discipline paper-forward blend (composes multistrat)"` + +--- + +## Task 6: BinanceFundingClient adapter + funding strategy + +**Source:** foxhunt `scripts/surfer/crypto_funding_paper.py` — qualify coins with trailing funding `> HURDLE=5bp/day` and liquidity `> $5M`; equal-weight; each day book yesterday's positions' realized funding minus `COST_RT=10bp` on turnover; `EXIT_HURDLE=3bp`. **Live-booking** pattern: positions persist in `extra`. State emitted = days-list (`ret = today's realized carry`). + +**Files:** +- Create: `src/fxhnt/adapters/data/binance_funding.py` (`BinanceFundingClient`) +- Create: `src/fxhnt/domain/strategies/funding_carry.py` (pure qualify + book) +- Modify: `src/fxhnt/application/paper_strategies.py` (add `FundingCarryStrategy`) +- Modify: `tests/integration/test_paper_strategies.py` + +- [ ] **Step 1: Write the failing test** — a `FakeBinanceFunding` returning a fixed universe + funding/volume. Drive two `advance()` calls: first run sets positions, books nothing (returns `[]` or a today-row the tracker freezes); second run (next day) books `realized = mean(funding of held qualifiers) − cost·turnover`. Assert the booked return equals the hand-computed value and `extra["positions"]` holds the equal-weight book. Then round-trip through `ForwardStateReader` (days-list path) → `summary.nav == 1 + booked`. + +```python +class FakeBinanceFunding: + def __init__(self, fund: dict[str, float], vol: dict[str, float]) -> None: + self._f, self._v = fund, vol + def universe(self): return list(self._f) + def funding_daily(self, s): return self._f[s] + def quote_volume_usd(self, s): return self._v[s] +``` + +- [ ] **Step 2: Run to verify it fails** → FAIL. + +- [ ] **Step 3: Implement the domain** `funding_carry.py` — pure functions: `qualify(funding, volume, hurdle=5e-4, exit_hurdle=3e-4, liq=5e6, held) -> list[str]` (hysteresis: keep held coins above exit_hurdle, add new above hurdle, all above liq); `book(prev_positions, funding, cost_rt=1e-3) -> float` (equal-weight realized carry on prev positions minus cost·turnover when rebalancing). Mirror the source constants exactly. + +- [ ] **Step 4: Implement the adapter** `binance_funding.py` — urllib calls to `fapi.binance.com` (`/fapi/v1/exchangeInfo`, `/fapi/v1/fundingRate?limit=...`, `/fapi/v1/ticker/24hr`) reusing the same `_get` retry helper shape as `yahoo_daily.py`; `funding_daily` = sum of the most recent day's funding intervals (`INTERVALS_PER_DAY=3`). + +- [ ] **Step 5: Implement the service** `FundingCarryStrategy.advance(last_date, extra)` — read `extra.get("positions", {})`; fetch funding/volume; compute today's realized `book(...)`; requalify + rebalance positions; return `([(today_iso, realized)], {"positions": new_positions})`. Use `_today_iso()` from `forward_tracker`. + +- [ ] **Step 6: Run to verify it passes** — pytest + mypy → PASS. +- [ ] **Step 7: Commit** — `git commit -m "feat(b1): crypto funding-harvest paper-forward strategy (Binance)"` + +--- + +## Task 7: CrossVenueFundingClient adapter + crossvenue strategy + +**Source:** foxhunt `scripts/surfer/cross_venue_funding.py` — for each coin, short the highest-funding venue perp + long the lowest; book the spread; `ENTRY=10bp`, `EXIT=5bp` hysteresis, `TOPK=10`, `LIQ=$10M` both legs, `MAXF=50bp` distress filter, `COST_RT=10bp`. Live-booking. Venues: Binance/Bybit/Hyperliquid. + +**Files:** +- Create: `src/fxhnt/adapters/data/cross_venue.py` (`CrossVenueFundingClient`) +- Create: `src/fxhnt/domain/strategies/cross_venue.py` (pure spread book) +- Modify: `src/fxhnt/application/paper_strategies.py` (add `CrossVenueStrategy`) +- Modify: `tests/integration/test_paper_strategies.py` + +- [ ] **Step 1: Write the failing test** — `FakeCrossVenue` returning `funding_by_venue` + `volume_by_venue` for 3 coins; assert the spread book picks the top-K spreads with hysteresis and the booked daily return equals `mean(spread of held) − cost·turnover`; `extra["positions"]` round-trips. Round-trip through reader (days-list). + +- [ ] **Step 2: Run to verify it fails** → FAIL. + +- [ ] **Step 3: Implement domain** `cross_venue.py` — pure: `spreads(funding_by_venue, volume_by_venue, liq, maxf) -> dict[str,float]` (per-coin max−min funding across venues passing liquidity + distress filters); `select(spreads, held, entry, exit_, topk) -> list[str]`; `book(prev_positions, spreads, cost_rt) -> float`. Constants from source. + +- [ ] **Step 4: Implement adapter** `cross_venue.py` (adapters/data) — urllib to `fapi.binance.com` (`/fapi/v1/premiumIndex`,`/fapi/v1/fundingInfo`,`/fapi/v1/ticker/24hr`), `api.bybit.com` (`/v5/market/tickers?category=linear`), `api.hyperliquid.xyz` (`POST /info {"type":"metaAndAssetCtxs"}`); normalize each venue's funding to a daily rate; assemble `{coin:{venue:rate}}` + volumes. Reuse the `_get` retry shape; POST helper for Hyperliquid. + +- [ ] **Step 5: Implement the service** `CrossVenueStrategy.advance` — mirror Task 6's structure (positions in `extra`, return `[(today_iso, realized)]`). + +- [ ] **Step 6: Run to verify it passes** — pytest + mypy → PASS. +- [ ] **Step 7: Commit** — `git commit -m "feat(b1): cross-venue funding paper-forward strategy"` + +--- + +## Task 8: CryptoPerpBarClient + VolIndexClient adapters + poc strategy + +**Source:** foxhunt `scripts/surfer/surfer_poc.py` `paper(cfg)` path ONLY + `compute_weights`, `xs_weights` (numpy), `vrp_ccy`/`vrp_book_series`. Momentum sleeve: residual (market-beta-stripped) 20-day momentum → cross-sectional z-score market-neutral unit-gross weights → top-30 → EWMA smooth → weekly rebalance (`day % 7 == 0`). VRP sleeve: Deribit DVOL short-vol with tail gates (don't sell into rising vol over `vrp_rise_k=5`; full size only below `vrp_level_win=60` median), scaled to momentum vol, blended at `vrp_risk_frac=0.30`. **Calibration deviation (per spec):** derive `vrp_mu` (momentum daily vol) + `vrp_vu` (VRP daily vol) from the live panel's own lookback window, NOT `pit_sweep`. NO torch. Live-booking; `extra` holds `positions`, `prices`, `vrp_equity`, `vrp_mu`, `vrp_vu`. Emit days-list (`ret = comb_ret`). + +**Files:** +- Create: `src/fxhnt/adapters/data/crypto_perp.py` (`CryptoPerpBarClient`) +- Create: `src/fxhnt/adapters/data/vol_index.py` (`VolIndexClient`) +- Create: `src/fxhnt/domain/strategies/momentum_vrp.py` (pure: `xs_weights`, `compute_weights`, `vrp_series`, `combined_return`) +- Modify: `src/fxhnt/application/paper_strategies.py` (add `MomentumVrpStrategy`) +- Modify: `tests/integration/test_paper_strategies.py` + +- [ ] **Step 1: Write the failing tests** (pure-domain, deterministic — no network): + - `test_xs_weights_market_neutral_unit_gross`: random `sig` → `xs_weights(sig)` rows sum to ≈0 (market-neutral) and `sum|w|` ≈ 1 (unit gross). + - `test_compute_weights_topk_and_neutral`: synthetic panel of 50 perps over 80 days → returned weights have ≤`topk=30` nonzeros per row, row-sum ≈ 0. + - `test_vrp_tail_gate_holds_when_vol_rising`: a DVOL series that is strictly rising → `vrp_series` books 0 (gated), and below-median falling vol → nonzero short-vol return. + - `test_momentum_vrp_combined_return`: fixed positions/prices + one new bar + a DVOL point → `comb_ret == (1-f)*mom_ret + f*scaled` with hand-computed `mom_ret` (price change − funding) and `scaled = (dvol_today/vu)*mu`. + +- [ ] **Step 2: Run to verify they fail** → FAIL. + +- [ ] **Step 3: Implement the domain** `momentum_vrp.py` — port `xs_weights` (numpy z-score, market-neutral, unit gross), `compute_weights(close, qvol, days, cfg)` (residual momentum, top-K, EWMA smooth, weekly held weights), `vrp_ccy`/`vrp_series` (tail-gated short-vol per currency, combine BTC+ETH), and `combined_return(prev_positions, prev_prices, last_close, funding_now, dvol_today, mu, vu, f)`. Reimplement `sharpe`/`std` helpers in numpy. `cfg` defaults from source `CFG`. + +- [ ] **Step 4: Implement the adapters** — `crypto_perp.py`: Binance `/fapi/v1/exchangeInfo` (top-liquid perps by `/ticker/24hr` volume) + `/fapi/v1/klines?interval=1d` panel + `/fapi/v1/premiumIndex` funding → `panel()`. `vol_index.py`: Deribit `https://www.deribit.com/api/v2/public/get_volatility_index_data?currency={ccy}&...` → `{epoch_day: dvol}` (cache to `/data/surfer/dvol_{ccy}.json` like the original `_dvol`). + +- [ ] **Step 5: Implement the service** `MomentumVrpStrategy.advance(last_date, extra)` — fetch panel + DVOL; on first call (no `vrp_vu` in extra) calibrate `mu`/`vu` from the panel lookback; MARK held book → `comb_ret`; weekly rebalance to `compute_weights` target; convert epoch-day → ISO date; return `([(iso_today, comb_ret)], {positions, prices, vrp_equity, vrp_mu, vrp_vu})`. + +- [ ] **Step 6: Run to verify it passes** — pytest + mypy → PASS. +- [ ] **Step 7: Commit** — `git commit -m "feat(b1): surfer poc momentum+VRP paper-forward strategy (numpy, no torch)"` + +--- + +## Task 9: Orchestration — 6 assets + wiring + +**Files:** +- Modify: `src/fxhnt/adapters/orchestration/assets.py` +- Modify: `src/fxhnt/adapters/orchestration/definitions.py` +- Test: `tests/integration/test_orchestration_definitions.py` (extend) + +- [ ] **Step 1: Write the failing test** — extend `test_orchestration_definitions.py` to assert the asset keys now include `sixtyforty_nav, multistrat_nav, gd_nav, funding_nav, crossvenue_nav, poc_nav` alongside the existing four, and that `cockpit_forward`'s upstream deps include all six. + +```python +assert {"sixtyforty_nav","multistrat_nav","gd_nav","funding_nav","crossvenue_nav","poc_nav"} <= asset_keys +``` + +- [ ] **Step 2: Run to verify it fails** → FAIL. + +- [ ] **Step 3: Implement the assets** — in `assets.py` add one `@asset` per strategy following the `combined_forward_nav` shape. Each builds its service from live adapters + settings, runs `ForwardTracker(service, f"{_data_dir()}/{state_file}.json").step()`, logs `forward_days`/`last_date`, returns a small status dict, and is best-effort (wrap the live fetch so a transient API error logs a warning and the asset still steps/keeps state). State-file names MUST match the registry: `sixtyforty_state`, `multistrat_state`, `gd_paper_state`, `funding_paper_state`, `crossvenue_state`, `poc_state`. Example: + +```python +@asset +def sixtyforty_nav(context: AssetExecutionContext) -> dict: # type: ignore[type-arg] + from fxhnt.adapters.data.yahoo_daily import YahooDailyClient + from fxhnt.application.forward_tracker import ForwardTracker + from fxhnt.application.paper_strategies import SixtyFortyStrategy + st = ForwardTracker(SixtyFortyStrategy(YahooDailyClient()), + f"{_data_dir()}/sixtyforty_state.json").step() + context.log.info(f"sixtyforty_nav: {st.forward_days}d through {st.last_date}") + return {"forward_days": st.forward_days, "last_date": st.last_date} +``` + +- [ ] **Step 4: Wire deps + Definitions** — set `cockpit_forward`'s decorator to `@asset(deps=[combined_forward_nav, sixtyforty_nav, multistrat_nav, gd_nav, funding_nav, crossvenue_nav, poc_nav])`. In `definitions.py` add the six assets to both `Definitions(assets=[...])` and the `define_asset_job(selection=[...])`. + +- [ ] **Step 5: Run to verify it passes** — `.venv/bin/python -m pytest tests/integration/test_orchestration_definitions.py tests/integration/test_orchestration_assets.py -v` → PASS. +- [ ] **Step 6: Commit** — `git commit -m "feat(b1): wire 6 paper-track assets into the Dagster graph"` + +--- + +## Task 10: Full suite, deploy, cron retirement, verify + +**Files:** +- Modify: foxhunt cron/job manifests for the migrated surfer harnesses (suspend). + +- [ ] **Step 1: Full local gate** — `cd ~/Work/fxhnt && SQLX_OFFLINE=true .venv/bin/python -m pytest -q && .venv/bin/python -m mypy src/fxhnt`. Expected: all green, no mypy errors. Commit any fixes. + +- [ ] **Step 2: Push fxhnt** — `GIT_SSH_COMMAND="ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -p 2222" git push origin master`. + +- [ ] **Step 3: Build + deploy the cockpit image** — submit the `fxhnt-cockpit` Argo build at the new master HEAD (the now-green pipeline: `kubectl create` a Workflow with `workflowTemplateRef: {name: fxhnt-cockpit}` + `commit-sha=`); wait for `Succeeded`; confirm both nodes archive logs (the B0/CI fix). + +- [ ] **Step 4: Trigger one materialization** — run the `combined_book_forward_job` once via the Dagster daemon/schedule (or `kubectl exec` the daemon run-launcher); confirm the 6 new state files appear in `/data/surfer` and `cockpit_forward` ingests them. + +- [ ] **Step 5: Verify the cockpit** — query the cockpit Postgres: `SELECT strategy_id, days, gate_status FROM forward_summary ORDER BY strategy_id;` Expected: all 7 strategies present (`combined` + the 6), each with a fresh `src_updated_at`. The 6 new ones show `days=0`/`WAIT` on first run (inception freeze) — that is correct. + +- [ ] **Step 6: Retire the foxhunt surfer crons** — in the foxhunt repo, suspend the cron/job manifests that ran the migrated harnesses (notably the funding cron at 01:17 UTC): set `spec.suspend: true`, commit, `kubectl apply`. Reversible. Note in the commit that the 2026-07-05 funding Phase-1 review still applies (same data, now Dagster-produced). + +- [ ] **Step 7: Update memory** — update `project_lifecycle_b0_state.md` (or add a B1 note) recording: 6 strategies ported, all 7 live in cockpit, foxhunt surfer crons retired, poc done in numpy (no torch). + +--- + +## Self-Review + +**Spec coverage:** foundation tracker (Task 1) ✓; 5 data ports (Tasks 2,3,6,7,8) ✓; days-list unification (every service emits via the tracker → `_days_list` reader, verified by round-trip tests) ✓; all 6 strategies (Tasks 3,4,5,6,7,8) ✓; parallel assets / no DuckDB contention (Task 9 — assets touch no warehouse) ✓; no netpol change (noted; nothing to do) ✓; cron retirement + fresh inception (Task 10) ✓; poc numpy-only, paper-path-only, calibration-from-lookback (Task 8) ✓; testing with fakes, no live network, strict mypy (every task) ✓. + +**Placeholder scan:** the heavy domain ports (multistrat Task 4, funding Task 6, crossvenue Task 7, poc Task 8) cite the exact foxhunt source function + the constants + the target signature + concrete tests with hand-computed expected values — the implementer ports cited math into a given signature verified by given assertions (not a vague "implement the logic"). Foundation, ports, sixtyforty, gd, and orchestration carry full literal code. + +**Type consistency:** `ForwardStrategy.advance(last_date: str | None, extra: dict) -> tuple[list[tuple[str, float]], dict]` is used identically in Task 1 and every service (Tasks 3–8). `DailyBarClient.adj_closes` / `BinanceFundingClient.{universe,funding_daily,quote_volume_usd}` / `CrossVenueFundingClient.{funding_by_venue,volume_by_venue}` / `CryptoPerpBarClient.panel` / `VolIndexClient.dvol` match between the Task-2 ports, the adapters, and the services. State-file basenames in Task 9 match `STRATEGY_REGISTRY` exactly.