# Unified DB-backed Backtest Architecture (Phase 0a) 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:** One registry-driven backtest→ref path so every reconciliation-gated strategy always has a `backtest_summary` (a CI-enforced invariant), fixing the live gate bugs, with Timescale as the one durable store and DuckDB as an ephemeral engine.

**Architecture:** A `backtest_ref(strategy_id, store) -> BacktestSummary | None` dispatch (on a new `definition.backtest.kind`: `recompute-replay` / `sleeve` / `report` / `none`) replaces the three fragmented mechanisms. Cheap kinds run inline in the nightly assets; heavy `report` kinds run in a dedicated resourced scheduled step (own memory) using an ephemeral DuckDB that ATTACHes Timescale. A registry-iterating invariant test makes a gated-but-ref-less strategy a CI failure.

**Tech Stack:** Python 3.12, SQLAlchemy 2.0 (Postgres/Timescale SSOT), Dagster, `duckdb` 1.5.3 (postgres extension), pytest.

## Global Constraints

- **Branch:** `feat/unified-db-backtest-architecture`. Never commit to `master`.
- **Real money errs false-WAIT:** a backtest that fails/returns None → NO ref written → the gate WAITs (never false-PASS). `reconciliation_gate.py` already treats `backtest=None` as WAIT — preserve that.
- **The invariant:** every `STRATEGY_REGISTRY` entry with `gate_spec.gate_type == "reconciliation"` MUST declare a non-`none` `definition.backtest.kind` AND have a constructible provider. A registry-iterating test enforces this (CI failure otherwise).
- **backtest kinds (verbatim):** `recompute-replay` (build_strategy().advance(None,{}) → rows), `sleeve` (sleeve_returns_from_store(sid, store) → rows), `report` (per-sid dispatch to `bybit_4edge_backtest_summary(store, …)` — the real bybit book engine, NOT the B3-equity `backtest_summaries_from_report`), `none` (absolute-gated benchmark, no ref).
- **Single-SSOT / deterministic-forward-state:** Timescale is the one truth; the DuckDB engine is ephemeral (per-run, no file). Cheap recompute refs stay inline in the nightly `*_nav` assets; only heavy `report` refs move to the resourced scheduled step (the 4Gi Dagster daemon must NOT run the heavy backtest — it OOM'd on the bybit sleeve-ret).
- **Out of scope:** Binance/Alpaca-exec cleanup (0b), cockpit Ops/Fleet UI (Phase 2), reviving equity-factor research (park B3, don't migrate its data).
- Verify the cockpit LOCALLY (xsfunding gate un-sticks). Every review Minor is a latent bug — fix in-loop.

---

## File Structure

| File | Responsibility |
|---|---|
| `src/fxhnt/application/backtest_refs.py` (new) | `backtest_ref(strategy_id, store) -> BacktestSummary\|None` dispatch + per-strategy provider registry |
| `src/fxhnt/registry.py` (modify) | add `definition.backtest = {"kind": ...}` per entry; `xsfunding` → `sleeve` |
| `src/fxhnt/adapters/warehouse/timescale_duckdb_engine.py` (new) | `TimescaleDuckDbEngine` — ephemeral DuckDB ATTACH Timescale for heavy `report` backtests |
| `src/fxhnt/adapters/orchestration/assets.py` (modify) | route inline recompute/sleeve refs through `backtest_ref`; add xsfunding's ref |
| `src/fxhnt/cli.py` (modify) | `backtest-refs --all` command (the scheduled step); redirect `bybit-persist-sleeve-ret` into it |
| `src/fxhnt/adapters/orchestration/definitions.py` (modify) | (if used) schedule wiring |
| `infra/k8s/jobs/fxhnt-backtest-refs.yaml` (new) | scheduled resourced CronJob (own memory), replacing the manual sleeve-ret Job |
| `infra/k8s/jobs/` (retire) | the B3 backtest DuckDB PVC + `fxhnt-bybit-sleeve-ret-job.yaml` + `fxhnt-backtest-ingest/verdict-job.yaml` (park) |
| tests | `test_backtest_refs.py`, `test_backtest_invariant.py`, `test_timescale_duckdb_engine.py` |

---

## Task 1: The `backtest_ref` dispatch + `backtest.kind` registry field

**Files:**
- Create: `src/fxhnt/application/backtest_refs.py`
- Modify: `src/fxhnt/registry.py` (add `definition.backtest.kind` to every entry)
- Test: `tests/unit/test_backtest_refs.py`

**Interfaces:**
- Consumes: `BacktestSummary` (`fxhnt.application.forward_models`), `backtest_summary_from_rows(strategy_id, rows)` (`fxhnt.application.bybit_forward_track`), `STRATEGY_REGISTRY` (`fxhnt.registry`), the `AnalyticalStore` port.
- Produces: `backtest_ref(strategy_id: str, store, *, build_strategy=None) -> BacktestSummary | None`; `registry_backtest_kind(strategy_id: str) -> str`.

- [ ] **Step 1: Write the failing test**

```python
# tests/unit/test_backtest_refs.py
from fxhnt.application.backtest_refs import backtest_ref, registry_backtest_kind


class _ReplayStrat:
    def advance(self, last, extra):
        # a 3-day inception series -> a real BacktestSummary
        return [("2024-01-01", 0.01), ("2024-01-02", -0.005), ("2024-01-03", 0.008)], {}


def test_recompute_replay_produces_summary():
    sm = backtest_ref("multistrat", store=None, build_strategy=lambda: _ReplayStrat())
    assert sm is not None
    assert sm.strategy_id == "multistrat"
    assert sm.sharpe == sm.sharpe   # finite (not NaN)


def test_none_kind_returns_none():
    # sixtyforty is absolute-gated -> backtest.kind == "none" -> no ref
    assert registry_backtest_kind("sixtyforty") == "none"
    assert backtest_ref("sixtyforty", store=None) is None
```

- [ ] **Step 2: Run to verify failure**

Run: `.venv/bin/python -m pytest tests/unit/test_backtest_refs.py -q`
Expected: FAIL (`ModuleNotFoundError: fxhnt.application.backtest_refs`).

- [ ] **Step 3: Add `definition.backtest.kind` to every registry entry** (`src/fxhnt/registry.py`). For each entry add a `"backtest": {"kind": "..."}` inside `definition`, using:
  - `sixtyforty` → `"none"` (absolute gate)
  - `multistrat`, `vrp`, `crypto_tstrend`, `stablecoin_rotation`, `unlock` → `"recompute-replay"`
  - `xsfunding` → `"sleeve"`
  - `bybit_4edge`, `bybit_4edge_levered`, `positioning` → `"report"`
  - the `*_exec` twins (`multistrat_exec`, `vrp_exec`, `bybit_4edge_exec`) → `"none"` (observed twins reconcile against the MODELED book's ref, they don't produce their own)

- [ ] **Step 4: Implement the dispatch** (`src/fxhnt/application/backtest_refs.py`)

```python
"""The ONE backtest->ref path. Every reconciliation-gated strategy produces its `backtest_summary` here,
dispatched on `STRATEGY_REGISTRY[sid]["definition"]["backtest"]["kind"]`. Replaces the three fragmented
mechanisms (inline _persist_track_backtest_ref, the bybit-persist-sleeve-ret Job, the B3 equity runner).
A missing/failed ref is best-effort (returns None) -> the gate WAITs (false-WAIT-safe)."""
from __future__ import annotations

from typing import Any

from fxhnt.application.bybit_forward_track import backtest_summary_from_rows
from fxhnt.application.forward_models import BacktestSummary
from fxhnt.registry import STRATEGY_REGISTRY


def registry_backtest_kind(strategy_id: str) -> str:
    return STRATEGY_REGISTRY[strategy_id]["definition"].get("backtest", {}).get("kind", "none")


def backtest_ref(strategy_id: str, store: Any, *, build_strategy: Any = None) -> BacktestSummary | None:
    """Produce the reconciliation-gate backtest reference for `strategy_id`, or None (gate WAITs).
    `build_strategy` (a zero-arg callable returning a ForwardStrategy) is required for `recompute-replay`.
    `store` (an AnalyticalStore) is required for `sleeve`/`report`."""
    kind = registry_backtest_kind(strategy_id)
    if kind == "none":
        return None
    if kind == "recompute-replay":
        if build_strategy is None:
            return None
        rows, _ = build_strategy().advance(None, {})
        return backtest_summary_from_rows(strategy_id, rows) if len(rows) >= 2 else None
    if kind == "sleeve":
        from fxhnt.application.bybit_book_eval import sleeve_returns_from_store
        rows = sleeve_returns_from_store(strategy_id, store)   # [(iso_date, ret), ...]
        return backtest_summary_from_rows(strategy_id, rows) if len(rows) >= 2 else None
    if kind == "report":
        from fxhnt.application.backtest_refs_report import report_backtest_summary
        return report_backtest_summary(strategy_id, store)     # built in Task 5
    raise ValueError(f"unknown backtest kind {kind!r} for {strategy_id}")
```

*(Note for the implementer: read `sleeve_returns_from_store`'s real signature in `src/fxhnt/application/bybit_book_eval.py` and adapt the call/return-shape exactly; it is the same engine `funding_dispersion_eval.py` already reuses for xsfunding's return series. If it returns a bare return array rather than `(iso,ret)` rows, wrap it into the `(iso_date, ret)` shape `backtest_summary_from_rows` expects.)*

- [ ] **Step 5: Run tests to verify pass**

Run: `.venv/bin/python -m pytest tests/unit/test_backtest_refs.py -q`
Expected: PASS (2 passed). (`test_none_kind_returns_none` requires the `sixtyforty` registry `backtest.kind="none"` from Step 3.)

- [ ] **Step 6: Commit**

```bash
git add src/fxhnt/application/backtest_refs.py src/fxhnt/registry.py tests/unit/test_backtest_refs.py
git commit -m "feat(backtest): registry-driven backtest_ref dispatch + backtest.kind field"
```

---

## Task 2: Fix xsfunding (bug A) — its `sleeve` provider yields a real ref

**Files:**
- Modify: `src/fxhnt/application/backtest_refs.py` (confirm the `sleeve` path works for `xsfunding`)
- Test: `tests/unit/test_backtest_refs.py` (extend)

**Interfaces:**
- Consumes: `sleeve_returns_from_store("xsfunding", store)` (`fxhnt.application.bybit_book_eval`), the `AnalyticalStore` port.

- [ ] **Step 1: Write the failing test** (a fake store yielding a funding panel → xsfunding sleeve returns → a ref)

```python
# tests/unit/test_backtest_refs.py  (append)
def test_xsfunding_sleeve_produces_ref_bug_A(monkeypatch):
    """Regression for the live bug: xsfunding is reconciliation-gated but had NO backtest ref (gate stuck at
    WAIT forever). Its `sleeve` provider must yield a BacktestSummary from the funding panel."""
    import fxhnt.application.backtest_refs as mod
    monkeypatch.setattr(mod, "registry_backtest_kind", lambda sid: "sleeve")
    # stub the sleeve engine to a deterministic 3-day return series
    import fxhnt.application.bybit_book_eval as bbe
    monkeypatch.setattr(bbe, "sleeve_returns_from_store",
                        lambda sid, store: [("2024-03-01", 0.002), ("2024-03-02", 0.0015), ("2024-03-03", 0.003)])
    sm = mod.backtest_ref("xsfunding", store=object())
    assert sm is not None and sm.strategy_id == "xsfunding"
```

- [ ] **Step 2: Run to verify fail/pass**

Run: `.venv/bin/python -m pytest tests/unit/test_backtest_refs.py::test_xsfunding_sleeve_produces_ref_bug_A -v`
Expected: PASS if Task 1's `sleeve` branch is correct; if it FAILS on the `sleeve_returns_from_store` call/return shape, fix the branch (Step 3) to match the real engine.

- [ ] **Step 3: Reconcile the `sleeve` branch with the real engine.** Read `src/fxhnt/application/bybit_book_eval.py::sleeve_returns_from_store` and `src/fxhnt/application/funding_dispersion_eval.py` (which already calls it for xsfunding). Ensure the `sleeve` branch in `backtest_refs.py` passes the store the engine expects (a `_UniverseRestrictedStore`-compatible `AnalyticalStore`) and converts its output into the `(iso_date, ret)` rows `backtest_summary_from_rows` consumes. Do NOT reimplement the return engine — reuse it.

- [ ] **Step 4: Run tests to verify pass**

Run: `.venv/bin/python -m pytest tests/unit/test_backtest_refs.py -q`
Expected: PASS (3 passed).

- [ ] **Step 5: Commit**

```bash
git add src/fxhnt/application/backtest_refs.py tests/unit/test_backtest_refs.py
git commit -m "fix(backtest): xsfunding sleeve backtest ref (bug A — reconciliation gate had no ref)"
```

---

## Task 3: The invariant test (a gated-but-ref-less strategy = CI failure)

**Files:**
- Test: `tests/unit/test_backtest_invariant.py`

**Interfaces:**
- Consumes: `STRATEGY_REGISTRY`, `registry_backtest_kind`, `track_builders` (`fxhnt.adapters.orchestration.migration_builders`).

- [ ] **Step 1: Write the invariant test**

```python
# tests/unit/test_backtest_invariant.py
from fxhnt.application.backtest_refs import registry_backtest_kind
from fxhnt.registry import STRATEGY_REGISTRY


def test_every_reconciliation_gated_strategy_has_a_backtest_provider():
    """THE invariant: a reconciliation-gated strategy without a non-`none` backtest kind can never gate-PASS
    (its gate WAITs forever on a missing ref). Making that a CI failure is what turns a silent live gap into
    a caught error. If you add a reconciliation-gated strategy, declare its `definition.backtest.kind`."""
    offenders = []
    for sid, entry in STRATEGY_REGISTRY.items():
        if entry.get("gate_spec", {}).get("gate_type") == "reconciliation":
            if registry_backtest_kind(sid) == "none":
                offenders.append(sid)
    assert not offenders, f"reconciliation-gated strategies with no backtest ref provider: {offenders}"
```

- [ ] **Step 2: Run — it should PASS** (after Tasks 1-2 declared kinds for every reconciliation-gated strategy incl. xsfunding).

Run: `.venv/bin/python -m pytest tests/unit/test_backtest_invariant.py -v`
Expected: PASS. If it FAILS, the failure names the offending strategy id(s) — go add their `backtest.kind` to the registry (that is the invariant doing its job).

- [ ] **Step 3: Commit**

```bash
git add tests/unit/test_backtest_invariant.py
git commit -m "test(backtest): invariant — every reconciliation-gated strategy must declare a backtest provider"
```

---

## Task 4: `TimescaleDuckDbEngine` — ephemeral DuckDB ATTACH Timescale

**Files:**
- Create: `src/fxhnt/adapters/warehouse/timescale_duckdb_engine.py`
- Test: `tests/unit/test_timescale_duckdb_engine.py`

**Interfaces:**
- Produces: `TimescaleDuckDbEngine(dsn: str)` with `scan(sql: str) -> list[tuple]` (run a columnar query over ATTACHed Timescale) and a context-manager `__enter__/__exit__` that connects + `INSTALL/LOAD postgres` + `ATTACH`.

- [ ] **Step 1: Write the failing test** (against a local sqlite-or-duckdb-attachable fixture — the ATTACH-to-postgres path is integration-only, so the unit test exercises the SQL-scan shape on an in-memory table)

```python
# tests/unit/test_timescale_duckdb_engine.py
from fxhnt.adapters.warehouse.timescale_duckdb_engine import TimescaleDuckDbEngine


def test_engine_scans_an_in_memory_table():
    eng = TimescaleDuckDbEngine("postgresql://unused")   # dsn unused in the in-memory scan path
    with eng.in_memory() as con:                          # a test seam: an in-memory DuckDB, no ATTACH
        con.execute("CREATE TABLE t AS SELECT * FROM (VALUES (1, 0.5), (2, 0.7)) AS v(sym, ret)")
        rows = con.execute("SELECT sym, ret FROM t ORDER BY sym").fetchall()
    assert rows == [(1, 0.5), (2, 0.7)]
```

- [ ] **Step 2: Run to verify failure**

Run: `.venv/bin/python -m pytest tests/unit/test_timescale_duckdb_engine.py -q`
Expected: FAIL (`ModuleNotFoundError`).

- [ ] **Step 3: Implement the engine**

```python
"""Ephemeral columnar engine for HEAVY `report` backtests: an in-memory DuckDB that ATTACHes the live
Timescale (Postgres) so a backtest can scan/aggregate the SSOT columnar-fast WITHOUT a persistent DuckDB
file. Per-run, discarded after — Timescale stays the one durable store. (Verified: duckdb 1.5.3 `INSTALL
postgres; LOAD postgres; ATTACH` works.)"""
from __future__ import annotations

import contextlib


class TimescaleDuckDbEngine:
    def __init__(self, dsn: str) -> None:
        # DuckDB's postgres extension wants a libpq DSN (postgresql://...), not SQLAlchemy's +psycopg form.
        self._dsn = dsn.replace("postgresql+psycopg://", "postgresql://")

    @contextlib.contextmanager
    def in_memory(self):  # type: ignore[no-untyped-def]
        """A bare in-memory DuckDB connection (no ATTACH) — the unit-test seam + the compute scratch space."""
        import duckdb
        con = duckdb.connect()
        try:
            yield con
        finally:
            con.close()

    @contextlib.contextmanager
    def attached(self):  # type: ignore[no-untyped-def]
        """In-memory DuckDB with the live Timescale ATTACHed read-only as schema `ts`. Heavy backtests scan
        `ts.<table>` columnar-fast, then write results back to Timescale via the normal repo (NOT via DuckDB)."""
        import duckdb
        con = duckdb.connect()
        try:
            con.execute("INSTALL postgres; LOAD postgres;")
            con.execute(f"ATTACH '{self._dsn}' AS ts (TYPE postgres, READ_ONLY);")
            yield con
        finally:
            con.close()

    def scan(self, sql: str) -> list[tuple]:  # type: ignore[type-arg]
        with self.attached() as con:
            return con.execute(sql).fetchall()
```

- [ ] **Step 4: Run tests to verify pass**

Run: `.venv/bin/python -m pytest tests/unit/test_timescale_duckdb_engine.py -q`
Expected: PASS (1 passed).

- [ ] **Step 5: Commit**

```bash
git add src/fxhnt/adapters/warehouse/timescale_duckdb_engine.py tests/unit/test_timescale_duckdb_engine.py
git commit -m "feat(backtest): TimescaleDuckDbEngine — ephemeral DuckDB ATTACH Timescale (no file)"
```

---

## Task 5: The `report` provider (bybit book) + `backtest-refs --all` CLI

**Files:**
- Create: `src/fxhnt/application/backtest_refs_report.py`
- Modify: `src/fxhnt/cli.py` (add `backtest-refs`; redirect `bybit-persist-sleeve-ret` to it)
- Test: `tests/unit/test_backtest_refs_report.py`

**Interfaces:**
- Consumes: `bybit_4edge_backtest_summary(store, *, universe, cost_bps, unlock_events, leverage_shape, strategy_id, sleeves, positioning_capacity_capital, positioning_tail_cap_k)` + `_LEVERED_SLEEVE_SHAPE` + `POSITIONING_TAIL_CAP_K` (`fxhnt.application.bybit_forward_track`); `liquid_universe(store, min_dollar_vol)` (`fxhnt.application.bybit_liquidity`); `load_unlock_events(operational_unlock_repo(settings), path)` + `operational_unlock_repo` (`fxhnt.application.unlock_calendar_loader`); `_data_dir` (`fxhnt.adapters.orchestration.assets`); `get_settings`; `ForwardNavRepo.upsert_backtest_summary`. **The real reuse target is the existing bybit book engine `bybit_4edge_backtest_summary` (cli.py:3135-3157 `bybit-persist-sleeve-ret` calls it 3×, once per report-kind sid) — NOT `backtest_summaries_from_report`/`evaluate_constructions`, which are the unrelated B3 equity-factor path whose `_CONSTRUCTION_TO_SID` maps only `eqfactor_*` sids and would silently return `[]` for bybit sids (the exact false-WAIT this task prevents).** Task 4's `TimescaleDuckDbEngine` is NOT consumed here — the bybit book reads via the FeatureStore (`store`), not DuckDB.
- Produces: `report_backtest_summary(strategy_id, store) -> BacktestSummary | None`; CLI `backtest-refs --all`.

- [ ] **Step 1: Write the failing test**

Prove the provider DISPATCHES to the real bybit engine with the correct per-sid kwargs (stub the
engine so the test is fast and does not touch a store). The engine's math is already covered by the
bybit forward-track tests — this test guards the report→ref dispatch, not the return-series math.

```python
# tests/unit/test_backtest_refs_report.py
from fxhnt.application.forward_models import BacktestSummary


def _fake_summary(strategy_id="bybit_4edge"):
    return BacktestSummary(strategy_id=strategy_id, as_of="2024-06-01", cagr=1.2, ann_vol=0.4,
                           sharpe=1.8, max_drawdown=-0.2, passed=True, dsr=0.9, is_sharpe=1.5,
                           oos_sharpe=1.4, pvalue=0.01)


def test_report_provider_dispatches_levered_kwargs(monkeypatch):
    import fxhnt.application.backtest_refs_report as mod
    calls = []

    def fake_engine(store, **kw):
        calls.append(kw)
        return _fake_summary(kw.get("strategy_id", "bybit_4edge"))

    monkeypatch.setattr(mod, "bybit_4edge_backtest_summary", fake_engine)
    monkeypatch.setattr(mod, "liquid_universe", lambda store, **kw: {"BTC", "ETH"})
    monkeypatch.setattr(mod, "load_unlock_events", lambda *a, **k: [])
    monkeypatch.setattr(mod, "_data_dir", lambda: "/tmp")

    sm = mod.report_backtest_summary("bybit_4edge_levered", store=object())
    assert sm is not None and sm.strategy_id == "bybit_4edge_levered"
    assert calls[-1]["leverage_shape"] == mod._LEVERED_SLEEVE_SHAPE
    assert calls[-1]["strategy_id"] == "bybit_4edge_levered"


def test_report_provider_positioning_single_sleeve(monkeypatch):
    import fxhnt.application.backtest_refs_report as mod
    calls = []
    monkeypatch.setattr(mod, "bybit_4edge_backtest_summary",
                        lambda store, **kw: (calls.append(kw), _fake_summary(kw.get("strategy_id")))[1])
    monkeypatch.setattr(mod, "liquid_universe", lambda store, **kw: {"BTC", "ETH"})
    monkeypatch.setattr(mod, "load_unlock_events", lambda *a, **k: [])
    monkeypatch.setattr(mod, "_data_dir", lambda: "/tmp")

    sm = mod.report_backtest_summary("positioning", store=object())
    assert sm.strategy_id == "positioning"
    assert calls[-1]["sleeves"] == ["positioning"]
```

- [ ] **Step 2: Run to verify failure**

Run: `.venv/bin/python -m pytest tests/unit/test_backtest_refs_report.py -q`
Expected: FAIL (`ModuleNotFoundError`).

- [ ] **Step 3: Implement `report_backtest_summary`.** This is a per-sid dispatcher over the EXISTING bybit book engine `bybit_4edge_backtest_summary` — the SAME three calls the `bybit-persist-sleeve-ret` command makes at `cli.py:3135-3157`. Do NOT reimplement any return-series math and do NOT touch `backtest_summaries_from_report`. Import the patchable names at module level (`bybit_4edge_backtest_summary`, `_LEVERED_SLEEVE_SHAPE`, `POSITIONING_TAIL_CAP_K` from `bybit_forward_track`; `liquid_universe` from `bybit_liquidity`; `load_unlock_events`, `operational_unlock_repo` from `unlock_calendar_loader`; `_data_dir` from `adapters.orchestration.assets`) so the test can monkeypatch them.

```python
# src/fxhnt/application/backtest_refs_report.py
import logging

from fxhnt.adapters.orchestration.assets import _data_dir
from fxhnt.application.bybit_forward_track import (
    POSITIONING_TAIL_CAP_K, _LEVERED_SLEEVE_SHAPE, bybit_4edge_backtest_summary)
from fxhnt.application.bybit_liquidity import liquid_universe
from fxhnt.application.unlock_calendar_loader import load_unlock_events, operational_unlock_repo
from fxhnt.config import get_settings

log = logging.getLogger(__name__)

# The three report-kind sids and how they differ (mirrors cli.py:3135-3157 exactly).
_REPORT_SIDS = ("bybit_4edge", "bybit_4edge_levered", "positioning")


def report_backtest_summary(strategy_id, store):  # -> BacktestSummary | None
    """Backtest ref for a `report`-kind bybit strategy: dispatch to the real book engine
    `bybit_4edge_backtest_summary` with the per-sid kwargs. Reconstructs the liquid universe, unlock
    calendar, and positioning capturability haircuts internally (from the store + settings) so the ref
    reconciles against the SAME capturable book the forward track records. cost_bps=5.5 matches the live
    BybitFourEdge basis and the `sleeve` kind. Built from the store (sleeve_returns=None) so the
    positioning haircut kwargs are applied by the engine."""
    if strategy_id not in _REPORT_SIDS:
        raise ValueError(f"report_backtest_summary: {strategy_id!r} is not a report-kind sid {_REPORT_SIDS}")
    settings = get_settings()
    universe = liquid_universe(store, min_dollar_vol=10_000_000.0) or None
    try:
        unlock_events = load_unlock_events(
            operational_unlock_repo(settings), f"{_data_dir()}/unlock_calendar.json")
    except Exception as exc:  # noqa: BLE001 — absent calendar → unlock sleeve omitted, never fabricated
        log.warning("report_backtest_summary(%s): could not load unlock calendar: %s", strategy_id, exc)
        unlock_events = []
    common = dict(
        universe=universe, cost_bps=5.5,
        positioning_capacity_capital=settings.paper_capital,
        positioning_tail_cap_k=POSITIONING_TAIL_CAP_K)
    if strategy_id == "bybit_4edge":
        return bybit_4edge_backtest_summary(store, unlock_events=unlock_events, **common)
    if strategy_id == "bybit_4edge_levered":
        return bybit_4edge_backtest_summary(
            store, unlock_events=unlock_events, leverage_shape=_LEVERED_SLEEVE_SHAPE,
            strategy_id="bybit_4edge_levered", **common)
    # positioning: single sleeve, no unlock_events, no leverage (mirrors cli.py:3155-3157).
    return bybit_4edge_backtest_summary(
        store, sleeves=["positioning"], strategy_id="positioning", **common)
```

Note the recompute cost: unlike `bybit-persist-sleeve-ret` (which precomputes `sleeve_returns` once and reuses it for all three), this rebuilds the book per sid. That is intentional — it keeps the `backtest_ref(sid, store)` contract stateless per-sid, and the constraint that made this an own-memory Job is PEAK MEMORY (a single 4-sleeve walk-forward), which sequential per-sid recompute does not raise; only wall-clock rises (~3×), acceptable for the weekly scheduled step (Task 6).

- [ ] **Step 4: Add the `backtest-refs --all` CLI command** (`cli.py`): iterate `STRATEGY_REGISTRY`; for each strategy with `registry_backtest_kind(sid) in ("report",)` (the HEAVY ones — the cheap recompute/sleeve refs run inline in the nightly assets, Task 6), call `backtest_ref(sid, store)` and `upsert_backtest_summary`. Log per-strategy; a failure logs + continues (never aborts). This is the scheduled step's entrypoint.

- [ ] **Step 5: Run tests + confirm the CLI loads**

Run: `.venv/bin/python -m pytest tests/unit/test_backtest_refs_report.py -q && .venv/bin/python -c "from fxhnt.cli import app; print('cli OK')"`
Expected: PASS + `cli OK`.

- [ ] **Step 6: Commit**

```bash
git add src/fxhnt/application/backtest_refs_report.py src/fxhnt/cli.py tests/unit/test_backtest_refs_report.py
git commit -m "feat(backtest): report provider (bybit book) + backtest-refs --all CLI"
```

---

## Task 6: Route the nightly assets through `backtest_ref`; schedule the resourced step

**Files:**
- Modify: `src/fxhnt/adapters/orchestration/assets.py` (`_persist_track_backtest_ref` → `backtest_ref`; add xsfunding's ref)
- Create: `infra/k8s/jobs/fxhnt-backtest-refs.yaml` (scheduled resourced CronJob)
- Retire: `infra/k8s/jobs/fxhnt-bybit-sleeve-ret-job.yaml` (its logic now in the CronJob)
- Test: `tests/integration/test_backtest_refs_wiring.py`

**Interfaces:**
- Consumes: `backtest_ref` (Task 1), `registry_backtest_kind`, `track_builders` (for the recompute-replay `build_strategy`), `get_feature_store` (Timescale store), `ForwardNavRepo.upsert_backtest_summary`.

- [ ] **Step 1: Write the failing wiring test** (the inline path writes a ref for a recompute-replay strategy AND for xsfunding via `backtest_ref`, into a sqlite repo)

```python
# tests/integration/test_backtest_refs_wiring.py
import datetime as dt
from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
from fxhnt.application.backtest_refs import backtest_ref


class _Replay:
    def advance(self, last, extra):
        return [("2024-01-0%d" % i, 0.01) for i in range(1, 6)], {}


def test_backtest_ref_upserts_for_recompute_replay(tmp_path):
    repo = ForwardNavRepo(f"sqlite:///{tmp_path/'c.db'}")
    repo.migrate()
    sm = backtest_ref("multistrat", store=None, build_strategy=lambda: _Replay())
    assert sm is not None
    repo.upsert_backtest_summary(sm, at=dt.datetime(2024, 1, 6))
    # read it back via the SAME reader the gate provider uses (forward_ingest.py:48 → all_backtest_summaries())
    assert any(b.strategy_id == "multistrat" for b in repo.all_backtest_summaries())
```

*(The reconciliation-gate ref provider reads refs via `ForwardNavRepo.all_backtest_summaries() -> list[BacktestSummaryRow]` — there is no per-sid `backtest_summary(sid)` reader; use `all_backtest_summaries()` and filter by `strategy_id`.)*

- [ ] **Step 2: Run to verify it passes** (backtest_ref + upsert already exist from Task 1).

Run: `.venv/bin/python -m pytest tests/integration/test_backtest_refs_wiring.py -q`
Expected: PASS.

- [ ] **Step 3: Refactor `_persist_track_backtest_ref` (`assets.py:363`) to delegate to `backtest_ref`.** Keep its best-effort try/except + logging (never fail the forward step). Change its body so it: resolves the strategy's `build_strategy` (from the migration builders for recompute-replay) or the store (for sleeve), calls `backtest_ref(sid, store, build_strategy=...)`, and upserts the result if non-None. Keep the existing `persist_backtest_ref=True` call sites (multistrat/vrp/crypto_tstrend/stablecoin/unlock) unchanged in behaviour.

- [ ] **Step 4: Add xsfunding's inline ref.** In `xsfunding_nav` (`assets.py`), after the tracker runs, call the inline ref persist for `xsfunding` (kind `sleeve`) via the same `_persist_track_backtest_ref`/`backtest_ref` path, using `get_feature_store(s)` as the store. This is the asset-level half of the bug-A fix (Task 2 built the provider; this wires it into the nightly run). Confirm `xsfunding` now writes a `backtest_summary` row on a nightly run.

- [ ] **Step 5: Create the scheduled CronJob** `infra/k8s/jobs/fxhnt-backtest-refs.yaml`: copy the resourcing + labels (`app.kubernetes.io/part-of: foxhunt` for gitea git-sync + base egress) + git-sync-from-master pattern from `fxhnt-bybit-sleeve-ret-job.yaml`, but as a **CronJob** (weekly, e.g. `schedule: "0 2 * * 1"`), OWN memory (match the sleeve-ret Job's memory, e.g. 4-6Gi), command `fxhnt backtest-refs --all`. This replaces the manual `fxhnt-bybit-sleeve-ret-job.yaml` (delete that file). **Fixes bug B** (the deploy-gate ref becomes a scheduled guarantee).

- [ ] **Step 6: Run tests + yaml validity**

Run: `.venv/bin/python -m pytest tests/integration/test_backtest_refs_wiring.py -q && .venv/bin/python -c "import yaml; list(yaml.safe_load_all(open('infra/k8s/jobs/fxhnt-backtest-refs.yaml'))); print('yaml OK')"`
Expected: PASS + `yaml OK` (confirm it's a CronJob with a schedule + own memory + `part-of: foxhunt` label).

- [ ] **Step 7: Commit**

```bash
git add src/fxhnt/adapters/orchestration/assets.py infra/k8s/jobs/fxhnt-backtest-refs.yaml tests/integration/test_backtest_refs_wiring.py
git rm infra/k8s/jobs/fxhnt-bybit-sleeve-ret-job.yaml
git commit -m "feat(backtest): route nightly refs through backtest_ref + scheduled resourced backtest-refs CronJob (bug B)"
```

---

## Task 7: Park B3 + carry file-stores; verify the paper_book DuckDB touch

**Files:**
- Retire: `infra/k8s/jobs/fxhnt-backtest-ingest-job.yaml`, `fxhnt-backtest-verdict-job.yaml` (+ the B3 DuckDB PVC manifest if separate)
- Investigate/Modify: `src/fxhnt/application/paper_book.py`, `paper_sleeves.py`, `paper_backfill.py`
- Test: `tests/unit/test_no_live_duckdb_files.py`

- [ ] **Step 1: VERIFY the paper_book DuckDB touch.** Read `src/fxhnt/application/paper_book.py`, `paper_sleeves.py`, `paper_backfill.py` for their `DuckDbFeatureStore` / `.duckdb` / `read_parquet` usage. Determine: does the LIVE cockpit/paper path (prod, `feature_store="postgres"`) actually read from a DuckDB file, or is the import a dev/test/fallback only? Document the finding in the commit message.
  - If **live-adjacent** (the running paper book reads a DuckDB file): migrate that read to the `AnalyticalStore` port so it uses `get_feature_store()` (Timescale) — matching the nav assets.
  - If **dev/test-only**: leave it (the `DuckDbFeatureStore` stays as the opt-in test backend behind the port).

- [ ] **Step 2: Add a guard test** that the live path never silently falls back to a DuckDB file. Assert `get_feature_store(settings)` with the default settings returns a `TimescaleFeatureStore` (postgres), so a Job that forgets `FXHNT_FEATURE_STORE` can't write to a throwaway DuckDB file (the existing safety comment in `config.py:158`).

```python
# tests/unit/test_no_live_duckdb_files.py
def test_default_feature_store_is_timescale_not_duckdb():
    from fxhnt.adapters.warehouse import get_feature_store
    from fxhnt.adapters.warehouse.timescale_feature_store import TimescaleFeatureStore
    from fxhnt.config import Settings
    s = Settings()   # defaults; feature_store should default to "postgres"
    assert s.feature_store == "postgres"
    # get_feature_store with a postgres DSN returns the Timescale store (not DuckDbFeatureStore)
    s.operational_dsn = "postgresql+psycopg://unused@localhost/x"
    assert isinstance(get_feature_store(s), TimescaleFeatureStore)
```

- [ ] **Step 3: Retire the B3 backtest Jobs/PVC.** `git rm` `infra/k8s/jobs/fxhnt-backtest-ingest-job.yaml` and `fxhnt-backtest-verdict-job.yaml` (the orphaned B3 platform — code stays in the tree, revivable; only its Jobs/PVC are decommissioned from the running system). Leave `equity_backtest_runner.py` + `worst_basis*` code in place (parked research). Add a one-line header comment to `equity_backtest_runner.py` noting it's PARKED (no live consumer; plugs into `backtest_refs` when an equity strat is added).

- [ ] **Step 4: Run tests to verify pass**

Run: `.venv/bin/python -m pytest tests/unit/test_no_live_duckdb_files.py -q`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add tests/unit/test_no_live_duckdb_files.py src/fxhnt/application/equity_backtest_runner.py
git rm infra/k8s/jobs/fxhnt-backtest-ingest-job.yaml infra/k8s/jobs/fxhnt-backtest-verdict-job.yaml
# + any paper_book migration edits from Step 1
git commit -m "chore(backtest): park B3 equity + carry file-stores; guard live feature store = Timescale"
```

---

## Task 8: The xsfunding gate un-sticks — end-to-end at the real gate seam

**Files:**
- Test: `tests/integration/test_xsfunding_gate_unsticks.py`

**Why not a cockpit-render test:** the strategy detail page's "Backtest reference" section is driven by
`_backtest_reference` → the bybit **sleeve sim** (`bybit_sleeve_ret`), NOT the `backtest_summary` gate ref —
so seeding a `BacktestSummaryRow` and grepping the rendered HTML for "no backtest reference" tests the wrong
surface (near-vacuous). Bug A lives in the **reconciliation gate**: `evaluate_reconciliation_gate` returns a
specific permanent WAIT `"no backtest reference to reconcile against — PASS withheld"` when its
`backtest is None`. The gate's ref comes from `CockpitBacktestRefProvider(repo).reference(sid, days)`, which
reads `backtest_summary` rows. So the faithful proof drives that exact production seam: ref present → the gate
reconciles (leaves the no-ref branch); ref absent → it IS the no-ref WAIT (the pre-fix state).

- [ ] **Step 1: Write the two tests** (network-free, sqlite; mirrors `tests/integration/test_forward_gate_e2e.py`).

```python
# tests/integration/test_xsfunding_gate_unsticks.py
"""Bug A end-to-end: xsfunding is reconciliation-gated but had NO backtest_summary ref, so the gate returned
the permanent 'no backtest reference to reconcile against' WAIT forever. Tasks 2/6 wired the ref. This proves
the un-sticking at the REAL seam the nightly ingest uses: CockpitBacktestRefProvider (reads backtest_summary
rows) -> evaluate_reconciliation_gate. Ref present => the gate reconciles; ref absent => the no-ref WAIT."""
import datetime as dt

from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
from fxhnt.application.forward_ingest import CockpitBacktestRefProvider
from fxhnt.application.forward_models import BacktestSummary, ForwardNavRow, ForwardSummary
from fxhnt.application.gate_policy import POLICY
from fxhnt.application.gate_policy_resolve import resolve_policy
from fxhnt.application.reconciliation_gate import evaluate_reconciliation_gate
from fxhnt.registry import STRATEGY_REGISTRY

_NO_REF = "no backtest reference to reconcile against"


def _policy():
    return resolve_policy(POLICY, STRATEGY_REGISTRY["xsfunding"]["gate_spec"])   # reconciliation, min 14d


def _xsfunding_forward(days: int):
    """A mature (>= min_forward_days), continuous (no gaps), modestly-positive xsfunding forward record."""
    start = dt.date(2026, 6, 1)
    rows = [ForwardNavRow(strategy_id="xsfunding", date=(start + dt.timedelta(days=i)).isoformat(),
                          ret=0.001, nav=1.001 ** (i + 1)) for i in range(days)]
    nav = rows[-1].nav
    summary = ForwardSummary(strategy_id="xsfunding", as_of=rows[-1].date, days=days, nav=nav,
                             total_return=nav - 1.0, sharpe=1.0, maxdd=0.0)
    return summary, rows


def test_xsfunding_gate_stuck_without_ref(tmp_path):
    """Pre-fix state: no backtest_summary row -> provider yields None -> the permanent no-ref WAIT."""
    repo = ForwardNavRepo(f"sqlite:///{tmp_path / 'c.db'}")
    repo.migrate()
    policy = _policy()
    summary, rows = _xsfunding_forward(policy.min_forward_days + 3)
    ref = CockpitBacktestRefProvider(repo).reference("xsfunding", summary.days)
    assert ref is None
    verdict = evaluate_reconciliation_gate(summary, rows, ref, policy)
    assert verdict.status == "WAIT" and _NO_REF in verdict.reason


def test_xsfunding_gate_unsticks_with_ref(tmp_path):
    """Post-fix (Tasks 2/6): a persisted xsfunding backtest_summary -> provider yields a ref -> the gate
    RECONCILES (leaves the no-ref branch entirely; a PASS or a divergence-WAIT both mean 'reconciling')."""
    repo = ForwardNavRepo(f"sqlite:///{tmp_path / 'c.db'}")
    repo.migrate()
    repo.upsert_backtest_summary(
        BacktestSummary(strategy_id="xsfunding", as_of="2026-06-20", cagr=0.35, ann_vol=0.2, sharpe=1.2,
                        max_drawdown=-0.06, passed=True, dsr=0.9, is_sharpe=1.3, oos_sharpe=1.1, pvalue=0.02),
        at=dt.datetime(2026, 6, 20))
    policy = _policy()
    summary, rows = _xsfunding_forward(policy.min_forward_days + 3)
    ref = CockpitBacktestRefProvider(repo).reference("xsfunding", summary.days)
    assert ref is not None and ref.expected_window_return > 0.0
    verdict = evaluate_reconciliation_gate(summary, rows, ref, policy)
    assert _NO_REF not in verdict.reason   # the un-sticking: no longer stuck on a missing backtest ref
```

*(Verify `BacktestSummary` / `ForwardSummary` / `ForwardNavRow` field names against `forward_models.py` and
`upsert_backtest_summary`'s signature against `forward_nav.py` — adjust only if a name differs.)*

- [ ] **Step 2: Run to verify pass**

Run: `.venv/bin/python -m pytest tests/integration/test_xsfunding_gate_unsticks.py -q`
Expected: PASS.

- [ ] **Step 3: Manual local browser verify** (`feedback_verify_cockpit_locally_not_prod`): run the local dev cockpit, seed an `xsfunding` forward + a real `backtest_ref("xsfunding", store)` ref, open `/strategy/xsfunding`, and READ that the gate shows a real reconciliation state (building N/14 → reconciling) rather than the permanent "no backtest reference" WAIT. Do NOT claim off synthetic/prod.

- [ ] **Step 4: Commit**

```bash
git add tests/integration/test_xsfunding_gate_unsticks.py
git commit -m "test(backtest): cockpit shows xsfunding gate reconciling once its ref exists (bug A verified)"
```

---

## Self-Review

**Spec coverage:**
- Unified backtest→ref contract (3 mechanisms → 1 dispatch on `backtest.kind`) → Tasks 1, 5. ✓
- Fix xsfunding (bug A) → Tasks 2, 6. ✓
- TimescaleDuckDbEngine (ephemeral ATTACH) → Task 4. ✓
- The invariant (CI-enforced) → Task 3. ✓
- Scheduled resourced step (fixes bug B, replaces manual Job) → Task 6. ✓
- Retire/park B3 + carry file-stores → Task 7. ✓
- VERIFY paper_book DuckDB → Task 7 Step 1. ✓
- Real-money-errs-false-WAIT (backtest None → gate WAITs) → preserved (Task 1 returns None; gate unchanged). ✓
- Local cockpit verify → Task 8. ✓

**Placeholder scan:** the `sleeve`/`report` provider steps say "read the real signature/report shape in bybit_book_eval.py / the sleeve-ret command and adapt" — this is a deliberate reuse-don't-reimplement instruction with the exact seam cited (not a vague placeholder); the novel code (dispatch, engine, invariant, tests) is complete. Task 7 Step 1 is a genuine investigate-then-branch (the plan can't pre-decide what it can't see).

**Type consistency:** `backtest_ref(strategy_id, store, *, build_strategy=None) -> BacktestSummary | None` consistent (Tasks 1/2/5/6). `registry_backtest_kind(sid) -> str` consistent. `report_backtest_summary(strategy_id, store)` consistent (Tasks 1/5). `TimescaleDuckDbEngine(dsn)` + `.in_memory()`/`.attached()`/`.scan()` consistent (Tasks 4/5). `backtest.kind` values (`recompute-replay`/`sleeve`/`report`/`none`) consistent across registry + dispatch + invariant.

**Flagged for the implementer:** Task 2/5's exact provider code depends on `sleeve_returns_from_store` and the sleeve-ret report shape — the implementer MUST read those (`bybit_book_eval.py`, `funding_dispersion_eval.py`, the `bybit-persist-sleeve-ret` command) and adapt the row/report conversion; surface to the human if the engine's contract differs materially from what this plan assumes (e.g. if xsfunding's sleeve returns aren't directly `(iso,ret)` rows).
