# Cross-Sectional Crypto Funding-Dispersion — Implementation Plan

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

**Goal:** Backtest (survivorship-free, PIT) a cross-sectional crypto funding-dispersion strategy across a matrix of constructions × liquidity floors, through the gauntlet, to find a real uncrowded small-capital edge — then surface the verdict.

**Architecture:** Pure domain (PIT eligibility + cross-sectional funding score + 3 construction modes + signed delta-neutral carry book-return) consumed by a walk-forward runner that reads the existing survivorship-free `crypto_pit` npz panel, producing a per-(mode × floor) daily return series, each scored by the existing gauntlet (DSR/OOS). A CLI runs it and prints the verdict matrix.

**Tech Stack:** Python 3.12, numpy, the `crypto_pit` npz panel (foxhunt `data/surfer/crypto_pit/`, 180 symbols incl. 25 delisted, 2019–2026), the gauntlet (`compute_stats`/`evaluate`), Typer CLI, pytest (`.venv/bin/python -m pytest`), strict mypy.

**Spec:** `docs/superpowers/specs/2026-06-18-cross-sectional-funding-design.md`.

**House rules:** TDD (failing test first). Pure functions in `domain/`. Synthetic in-memory panels in tests — NO live network, `.npz` loaded WITHOUT `allow_pickle`. Strict mypy (no NEW errors). Commit per task, do NOT push. Trailer:
```
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
```
**Crypto convention:** daily series, **365 periods/year** (pass `periods_per_year=365` to `compute_stats`).

