docs(xsfunding): cross-sectional funding-dispersion implementation plan (6 tasks → verdict matrix)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-06-18 22:58:52 +02:00
parent 31aa746369
commit 549a21f660

View File

@@ -0,0 +1,694 @@
# 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, 20192026), 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.