**Scope:** data adapter → domain → runner → gauntlet verdict matrix → CLI. Paper-forward wiring of the *winning* construction is a deliberate follow-on AFTER we see the verdict (don't wire a possibly-falsified strategy into the cockpit) — NOT in this plan.

---

## Reuse contract (exact — verified this session)
```python
# src/fxhnt/domain/strategies/equity_factor.py
def robust_z(values: list[float | None], k: float = 3.0) -> list[float]   # median/MAD winsorized z, missing→0.0

# src/fxhnt/domain/backtest.py
class BacktestStats(BaseModel): cagr: float; ann_vol: float; sharpe: float; max_drawdown: float; n_obs: int
def compute_stats(returns: np.ndarray, periods_per_year: int = 252) -> BacktestStats

# src/fxhnt/domain/gauntlet/core.py
def evaluate(is_returns, oos_returns, n_trials, sr_variance, *, dsr_min=0.95, oos_min_sharpe=0.0,
             max_is_oos_decay=0.50, has_economic_rationale=None) -> Verdict
def per_period_sharpe(returns: np.ndarray) -> float    # per discovery.py; if the real name differs, use what discovery.py uses
class Verdict(BaseModel): passed; dsr; is_sharpe; oos_sharpe; n_trials; reasons; pvalue

# src/fxhnt/config.py — Settings.gauntlet has oos_fraction/dsr_min/oos_min_sharpe/max_is_oos_decay
```
**npz panel format** (verified): each `data/surfer/crypto_pit/<SYMBOL>.npz` has parallel arrays `day` (int epoch-day), `close`, `qvol` (daily USD quote-volume), `funding` (daily-summed 8h funding rate). Symbol = filename stem.

---

## File Structure
- Create `src/fxhnt/adapters/data/crypto_pit_panel.py` — npz loader → in-memory panel.
- Create `src/fxhnt/domain/cross_sectional_funding.py` — pure: `eligible_asof`, `funding_score`, `construction_weights`, `book_return_xs`, `_epoch_day`.
- Create `src/fxhnt/application/funding_backtest_runner.py` — walk-forward runner + `evaluate_funding_constructions`.
- Modify `src/fxhnt/config.py` — add `crypto_pit_dir`.
- Modify `src/fxhnt/cli.py` — add `backtest-funding`.
- Tests: `tests/unit/test_cross_sectional_funding.py`, `tests/integration/test_crypto_pit_panel.py`, `tests/integration/test_funding_backtest_runner.py`, `tests/integration/test_backtest_funding_cli.py`.

---

## Task 1: crypto_pit npz panel adapter

**Files:** Create `src/fxhnt/adapters/data/crypto_pit_panel.py`; Modify `src/fxhnt/config.py`; Test `tests/integration/test_crypto_pit_panel.py`.

- [ ] **Step 1: config** — add to `Settings` in `src/fxhnt/config.py` (mirror the existing `backtest_warehouse_path` field):
```python
    crypto_pit_dir: str = Field(default=str(_DATA_DIR / "crypto_pit"))   # FXHNT_CRYPTO_PIT_DIR; survivorship-free npz panel
```
(use the same `_DATA_DIR`/`Field` idiom already in the file.)

- [ ] **Step 2: failing test** `tests/integration/test_crypto_pit_panel.py`:
```python
import numpy as np
from fxhnt.adapters.data.crypto_pit_panel import CryptoPitPanelSource


def _write(tmp_path, sym, days, close, qvol, funding):
    np.savez(tmp_path / f"{sym}.npz",
             day=np.array(days, dtype=np.int64), close=np.array(close, dtype=float),
             qvol=np.array(qvol, dtype=float), funding=np.array(funding, dtype=float))


def test_loads_panel_from_npz(tmp_path):
    _write(tmp_path, "AAA", [100, 101], [1.0, 1.1], [2e6, 2e6], [0.001, 0.0012])
    _write(tmp_path, "DEAD", [100], [5.0], [1e6], [0.003])
    src = CryptoPitPanelSource(str(tmp_path))
    panel = src.panel()
    assert set(panel) == {"AAA", "DEAD"}
    assert panel["AAA"][100] == (1.0, 2e6, 0.001)        # (close, qvol, funding)
    assert panel["DEAD"][100] == (5.0, 1e6, 0.003)
    assert sorted(src.all_days()) == [100, 101]           # union of all symbols' days


def test_panel_ignores_non_npz_and_is_deterministic(tmp_path):
    _write(tmp_path, "BBB", [100], [1.0], [3e6], [0.0])
    (tmp_path / "notes.txt").write_text("ignore me")
    a = CryptoPitPanelSource(str(tmp_path)).panel()
    b = CryptoPitPanelSource(str(tmp_path)).panel()
    assert a == b and set(a) == {"BBB"}
```

- [ ] **Step 3: run → FAIL** `.venv/bin/python -m pytest tests/integration/test_crypto_pit_panel.py -v`

- [ ] **Step 4: implement** `src/fxhnt/adapters/data/crypto_pit_panel.py`:
```python
"""Loader for the survivorship-free `crypto_pit` npz panel (Binance USDT-perp daily
close/quote-volume/funding incl. delisted coins). Read-only; no live network."""
from __future__ import annotations

import glob
import os

import numpy as np


class CryptoPitPanelSource:
    def __init__(self, pit_dir: str) -> None:
        self._dir = pit_dir

    def panel(self) -> dict[str, dict[int, tuple[float, float, float]]]:
        """{symbol: {epoch_day: (close, qvol, funding)}}. Deterministic (sorted files)."""
        out: dict[str, dict[int, tuple[float, float, float]]] = {}
        for path in sorted(glob.glob(os.path.join(self._dir, "*.npz"))):
            sym = os.path.splitext(os.path.basename(path))[0]
            with np.load(path) as z:   # numeric arrays only; no allow_pickle
                days = z["day"].astype(np.int64)
                close = z["close"].astype(float)
                qvol = z["qvol"].astype(float)
                funding = z["funding"].astype(float)
            out[sym] = {int(d): (float(c), float(q), float(f))
                        for d, c, q, f in zip(days, close, qvol, funding)}
        return out

    def all_days(self) -> list[int]:
        return sorted({d for s in self.panel().values() for d in s})
```

- [ ] **Step 5: run → PASS**, then `.venv/bin/python -m mypy src/fxhnt/adapters/data/crypto_pit_panel.py src/fxhnt/config.py` → no new errors.

- [ ] **Step 6: commit**
```bash
git add src/fxhnt/adapters/data/crypto_pit_panel.py src/fxhnt/config.py tests/integration/test_crypto_pit_panel.py
git commit -m "feat(xsfunding): crypto_pit npz panel adapter (survivorship-free, read-only)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```

---

## Task 2: domain — PIT eligibility + cross-sectional funding score

**Files:** Create `src/fxhnt/domain/cross_sectional_funding.py`; Test `tests/unit/test_cross_sectional_funding.py`.

- [ ] **Step 1: failing tests** `tests/unit/test_cross_sectional_funding.py`:
```python
from fxhnt.domain.cross_sectional_funding import eligible_asof, funding_score, _epoch_day

# panel: {symbol: {epoch_day: (close, qvol, funding)}}
def _coin(start, n, qvol, funding, close=1.0):
    return {start + i: (close, qvol, funding) for i in range(n)}


def test_eligible_filters_by_liquidity_and_history_pit():
    panel = {
        "LIQ":   _coin(100, 40, 5e6, 0.001),   # liquid, long history
        "THIN":  _coin(100, 40, 1e5, 0.001),   # too illiquid
        "NEW":   _coin(135, 40, 5e6, 0.001),   # not enough history before asof
    }
    elig = eligible_asof(panel, asof_day=139, min_qvol=1e6, min_history=20, vol_window=30)
    assert elig == ["LIQ"]                       # THIN below floor, NEW lacks 20d history by 139


def test_funding_score_ranks_high_funding_higher():
    # three liquid coins, different trailing funding; score = robust_z of trailing-mean funding
    panel = {
        "HI":  _coin(100, 30, 5e6, 0.003),
        "MID": _coin(100, 30, 5e6, 0.001),
        "LO":  _coin(100, 30, 5e6, -0.001),
    }
    elig = ["HI", "MID", "LO"]
    sc = funding_score(panel, elig, asof_day=129, lookback_days=7)
    assert sc["HI"] > sc["MID"] > sc["LO"]


def test_funding_score_is_pit_no_lookahead():
    # LATE: funding low early, high late. At an EARLY asof it must NOT rank above a steady HI.
    panel = {
        "HI":   {100 + i: (1.0, 5e6, 0.002) for i in range(60)},
        "LATE": {100 + i: (1.0, 5e6, (0.0001 if i < 30 else 0.01)) for i in range(60)},
    }
    early = funding_score(panel, ["HI", "LATE"], asof_day=120, lookback_days=7)   # day120: LATE still in low regime
    assert early["HI"] > early["LATE"]
    late = funding_score(panel, ["HI", "LATE"], asof_day=159, lookback_days=7)    # day159: LATE now high
    assert late["LATE"] > late["HI"]


def test_epoch_day_roundtrip():
    assert _epoch_day("1970-01-02") == 1
```

- [ ] **Step 2: run → FAIL**

- [ ] **Step 3: implement** `src/fxhnt/domain/cross_sectional_funding.py` (part 1):
```python
"""Pure cross-sectional crypto funding-dispersion domain: PIT eligibility, the
cross-sectional funding score, construction weights, and signed delta-neutral
carry book-return. No I/O. Panel = {symbol: {epoch_day: (close, qvol, funding)}}."""
from __future__ import annotations

import datetime as dt

from fxhnt.domain.strategies.equity_factor import robust_z

Panel = dict[str, dict[int, tuple[float, float, float]]]


def _epoch_day(iso_date: str) -> int:
    return (dt.date.fromisoformat(iso_date) - dt.date(1970, 1, 1)).days


def eligible_asof(panel: Panel, asof_day: int, *, min_qvol: float,
                  min_history: int, vol_window: int = 30) -> list[str]:
    """Coins tradeable at `asof_day` (PIT): have >= `min_history` bars on/before asof,
    AND trailing-`vol_window` mean qvol >= `min_qvol`. Uses ONLY data <= asof_day."""
    out: list[str] = []
    lo = asof_day - vol_window
    for sym, series in panel.items():
        hist = [d for d in series if d <= asof_day]
        if len(hist) < min_history:
            continue
        vols = [series[d][1] for d in hist if d > lo]
        if vols and (sum(vols) / len(vols)) >= min_qvol:
            out.append(sym)
    out.sort()
    return out


def funding_score(panel: Panel, eligible: list[str], asof_day: int,
                  lookback_days: int = 7) -> dict[str, float]:
    """Cross-sectional carry score: robust_z (across `eligible`) of each coin's
    trailing-`lookback_days` mean funding (<= asof_day). Short window keeps it
    responsive (avoids stale-level bias). Returns {symbol: score} aligned to `eligible`."""
    lo = asof_day - lookback_days
    tf: list[float | None] = []
    for sym in eligible:
        series = panel[sym]
        vals = [series[d][2] for d in series if lo < d <= asof_day]
        tf.append(sum(vals) / len(vals) if vals else None)
    z = robust_z(tf)
    return {eligible[i]: z[i] for i in range(len(eligible))}
```

- [ ] **Step 4: run → PASS** `.venv/bin/python -m pytest tests/unit/test_cross_sectional_funding.py -v`

- [ ] **Step 5: mypy** on the new module → no errors.

- [ ] **Step 6: commit**
```bash
git add src/fxhnt/domain/cross_sectional_funding.py tests/unit/test_cross_sectional_funding.py
git commit -m "feat(xsfunding): PIT eligibility + cross-sectional funding score (robust_z of trailing funding)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```

---

## Task 3: domain — construction weights (3 modes) + signed carry book-return

**Files:** Modify `src/fxhnt/domain/cross_sectional_funding.py`; Test `tests/unit/test_cross_sectional_funding.py` (append).

- [ ] **Step 1: append failing tests:**
```python
from fxhnt.domain.cross_sectional_funding import construction_weights, book_return_xs

# scores aligned to a symbol list
SC = {"A": 2.0, "B": 1.0, "C": 0.0, "D": -1.0, "E": -2.0}   # A richest carry, E poorest


def test_long_tilt_longs_top_quantile_sums_to_one():
    w = construction_weights(SC, "long_tilt", quantile=0.4)   # top 40% of 5 = top 2 = A,B
    assert set(k for k, v in w.items() if v != 0) == {"A", "B"}
    assert all(v > 0 for v in w.values() if v != 0)
    assert abs(sum(w.values()) - 1.0) < 1e-9


def test_market_neutral_longs_top_shorts_bottom_sums_to_zero():
    w = construction_weights(SC, "market_neutral", quantile=0.4)   # long A,B ; short D,E
    assert w["A"] > 0 and w["B"] > 0 and w["D"] < 0 and w["E"] < 0
    assert abs(sum(w.values())) < 1e-9          # dollar-neutral
    assert abs(sum(abs(v) for v in w.values()) - 2.0) < 1e-9   # gross 2.0


def test_executable_shorts_only_borrowable_names():
    # only A,B,C are "borrowable"; market_neutral would short D,E but executable can't
    w = construction_weights(SC, "executable", quantile=0.4, borrowable={"A", "B", "C"})
    assert w["A"] > 0 and w["B"] > 0           # long side intact
    assert w.get("D", 0.0) == 0.0 and w.get("E", 0.0) == 0.0   # un-borrowable shorts dropped


def test_book_return_signed_carry_minus_cost():
    # long A (collect +funding), short E (collect -funding => profit when E funding negative)
    prev = {"A": 0.5, "E": -0.5}
    funding_today = {"A": 0.002, "E": -0.002}
    new = {"A": 0.5, "E": -0.5}                # no turnover
    cost = {"A": 0.001, "E": 0.001}
    r = book_return_xs(prev, funding_today, new, cost)
    # realized = 0.5*0.002 + (-0.5)*(-0.002) = 0.001 + 0.001 = 0.002 ; no turnover cost
    assert abs(r - 0.002) < 1e-12


def test_book_return_charges_turnover():
    prev = {"A": 1.0}
    new = {"B": 1.0}                            # full switch: turnover |0-1|_A + |1-0|_B = 2.0
    cost = {"A": 0.001, "B": 0.001}
    r = book_return_xs(prev, {"A": 0.0}, new, cost)
    # realized 0 ; cost = (|1|*0.001 + |1|*0.001)/2 = 0.001
    assert abs(r - (-0.001)) < 1e-12
```

- [ ] **Step 2: run → FAIL**

- [ ] **Step 3: implement** (append to `src/fxhnt/domain/cross_sectional_funding.py`):
```python
def _quantile_cut(scores: dict[str, float], quantile: float) -> tuple[list[str], list[str]]:
    """Return (top, bottom) symbol lists at the given quantile (by score desc)."""
    ranked = sorted(scores, key=lambda s: (-scores[s], s))
    k = max(1, int(quantile * len(ranked)))
    return ranked[:k], ranked[-k:]


def construction_weights(scores: dict[str, float], mode: str, *, quantile: float = 0.2,
                         borrowable: set[str] | None = None) -> dict[str, float]:
    """Map cross-sectional funding scores -> delta-neutral weights.
    long_tilt: equal-weight long the top quantile, sum=1.
    market_neutral: long top + short bottom, dollar-neutral (sum=0, gross=2).
    executable: long top (sum=1) + short bottom ONLY for names in `borrowable`."""
    top, bot = _quantile_cut(scores, quantile)
    w: dict[str, float] = {}
    if mode == "long_tilt":
        for s in top:
            w[s] = 1.0 / len(top)
    elif mode == "market_neutral":
        for s in top:
            w[s] = 0.5 / len(top)
        for s in bot:
            w[s] = w.get(s, 0.0) - 0.5 / len(bot)
    elif mode == "executable":
        for s in top:
            w[s] = 1.0 / len(top)
        shorts = [s for s in bot if borrowable is None or s in borrowable]
        for s in shorts:
            w[s] = w.get(s, 0.0) - 1.0 / len(shorts) if shorts else w.get(s, 0.0)
    else:
        raise ValueError(f"unknown construction mode: {mode}")
    return {s: v for s, v in w.items() if v != 0.0}


def book_return_xs(prev_weights: dict[str, float], funding_today: dict[str, float],
                   new_weights: dict[str, float], coin_cost_rt: dict[str, float]) -> float:
    """Signed delta-neutral carry on the PRIOR book at today's funding, minus round-trip
    cost on turnover. realized = sum(w_i * funding_i); a short (w<0) on a negative-funding
    coin earns w*funding>0. cost = sum(|Δw_i| * cost_i)/2 over the union."""
    realized = sum(w * funding_today.get(s, 0.0) for s, w in prev_weights.items())
    cost = sum(abs(new_weights.get(s, 0.0) - prev_weights.get(s, 0.0)) * coin_cost_rt.get(s, 0.0)
               for s in set(new_weights) | set(prev_weights)) / 2.0
    return realized - cost
```

- [ ] **Step 4: run → PASS**

- [ ] **Step 5: mypy** → no errors.

- [ ] **Step 6: commit**
```bash
git add src/fxhnt/domain/cross_sectional_funding.py tests/unit/test_cross_sectional_funding.py
git commit -m "feat(xsfunding): construction weights (long_tilt/market_neutral/executable) + signed carry book-return

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```

---

## Task 4: walk-forward backtest runner

**Files:** Create `src/fxhnt/application/funding_backtest_runner.py`; Test `tests/integration/test_funding_backtest_runner.py`.

- [ ] **Step 1: failing test** (synthetic panel where high-funding coins persist → long_tilt should be net positive):
```python
import numpy as np
from fxhnt.application.funding_backtest_runner import FundingBacktestRunner


def _panel():
    # 4 coins, 200 days; HI/MID positive funding, LO/NEG low/negative; all liquid
    panel = {}
    fund = {"HI": 0.0015, "MID": 0.0008, "LO": 0.0001, "NEG": -0.0010}
    for sym, f in fund.items():
        panel[sym] = {1000 + i: (1.0, 5e6, f) for i in range(200)}
    return panel


def test_runner_produces_series_per_mode():
    runner = FundingBacktestRunner(_panel(), min_qvol=1e6, min_history=20,
                                   lookback_days=7, quantile=0.5, cost_bps=8.0, slip_coef=0.0)
    res = runner.run()
    assert set(res.returns_by_mode) == {"long_tilt", "market_neutral", "executable"}
    for series in res.returns_by_mode.values():
        assert len(series) > 50 and np.isfinite(series).all()
    # long_tilt holds the high-funding coins -> positive cumulative carry
    assert float(np.sum(res.returns_by_mode["long_tilt"])) > 0.0


def test_runner_deterministic():
    r1 = FundingBacktestRunner(_panel(), min_qvol=1e6, min_history=20, lookback_days=7,
                               quantile=0.5, cost_bps=8.0, slip_coef=0.0).run().returns_by_mode["long_tilt"]
    r2 = FundingBacktestRunner(_panel(), min_qvol=1e6, min_history=20, lookback_days=7,
                               quantile=0.5, cost_bps=8.0, slip_coef=0.0).run().returns_by_mode["long_tilt"]
    assert np.array_equal(r1, r2)
```

- [ ] **Step 2: run → FAIL**

- [ ] **Step 3: implement** `src/fxhnt/application/funding_backtest_runner.py`:
```python
"""Walk-forward cross-sectional funding-dispersion backtest over the crypto_pit panel.
Daily rebalance, PIT universe, signed delta-neutral carry minus a liquidity-scaled cost.
Produces a daily net-return series per construction mode."""
from __future__ import annotations

from dataclasses import dataclass

import numpy as np

from fxhnt.domain.cross_sectional_funding import (
    Panel, book_return_xs, construction_weights, eligible_asof, funding_score,
)

_MODES = ("long_tilt", "market_neutral", "executable")


@dataclass(frozen=True)
class FundingRunResult:
    returns_by_mode: dict[str, np.ndarray]
    n_names_avg: float
    trading_days: int


class FundingBacktestRunner:
    def __init__(self, panel: Panel, *, min_qvol: float = 1e6, min_history: int = 30,
                 lookback_days: int = 7, quantile: float = 0.2, cost_bps: float = 8.0,
                 slip_coef: float = 0.0005, slip_cap_bps: float = 50.0,
                 liq_ref: float = 1e6, borrowable_qvol: float = 2e7) -> None:
        self._p = panel
        self._minq, self._minh, self._lb, self._q = min_qvol, min_history, lookback_days, quantile
        self._cost = cost_bps / 1e4
        self._slip_coef, self._slip_cap = slip_coef, slip_cap_bps / 1e4
        self._liq_ref, self._borrow_q = liq_ref, borrowable_qvol

    def _coin_cost(self, qvol: float) -> float:
        """Round-trip cost: taker fee both legs + liquidity-scaled slippage (thin=more)."""
        slip = min(self._slip_cap, self._slip_coef * (self._liq_ref / qvol)) if qvol > 0 else self._slip_cap
        return self._cost + slip

    def run(self) -> FundingRunResult:
        all_days = sorted({d for s in self._p.values() for d in s})
        series: dict[str, list[float]] = {m: [] for m in _MODES}
        prev_w: dict[str, dict[str, float]] = {m: {} for m in _MODES}
        names_seen = 0
        n_rebals = 0
        for i in range(len(all_days) - 1):
            d, nxt = all_days[i], all_days[i + 1]
            elig = eligible_asof(self._p, d, min_qvol=self._minq, min_history=self._minh)
            if len(elig) < 4:                       # need a cross-section to rank
                continue
            scores = funding_score(self._p, elig, d, lookback_days=self._lb)
            borrowable = {s for s in elig if self._p[s].get(d, (0, 0, 0))[1] >= self._borrow_q}
            funding_next = {s: self._p[s][nxt][2] for s in elig if nxt in self._p[s]}
            cost = {s: self._coin_cost(self._p[s][d][1]) for s in elig}
            names_seen += len(elig)
            n_rebals += 1
            for m in _MODES:
                w = construction_weights(scores, m, quantile=self._q, borrowable=borrowable)
                # carry the NEW book earns over [d, nxt] at next-day funding, minus rebalance cost vs prior book
                series[m].append(_book(w, prev_w[m], funding_next, cost))
                prev_w[m] = w
        return FundingRunResult(
            returns_by_mode={m: np.asarray(series[m], dtype=float) for m in _MODES},
            n_names_avg=(names_seen / n_rebals) if n_rebals else 0.0,
            trading_days=len(all_days),
        )


def _book(w: dict[str, float], prev_w: dict[str, float],
          funding_next: dict[str, float], cost: dict[str, float]) -> float:
    """Carry the NEW book `w` earns over the next day (sum w_i * funding_next_i) minus the
    round-trip rebalance cost charged on turnover between the prior book and the new one."""
    realized = sum(wt * funding_next.get(s, 0.0) for s, wt in w.items())
    turnover = sum(abs(w.get(s, 0.0) - prev_w.get(s, 0.0)) * cost.get(s, 0.0)
                   for s in set(w) | set(prev_w)) / 2.0
    return realized - turnover
```
(`book_return_xs` stays as the domain primitive exercised by the Task-3 unit tests; the runner uses this direct, clearer `_book`.)

- [ ] **Step 4: run → PASS.** If `long_tilt` isn't positive, debug by printing the realized series; do NOT weaken the determinism test. Adjust the synthetic fixture only if the quantile/360-day math genuinely needs it (report why).

- [ ] **Step 5: mypy** → no errors.

- [ ] **Step 6: commit**
```bash
git add src/fxhnt/application/funding_backtest_runner.py tests/integration/test_funding_backtest_runner.py
git commit -m "feat(xsfunding): walk-forward runner — PIT universe + per-mode daily carry series + liquidity-scaled cost

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```

---

## Task 5: gauntlet verdict matrix (modes × liquidity floors)

**Files:** Modify `src/fxhnt/application/funding_backtest_runner.py` (add `evaluate_funding_matrix`); Test `tests/integration/test_funding_backtest_runner.py` (append).

- [ ] **Step 1: append failing test:**
```python
from fxhnt.application.funding_backtest_runner import evaluate_funding_matrix


def test_evaluate_matrix_runs_all_cells(tmp_path):
    panel = _panel()
    report = evaluate_funding_matrix(panel, floors=[1e6, 5e6], lookback_days=7, quantile=0.5,
                                     cost_bps=8.0, slip_coef=0.0, oos_fraction=0.4)
    # 3 modes x 2 floors = 6 cells
    assert len(report["cells"]) == 6
    for key, cell in report["cells"].items():
        assert {"mode", "floor", "stats", "verdict"} <= set(cell)
        assert {"sharpe", "cagr", "max_drawdown", "n_obs"} <= set(cell["stats"])
        assert {"passed", "dsr", "is_sharpe", "oos_sharpe", "n_trials"} <= set(cell["verdict"])
        assert cell["verdict"]["n_trials"] == 6        # multiple-testing across the whole matrix
    import json
    json.dumps(report)                                  # JSON-serializable (no NaN/np types)
```

- [ ] **Step 2: run → FAIL**

- [ ] **Step 3: implement** (append to `src/fxhnt/application/funding_backtest_runner.py`):
```python
from typing import Any

from fxhnt.domain.backtest import compute_stats
from fxhnt.domain.gauntlet.core import evaluate, per_period_sharpe

_PERIODS_PER_YEAR = 365   # crypto trades daily, all year


def evaluate_funding_matrix(panel: Panel, *, floors: list[float], lookback_days: int = 7,
                            quantile: float = 0.2, cost_bps: float = 8.0, slip_coef: float = 0.0005,
                            oos_fraction: float = 0.40, dsr_min: float = 0.95,
                            oos_min_sharpe: float = 0.0, max_is_oos_decay: float = 0.50) -> dict[str, Any]:
    """Run the runner at each liquidity floor, gauntlet every (mode, floor) cell.
    n_trials = total cells tested (multiple-testing correction)."""
    runs = {f: FundingBacktestRunner(panel, min_qvol=f, lookback_days=lookback_days, quantile=quantile,
                                     cost_bps=cost_bps, slip_coef=slip_coef).run() for f in floors}
    series_by_cell = {(m, f): runs[f].returns_by_mode[m] for f in floors for m in _MODES}
    n_trials = len(series_by_cell)
    # cross-cell sr_variance (Bailey/Lopez de Prado), matching discovery.py convention
    is_sharpes = [per_period_sharpe(r[:int((1.0 - oos_fraction) * len(r))])
                  for r in series_by_cell.values() if len(r) > 3]
    sr_variance = float(np.var(is_sharpes)) if len(is_sharpes) > 1 else 0.0
    out: dict[str, Any] = {"cells": {}, "n_trials": n_trials,
                           "settings": {"floors": floors, "lookback_days": lookback_days,
                                        "quantile": quantile, "cost_bps": cost_bps,
                                        "slip_coef": slip_coef, "oos_fraction": oos_fraction}}
    for (m, f), r in series_by_cell.items():
        stats = compute_stats(r, periods_per_year=_PERIODS_PER_YEAR)
        key = f"{m}@{f:.0e}"
        if len(r) < 3:
            out["cells"][key] = {"mode": m, "floor": float(f), "stats": stats.model_dump(),
                                 "verdict": {"passed": False, "dsr": 0.0, "is_sharpe": 0.0,
                                             "oos_sharpe": 0.0, "n_trials": n_trials,
                                             "reasons": ["insufficient data"], "pvalue": 1.0}}
            continue
        split = int((1.0 - oos_fraction) * len(r))
        v = evaluate(r[:split], r[split:], n_trials=n_trials, sr_variance=sr_variance,
                     dsr_min=dsr_min, oos_min_sharpe=oos_min_sharpe, max_is_oos_decay=max_is_oos_decay,
                     has_economic_rationale=True)   # funding carry is a documented structural premium
        out["cells"][key] = {"mode": m, "floor": float(f),
                             "stats": stats.model_dump(), "verdict": v.model_dump()}
    return out
```

- [ ] **Step 4: run → PASS.** Verify `per_period_sharpe` is the real gauntlet helper name (check `domain/gauntlet/core.py` + how `application/discovery.py` computes `sr_variance`); if it differs, use the real one. Confirm `model_dump()` is JSON-native (no numpy scalars); if `compute_stats`/`evaluate` leak `np.float64`, coerce with `float(...)`.

- [ ] **Step 5: mypy** → no errors; full `.venv/bin/python -m pytest -q` → all PASS.

- [ ] **Step 6: commit**
```bash
git add src/fxhnt/application/funding_backtest_runner.py tests/integration/test_funding_backtest_runner.py
git commit -m "feat(xsfunding): gauntlet verdict matrix (modes x floors, crypto 365d, cross-cell DSR n_trials)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```

---

## Task 6: CLI `backtest-funding` + run the real verdict

**Files:** Modify `src/fxhnt/cli.py`; Test `tests/integration/test_backtest_funding_cli.py`.

- [ ] **Step 1: read** `src/fxhnt/cli.py` `backtest-equity` for the house idiom (`get_settings()`, function-local imports, `--out` json write, echo loop).

- [ ] **Step 2: failing CLI test** (mirror `test_backtest_equity_cli.py`): build a synthetic crypto_pit dir of npz (reuse the `_panel()` shape, write each coin to `<sym>.npz`), set `FXHNT_CRYPTO_PIT_DIR` to it (monkeypatch + `cli.get_settings.cache_clear()`), invoke `backtest-funding --floors 1e6 --out <path>`, assert exit 0 and the report JSON has cells for the 3 modes.
```python
import json
import numpy as np
from typer.testing import CliRunner
from fxhnt.cli import app


def _write_pit(d):
    fund = {"HI": 0.0015, "MID": 0.0008, "LO": 0.0001, "NEG": -0.0010}
    for sym, f in fund.items():
        days = np.array([1000 + i for i in range(200)], dtype=np.int64)
        np.savez(d / f"{sym}.npz", day=days, close=np.ones(200), qvol=np.full(200, 5e6),
                 funding=np.full(200, f))


def test_cli_backtest_funding_writes_report(tmp_path, monkeypatch):
    pit = tmp_path / "pit"; pit.mkdir(); _write_pit(pit)
    out = tmp_path / "funding_report.json"
    monkeypatch.setenv("FXHNT_CRYPTO_PIT_DIR", str(pit))
    from fxhnt import cli as climod
    climod.get_settings.cache_clear()
    res = CliRunner().invoke(app, ["backtest-funding", "--floors", "1e6", "--quantile", "0.5",
                                   "--cost-bps", "8", "--out", str(out)])
    assert res.exit_code == 0, res.output
    report = json.loads(out.read_text())
    modes = {c["mode"] for c in report["cells"].values()}
    assert modes == {"long_tilt", "market_neutral", "executable"}
```

- [ ] **Step 3: run → FAIL**

- [ ] **Step 4: implement** the command in `src/fxhnt/cli.py`:
```python
@app.command("backtest-funding")
def backtest_funding(
    floors: list[float] = typer.Option([1e6, 5e6, 2e7], "--floors", help="Liquidity floors ($/day) to sweep."),
    lookback_days: int = typer.Option(7, "--lookback-days", help="Trailing window for the funding score."),
    quantile: float = typer.Option(0.2, "--quantile", help="Top/bottom quantile for construction."),
    cost_bps: float = typer.Option(8.0, "--cost-bps", help="Taker round-trip cost (both legs), bps."),
    slip_coef: float = typer.Option(0.0005, "--slip-coef", help="Liquidity-scaled slippage coefficient."),
    out: str = typer.Option("/backtest-data/funding_backtest_report.json", "--out", help="Report JSON path."),
) -> None:
    """Cross-sectional crypto funding-dispersion backtest: verdict matrix (modes x liquidity floors)
    over the survivorship-free crypto_pit panel."""
    import json
    from fxhnt.adapters.data.crypto_pit_panel import CryptoPitPanelSource
    from fxhnt.application.funding_backtest_runner import evaluate_funding_matrix

    s = get_settings()
    panel = CryptoPitPanelSource(s.crypto_pit_dir).panel()
    report = evaluate_funding_matrix(panel, floors=floors, lookback_days=lookback_days, quantile=quantile,
                                     cost_bps=cost_bps, slip_coef=slip_coef,
                                     oos_fraction=s.gauntlet.oos_fraction, dsr_min=s.gauntlet.dsr_min,
                                     oos_min_sharpe=s.gauntlet.oos_min_sharpe,
                                     max_is_oos_decay=s.gauntlet.max_is_oos_decay)
    with open(out, "w") as fh:
        json.dump(report, fh, indent=2)
    for key, c in sorted(report["cells"].items()):
        v, st = c["verdict"], c["stats"]
        typer.echo(f"backtest-funding {key}: passed={v['passed']} dsr={v['dsr']:.3f} "
                   f"sharpe={st['sharpe']:.2f} cagr={st['cagr']*100:.1f}% maxDD={st['max_drawdown']*100:.1f}% -> {out}")
```
(Match the real `get_settings()` idiom + function-local imports from `backtest-equity`; there's no store to close here.)

- [ ] **Step 5: run → PASS**, full `.venv/bin/python -m pytest -q` → all PASS, mypy on cli.py → no NEW errors.

- [ ] **Step 6: commit**
```bash
git add src/fxhnt/cli.py tests/integration/test_backtest_funding_cli.py
git commit -m "feat(xsfunding): backtest-funding CLI — verdict matrix over crypto_pit panel

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```

- [ ] **Step 7: RUN THE REAL VERDICT** (controller does this, not a subagent): point at the on-disk panel and run the matrix:
```bash
FXHNT_CRYPTO_PIT_DIR=/home/jgrusewski/Work/foxhunt/data/surfer/crypto_pit \
  .venv/bin/fxhnt backtest-funding --floors 1e6 --floors 5e6 --floors 2e7 \
  --quantile 0.2 --cost-bps 8 --out /tmp/funding_report.json
```
Report the verdict matrix; apply the pre-registered kill criteria (DSR<0.5 / edge only sub-$1M-ADV / corr>0.6 to existing funding+momentum).

---

## Known caveats (flag, do not fix here)
- Delisted-ticker roster = the 35 famous blow-ups in the panel (survivorship-free for those, not an exhaustive census). Documented; future `data.binance.vision` hardening.
- Daily-summed funding only (no 8h). Daily rebalance.
- Cost model is a liquidity-scaled approximation, not order-book-exact — deliberately conservative.

## Final review (after Task 6)
Final code review over the whole diff; full suite + mypy. Then interpret the verdict and decide on the paper-forward follow-on. Do NOT push.
