# B3b — Walk-Forward Equity-Factor Backtest 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:** Backtest the 3 price-only equity-factor constructions (`long`/`ls`/`tilt`, momentum + low-vol) over the full survivorship-free price history in the dedicated backtest warehouse, producing a gauntlet GO/NO_GO verdict per construction.

**Architecture:** Pure domain layer (PIT universe reconstruction + rebalance-date math + extracted factor-window helpers) consumed by an application-layer walk-forward runner that reads the DuckDB warehouse (never live Tiingo), books daily returns per construction across monthly rebalances, then runs each construction's daily-return series through the existing `compute_stats` + gauntlet `evaluate`. A CLI command runs it against the backtest warehouse and writes a JSON report.

**Tech Stack:** Python 3.12, DuckDB warehouse (`DuckDbFeatureStore`), numpy, pydantic, Typer CLI, pytest (`.venv/bin/python -m pytest`), strict mypy. All tests use a synthetic on-tmp-path warehouse — no live network.

**Reference spec:** `docs/superpowers/specs/2026-06-17-equity-backtest-design.md`.

**House rules:** TDD (failing test first). Frozen dataclasses / pure functions in `domain/`. Fakes in tests, never live network. Strict mypy must stay clean (no NEW errors). Commit after each task. Do NOT push. Trailer on every commit:
```
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
```

---

## File Structure

**New files:**
- `src/fxhnt/domain/equity_backtest.py` — pure: `universe_asof`, `month_end_rebalance_dates`, `daily_returns_for_weights`.
- `src/fxhnt/application/equity_backtest_runner.py` — walk-forward orchestration over the warehouse → per-construction daily return series → verdict → report dict.
- `tests/unit/test_equity_backtest_domain.py` — domain unit tests.
- `tests/integration/test_equity_backtest_runner.py` — runner tests on a synthetic warehouse.

**Modified files:**
- `src/fxhnt/domain/strategies/equity_factor.py` — add two pure helpers (`momentum_12_1_from_closes`, `realized_vol_from_closes`) extracted from the live path.
- `src/fxhnt/application/equity_factor_strategy.py` — refactor `compute_factor_scores` to call the two new domain helpers (behaviour-preserving; DRY + single source of truth).
- `src/fxhnt/cli.py` — add `backtest-equity` command.
- `tests/unit/test_equity_factor.py` (or the existing equity_factor domain test) — tests for the two new helpers.

---

## Reuse contract (exact signatures — do not re-derive)

```python
# src/fxhnt/domain/strategies/equity_factor.py
def momentum_score(mom_12_1: list[float | None]) -> list[float]      # = robust_z(mom_12_1)
def lowvol_score(vols: list[float | None]) -> list[float]            # robust_z of negated vols
def composite_price(momentum_z: list[float], lowvol_z: list[float]) -> list[float]
def construction_weights(scores: list[float], construction: str, quantile: float = 0.2) -> list[float]
def book_return(prev_weights: dict[str, float], prev_prices: dict[str, float],
                cur_prices: dict[str, float], borrow_daily: float = 0.0) -> float

# 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
class Verdict(BaseModel): passed: bool; dsr: float; is_sharpe: float; oos_sharpe: float; n_trials: int; reasons: list[str]; pvalue: float
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

# src/fxhnt/adapters/warehouse/duckdb_feature_store.py
class DuckDbFeatureStore:
    def __init__(self, path: str) -> None
    def read_panel(self, symbols: list[str], feature: str) -> dict[str, dict[int, float]]   # {symbol: {epoch_day: value}}
    def read_membership(self) -> list[tuple[str, str, str, str]]                              # (symbol, exchange, start_date, end_date)
    def write_features_bulk(self, items: list[tuple[str, list[tuple[int, dict[str, float]]]]]) -> None
    def upsert_membership(self, rows: list[tuple[str, str, str, str]]) -> None

# src/fxhnt/config.py
Settings.backtest_warehouse_path: str            # env FXHNT_BACKTEST_WAREHOUSE_PATH, default /backtest-data/warehouse.duckdb
Settings.cost_bps_per_turnover: float = 10.0
Settings.gauntlet: GauntletSettings              # dsr_min .95, oos_min_sharpe 0.0, max_is_oos_decay .50, oos_fraction .40
```

**Warehouse `ts` convention:** stored as epoch-seconds at 00:00 UTC; `read_panel` returns keys as **epoch-days** (`ts // 86400`). Convert an ISO date to epoch-day with the helper in Task 2 (`_epoch_day`). Stored features: `"adjclose"`, `"close"`, `"volume"`.

**Cost model (spec default):** 15 bps per unit turnover (overrides the engine's 10.0 default; pass explicitly). OOS holdout 40%. n = 150 names. DSR n_trials = 3 (the three constructions).

---

## Task 1: Extract pure factor-window helpers (DRY the live path)

**Why:** The backtest scorer and the live `compute_factor_scores` must compute 12-1 momentum and trailing realized vol from a close series **identically**. Today that window logic is inline in `equity_factor_strategy.py`. Extract it into the domain so there is one source of truth.

**Files:**
- Modify: `src/fxhnt/domain/strategies/equity_factor.py`
- Modify: `src/fxhnt/application/equity_factor_strategy.py`
- Test: `tests/unit/test_equity_factor.py` (append; if absent, create)

- [ ] **Step 1: Read the existing live logic.** Open `src/fxhnt/application/equity_factor_strategy.py` and locate (a) the inline 12-1 momentum computation inside `compute_factor_scores` and (b) `_realized_vol`. Note the EXACT window constants used (lookback length for momentum, the 1-month skip, the vol window, min-history thresholds). These exact numbers are the source of truth — preserve them verbatim.

- [ ] **Step 2: Write failing tests** in `tests/unit/test_equity_factor.py`:

```python
from fxhnt.domain.strategies.equity_factor import (
    momentum_12_1_from_closes,
    realized_vol_from_closes,
)


def _ramp(n: int, daily: float = 0.001) -> list[float]:
    """Strictly increasing close series: 100 * (1+daily)**i."""
    return [100.0 * (1.0 + daily) ** i for i in range(n)]


def test_momentum_12_1_positive_for_uptrend():
    # An up-trending series of sufficient length yields positive 12-1 momentum.
    closes = _ramp(300)
    m = momentum_12_1_from_closes(closes)
    assert m is not None and m > 0.0


def test_momentum_12_1_none_when_insufficient_history():
    assert momentum_12_1_from_closes(_ramp(50)) is None


def test_realized_vol_zero_for_constant_series():
    v = realized_vol_from_closes([100.0] * 120)
    assert v is not None and v == 0.0


def test_realized_vol_positive_for_wiggly_series():
    closes = [100.0 * (1.0 + (0.02 if i % 2 == 0 else -0.02)) for i in range(120)]
    v = realized_vol_from_closes(closes)
    assert v is not None and v > 0.0


def test_realized_vol_none_when_insufficient_history():
    assert realized_vol_from_closes([100.0] * 5) is None
```

- [ ] **Step 3: Run, verify failure.** `.venv/bin/python -m pytest tests/unit/test_equity_factor.py -k "momentum_12_1 or realized_vol" -v` → FAIL (ImportError).

- [ ] **Step 4: Add the helpers** to `src/fxhnt/domain/strategies/equity_factor.py`. Move the EXACT window math from `equity_factor_strategy.py` (the numbers you read in Step 1 — do not change them). Signatures:

```python
def momentum_12_1_from_closes(closes: list[float]) -> float | None:
    """12-1 month momentum from an ascending daily-adjusted-close series.
    Returns the lookback-window return EXCLUDING the most recent ~1 month, or
    None if there is insufficient history. Window lengths copied verbatim from
    the prior inline logic in equity_factor_strategy.compute_factor_scores."""
    ...


def realized_vol_from_closes(closes: list[float]) -> float | None:
    """Trailing realized volatility from an ascending close series (the prior
    _realized_vol logic, moved verbatim). None if insufficient history."""
    ...
```

- [ ] **Step 5: Run new tests.** `.venv/bin/python -m pytest tests/unit/test_equity_factor.py -k "momentum_12_1 or realized_vol" -v` → PASS.

- [ ] **Step 6: Refactor the live path.** In `src/fxhnt/application/equity_factor_strategy.py`, replace the inline momentum computation and the `_realized_vol` body with calls to the new domain helpers (delete the now-dead inline code / make `_realized_vol` delegate, or remove it and call the domain helper directly). Behaviour MUST be identical.

- [ ] **Step 7: Run the full equity-factor suite.** `.venv/bin/python -m pytest tests/ -k equity_factor -v` → all PASS (the existing live tests prove the refactor preserved behaviour). Then `.venv/bin/python -m pytest -q` → all PASS.

- [ ] **Step 8: mypy.** `.venv/bin/python -m mypy src/fxhnt/domain/strategies/equity_factor.py src/fxhnt/application/equity_factor_strategy.py` → no new errors.

- [ ] **Step 9: Commit.**
```bash
git add src/fxhnt/domain/strategies/equity_factor.py src/fxhnt/application/equity_factor_strategy.py tests/unit/test_equity_factor.py
git commit -m "refactor(equity-factor): extract pure momentum_12_1/realized_vol window helpers (DRY live + backtest)

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

---

## Task 2: PIT universe + rebalance-date domain

**Files:**
- Create: `src/fxhnt/domain/equity_backtest.py`
- Test: `tests/unit/test_equity_backtest_domain.py`

- [ ] **Step 1: Write failing tests** in `tests/unit/test_equity_backtest_domain.py`:

```python
from fxhnt.domain.equity_backtest import (
    universe_asof,
    month_end_rebalance_dates,
    _epoch_day,
)

MEMBERS = [
    ("LIVE", "NASDAQ", "2000-01-01", "2026-06-01"),   # active across all asof dates
    ("DEAD", "NYSE", "1998-01-01", "2005-03-15"),     # delisted 2005
    ("IPO", "NASDAQ", "2010-06-01", "2026-06-01"),    # IPO 2010
]


def test_universe_asof_excludes_not_yet_listed_and_delisted():
    # At 2008-01-01: LIVE active, DEAD already delisted, IPO not yet listed.
    dollar_vol = {"LIVE": 1e9, "DEAD": 1e9, "IPO": 1e9}
    assert universe_asof(MEMBERS, dollar_vol, "2008-01-01", n=10) == ["LIVE"]


def test_universe_asof_includes_delisted_while_alive():
    # At 2003-01-01: LIVE + DEAD both alive (survivorship-free correctness).
    dollar_vol = {"LIVE": 2e9, "DEAD": 1e9}
    assert universe_asof(MEMBERS, dollar_vol, "2003-01-01", n=10) == ["LIVE", "DEAD"]


def test_universe_asof_ranks_by_dollar_volume_desc_and_takes_top_n():
    members = [(s, "NYSE", "2000-01-01", "2026-06-01") for s in ("A", "B", "C")]
    dollar_vol = {"A": 10.0, "B": 30.0, "C": 20.0}
    assert universe_asof(members, dollar_vol, "2020-01-01", n=2) == ["B", "C"]


def test_universe_asof_drops_zero_or_missing_dollar_volume():
    members = [(s, "NYSE", "2000-01-01", "2026-06-01") for s in ("A", "B")]
    assert universe_asof(members, {"A": 5.0, "B": 0.0}, "2020-01-01", n=10) == ["A"]


def test_month_end_rebalance_dates_one_per_month():
    dates = ["2020-01-02", "2020-01-31", "2020-02-03", "2020-02-27", "2020-03-02"]
    # last available trading date within each calendar month
    assert month_end_rebalance_dates(dates) == ["2020-01-31", "2020-02-27", "2020-03-02"]


def test_epoch_day_roundtrip():
    # 1970-01-01 is epoch-day 0; 1970-01-02 is 1.
    assert _epoch_day("1970-01-01") == 0
    assert _epoch_day("1970-01-02") == 1
```

- [ ] **Step 2: Run, verify failure.** `.venv/bin/python -m pytest tests/unit/test_equity_backtest_domain.py -v` → FAIL (ImportError).

- [ ] **Step 3: Implement** `src/fxhnt/domain/equity_backtest.py`:

```python
"""Pure walk-forward equity-factor backtest domain: point-in-time universe
reconstruction and rebalance-date math. No I/O, no live network."""
from __future__ import annotations

import datetime as dt

_SECONDS_PER_DAY = 86_400


def _epoch_day(iso_date: str) -> int:
    """ISO 'YYYY-MM-DD' -> epoch-day (matches DuckDbFeatureStore.read_panel keys)."""
    d = dt.date.fromisoformat(iso_date)
    return (d - dt.date(1970, 1, 1)).days


def universe_asof(
    members: list[tuple[str, str, str, str]],
    dollar_vol: dict[str, float],
    asof: str,
    n: int,
) -> list[str]:
    """Point-in-time top-N universe at `asof` (ISO date).

    A name is eligible iff it was listed at `asof` (start_date <= asof <= end_date)
    AND has strictly positive trailing dollar-volume. Eligible names are ranked by
    `dollar_vol` descending; the top `n` symbols are returned. Including names that
    were alive at `asof` but later delisted is what makes this survivorship-free.
    """
    eligible = [
        sym
        for (sym, _exch, start, end) in members
        if start <= asof <= end and dollar_vol.get(sym, 0.0) > 0.0
    ]
    eligible.sort(key=lambda s: dollar_vol[s], reverse=True)
    return eligible[:n]


def month_end_rebalance_dates(dates: list[str]) -> list[str]:
    """Given ascending ISO trading dates, return the last available trading date
    within each calendar (year, month) — the monthly rebalance schedule."""
    last_by_month: dict[tuple[int, int], str] = {}
    for d in dates:
        y, m, _ = d.split("-")
        last_by_month[(int(y), int(m))] = d
    return [last_by_month[k] for k in sorted(last_by_month)]
```

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

- [ ] **Step 5: mypy.** `.venv/bin/python -m mypy src/fxhnt/domain/equity_backtest.py` → no errors.

- [ ] **Step 6: Commit.**
```bash
git add src/fxhnt/domain/equity_backtest.py tests/unit/test_equity_backtest_domain.py
git commit -m "feat(b3b): PIT universe_asof + month-end rebalance-date domain (survivorship-free)

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

---

## Task 3: Daily-returns-for-weights domain

**Why:** Between rebalances we hold fixed weights and accrue the daily book return. This is pure given the per-day price panel.

**Files:**
- Modify: `src/fxhnt/domain/equity_backtest.py`
- Test: `tests/unit/test_equity_backtest_domain.py` (append)

- [ ] **Step 1: Append failing tests:**

```python
from fxhnt.domain.equity_backtest import daily_returns_for_weights


def test_daily_returns_long_only_matches_weighted_price_change():
    weights = {"A": 0.5, "B": 0.5}
    # day0 -> day1: A +10%, B 0%  => 0.5*0.10 + 0.5*0.0 = 0.05
    closes = {
        "A": {0: 100.0, 1: 110.0},
        "B": {0: 50.0, 1: 50.0},
    }
    rets = daily_returns_for_weights(weights, closes, [0, 1], borrow_daily=0.0)
    assert rets == [0.05]


def test_daily_returns_applies_borrow_on_short_notional():
    weights = {"A": -1.0}                       # 100% short
    closes = {"A": {0: 100.0, 1: 100.0}}        # flat price
    rets = daily_returns_for_weights(weights, closes, [0, 1], borrow_daily=0.001)
    assert rets == [-0.001]                      # only the borrow cost


def test_daily_returns_skips_day_with_missing_price():
    weights = {"A": 1.0}
    closes = {"A": {0: 100.0, 2: 121.0}}        # day 1 missing
    # day0->1 unusable (no price@1); day1->2 unusable (no price@1). No usable steps.
    rets = daily_returns_for_weights(weights, closes, [0, 1, 2], borrow_daily=0.0)
    assert rets == []
```

- [ ] **Step 2: Run, verify failure.** `.venv/bin/python -m pytest tests/unit/test_equity_backtest_domain.py -k daily_returns -v` → FAIL.

- [ ] **Step 3: Implement** (append to `src/fxhnt/domain/equity_backtest.py`):

```python
from fxhnt.domain.strategies.equity_factor import book_return


def daily_returns_for_weights(
    weights: dict[str, float],
    closes: dict[str, dict[int, float]],
    days: list[int],
    borrow_daily: float,
) -> list[float]:
    """Daily book returns of fixed `weights` over consecutive `days` (epoch-days,
    ascending). For each adjacent (prev, cur) pair, both prices must exist for a
    symbol to contribute; a step with no usable price pair is dropped (not 0.0)."""
    out: list[float] = []
    for prev, cur in zip(days, days[1:]):
        prev_p = {s: closes[s][prev] for s in weights if s in closes and prev in closes[s] and cur in closes[s]}
        cur_p = {s: closes[s][cur] for s in prev_p}
        if not prev_p:
            continue
        w = {s: weights[s] for s in prev_p}
        out.append(book_return(w, prev_p, cur_p, borrow_daily=borrow_daily))
    return out
```

- [ ] **Step 4: Run.** `.venv/bin/python -m pytest tests/unit/test_equity_backtest_domain.py -k daily_returns -v` → PASS.

- [ ] **Step 5: mypy.** `.venv/bin/python -m mypy src/fxhnt/domain/equity_backtest.py` → no errors.

- [ ] **Step 6: Commit.**
```bash
git add src/fxhnt/domain/equity_backtest.py tests/unit/test_equity_backtest_domain.py
git commit -m "feat(b3b): daily_returns_for_weights (fixed-weight holding-period book returns)

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

---

## Task 4: Walk-forward runner (warehouse → per-construction daily return series)

**Files:**
- Create: `src/fxhnt/application/equity_backtest_runner.py`
- Test: `tests/integration/test_equity_backtest_runner.py`

This is the integration core. The runner reads the warehouse once, builds the per-construction daily-return series, and returns them plus diagnostics. Verdict + report come in Task 5.

- [ ] **Step 1: Write the synthetic-warehouse fixture + failing test** in `tests/integration/test_equity_backtest_runner.py`:

```python
import datetime as dt

import numpy as np

from fxhnt.adapters.warehouse.duckdb_feature_store import DuckDbFeatureStore
from fxhnt.application.equity_backtest_runner import EquityBacktestRunner

_SPD = 86_400


def _ts(iso: str) -> int:
    d = dt.date.fromisoformat(iso)
    return (d - dt.date(1970, 1, 1)).days * _SPD


def _build_warehouse(path: str) -> DuckDbFeatureStore:
    """3 names over ~2 years of daily bars. AAA strong uptrend, BBB mild,
    CCC downtrend — gives a non-degenerate cross-section for long/ls/tilt."""
    store = DuckDbFeatureStore(path)
    start = dt.date(2022, 1, 3)
    n = 520  # ~2 trading years
    drifts = {"AAA": 0.0015, "BBB": 0.0003, "CCC": -0.0010}
    items = []
    members = []
    for sym, dr in drifts.items():
        rows = []
        px = 100.0
        for i in range(n):
            day = start + dt.timedelta(days=i)
            px *= (1.0 + dr)
            ts = (day - dt.date(1970, 1, 1)).days * _SPD
            rows.append((ts, {"adjclose": px, "close": px, "volume": 1_000_000.0}))
        items.append((sym, rows))
        members.append((sym, "NYSE", "2000-01-01", "2026-06-01"))
    store.write_features_bulk(items)
    store.upsert_membership(members)
    return store


def test_runner_produces_three_construction_series(tmp_path):
    store = _build_warehouse(str(tmp_path / "wh.duckdb"))
    runner = EquityBacktestRunner(store, n=10, cost_bps_per_turnover=15.0,
                                  borrow_annual=0.0, momentum_min_history=252)
    result = runner.run()
    store.close()
    assert set(result.returns_by_construction) == {"long", "ls", "tilt"}
    # Each construction yields a non-trivial daily return series.
    for series in result.returns_by_construction.values():
        assert len(series) > 50
        assert np.isfinite(series).all()
    # The long book (holding the uptrending AAA) should be net positive over the window.
    assert float(np.prod(1.0 + result.returns_by_construction["long"]) - 1.0) > 0.0


def test_runner_is_deterministic(tmp_path):
    store = _build_warehouse(str(tmp_path / "wh.duckdb"))
    runner = EquityBacktestRunner(store, n=10, cost_bps_per_turnover=15.0,
                                  borrow_annual=0.0, momentum_min_history=252)
    a = runner.run().returns_by_construction["ls"]
    b = runner.run().returns_by_construction["ls"]
    store.close()
    assert np.array_equal(a, b)
```

- [ ] **Step 2: Run, verify failure.** `.venv/bin/python -m pytest tests/integration/test_equity_backtest_runner.py -v` → FAIL (ImportError).

- [ ] **Step 3: Implement** `src/fxhnt/application/equity_backtest_runner.py`:

```python
"""Walk-forward equity-factor backtest runner. Reads the dedicated DuckDB
backtest warehouse (never live Tiingo), reconstructs the point-in-time universe
each month, scores names with the SAME domain factor math as the live sleeves,
holds weights between monthly rebalances, and produces a daily net-return series
per construction."""
from __future__ import annotations

import datetime as dt
from dataclasses import dataclass

import numpy as np

from fxhnt.adapters.warehouse.duckdb_feature_store import DuckDbFeatureStore
from fxhnt.domain.equity_backtest import (
    _epoch_day,
    daily_returns_for_weights,
    month_end_rebalance_dates,
    universe_asof,
)
from fxhnt.domain.strategies.equity_factor import (
    composite_price,
    construction_weights,
    lowvol_score,
    momentum_12_1_from_closes,
    momentum_score,
    realized_vol_from_closes,
)

_CONSTRUCTIONS = ("long", "ls", "tilt")
_DOLLAR_VOL_WINDOW_DAYS = 63   # ~3 trading months trailing, for liquidity ranking
_TRADING_DAYS_YEAR = 252


@dataclass(frozen=True)
class BacktestRunResult:
    returns_by_construction: dict[str, np.ndarray]   # construction -> daily net returns
    rebalance_dates: list[str]
    n_names_avg: float                               # mean PIT universe size per rebalance


def _iso(epoch_day: int) -> str:
    return (dt.date(1970, 1, 1) + dt.timedelta(days=epoch_day)).isoformat()


class EquityBacktestRunner:
    def __init__(
        self,
        store: DuckDbFeatureStore,
        *,
        n: int = 150,
        cost_bps_per_turnover: float = 15.0,
        borrow_annual: float = 0.0,
        momentum_min_history: int = 252,
    ) -> None:
        self._store = store
        self._n = n
        self._cost = cost_bps_per_turnover / 1e4
        self._borrow_daily = borrow_annual / _TRADING_DAYS_YEAR
        self._mom_min = momentum_min_history

    def run(self) -> BacktestRunResult:
        members = self._store.read_membership()
        syms = [m[0] for m in members]
        adj = self._store.read_panel(syms, "adjclose")     # {sym: {epoch_day: px}}
        close = self._store.read_panel(syms, "close")
        vol = self._store.read_panel(syms, "volume")

        all_days = sorted({d for s in adj.values() for d in s})
        all_iso = [_iso(d) for d in all_days]
        rebals = month_end_rebalance_dates(all_iso)

        series: dict[str, list[float]] = {c: [] for c in _CONSTRUCTIONS}
        prev_weights: dict[str, dict[str, float]] = {c: {} for c in _CONSTRUCTIONS}
        names_seen = 0
        n_rebals = 0

        for k, rebal in enumerate(rebals):
            rebal_day = _epoch_day(rebal)
            # liquidity: trailing dollar-volume (close*volume) over the window ending rebal
            lo = rebal_day - _DOLLAR_VOL_WINDOW_DAYS
            dollar_vol: dict[str, float] = {}
            for s in syms:
                cl, vl = close.get(s, {}), vol.get(s, {})
                acc = [cl[d] * vl[d] for d in cl if lo <= d <= rebal_day and d in vl]
                if acc:
                    dollar_vol[s] = float(np.mean(acc))
            uni = universe_asof(members, dollar_vol, rebal, self._n)

            # PIT scores: momentum + low-vol from closes up to and including rebal_day
            mom: list[float | None] = []
            vols: list[float | None] = []
            kept: list[str] = []
            prices_now: dict[str, float] = {}
            for s in uni:
                hist = [adj[s][d] for d in sorted(adj[s]) if d <= rebal_day]
                m = momentum_12_1_from_closes(hist)
                v = realized_vol_from_closes(hist)
                if m is None or v is None or rebal_day not in adj[s]:
                    continue
                kept.append(s)
                mom.append(m)
                vols.append(v)
                prices_now[s] = adj[s][rebal_day]
            names_seen += len(kept)
            n_rebals += 1
            if len(kept) < 2:
                continue

            scores = composite_price(momentum_score(mom), lowvol_score(vols))

            # holding-period trading days: (this rebal, next rebal]
            end_day = _epoch_day(rebals[k + 1]) if k + 1 < len(rebals) else all_days[-1]
            hold_days = [d for d in all_days if rebal_day <= d <= end_day]

            for c in _CONSTRUCTIONS:
                w_list = construction_weights(scores, c)
                weights = {kept[i]: w_list[i] for i in range(len(kept)) if w_list[i] != 0.0}
                # turnover cost at rebalance (vs prior book), charged on the first held day
                turnover = _turnover(prev_weights[c], weights)
                rets = daily_returns_for_weights(weights, adj, hold_days, self._borrow_daily)
                if rets:
                    rets[0] -= turnover * self._cost
                series[c].extend(rets)
                prev_weights[c] = weights

        return BacktestRunResult(
            returns_by_construction={c: np.asarray(series[c], dtype=float) for c in _CONSTRUCTIONS},
            rebalance_dates=rebals,
            n_names_avg=(names_seen / n_rebals) if n_rebals else 0.0,
        )


def _turnover(prev: dict[str, float], cur: dict[str, float]) -> float:
    """Sum of absolute weight changes across the union of names (one-way turnover)."""
    names = set(prev) | set(cur)
    return float(sum(abs(cur.get(s, 0.0) - prev.get(s, 0.0)) for s in names))
```

- [ ] **Step 4: Run.** `.venv/bin/python -m pytest tests/integration/test_equity_backtest_runner.py -v` → PASS. If the "long > 0" assertion is flaky on the synthetic data, inspect the realized series; do NOT weaken the determinism test.

- [ ] **Step 5: mypy.** `.venv/bin/python -m mypy src/fxhnt/application/equity_backtest_runner.py` → no errors.

- [ ] **Step 6: Commit.**
```bash
git add src/fxhnt/application/equity_backtest_runner.py tests/integration/test_equity_backtest_runner.py
git commit -m "feat(b3b): walk-forward runner — PIT universe + monthly rebalance + per-construction daily returns

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

---

## Task 5: Verdict + JSON report

**Files:**
- Modify: `src/fxhnt/application/equity_backtest_runner.py` (add `evaluate_constructions`)
- Test: `tests/integration/test_equity_backtest_runner.py` (append)

- [ ] **Step 1: Append failing test:**

```python
from fxhnt.application.equity_backtest_runner import evaluate_constructions


def test_evaluate_constructions_emits_stats_and_verdict_per_construction(tmp_path):
    store = _build_warehouse(str(tmp_path / "wh.duckdb"))
    runner = EquityBacktestRunner(store, n=10, cost_bps_per_turnover=15.0,
                                  borrow_annual=0.0, momentum_min_history=252)
    result = runner.run()
    store.close()
    report = evaluate_constructions(result, oos_fraction=0.40, dsr_min=0.95,
                                    oos_min_sharpe=0.0, max_is_oos_decay=0.50)
    assert set(report["constructions"]) == {"long", "ls", "tilt"}
    for c, block in report["constructions"].items():
        assert {"stats", "verdict"} <= set(block)
        assert {"sharpe", "cagr", "max_drawdown", "n_obs"} <= set(block["stats"])
        assert {"passed", "dsr", "is_sharpe", "oos_sharpe", "n_trials", "reasons"} <= set(block["verdict"])
        assert block["verdict"]["n_trials"] == 3        # the three constructions
    assert report["n_names_avg"] >= 0.0
    assert isinstance(report["rebalance_dates"], list)
```

- [ ] **Step 2: Run, verify failure.** `.venv/bin/python -m pytest tests/integration/test_equity_backtest_runner.py -k evaluate_constructions -v` → FAIL.

- [ ] **Step 3: Implement** (append to `src/fxhnt/application/equity_backtest_runner.py`):

```python
from typing import Any

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


def evaluate_constructions(
    result: BacktestRunResult,
    *,
    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]:
    """Stack each construction's daily returns, split IS/OOS, run the gauntlet.
    n_trials = the number of constructions tested (multiple-testing correction)."""
    n_trials = len(result.returns_by_construction)
    out: dict[str, Any] = {
        "constructions": {},
        "rebalance_dates": result.rebalance_dates,
        "n_names_avg": result.n_names_avg,
        "settings": {
            "oos_fraction": oos_fraction, "dsr_min": dsr_min,
            "oos_min_sharpe": oos_min_sharpe, "max_is_oos_decay": max_is_oos_decay,
            "n_trials": n_trials,
        },
    }
    for c, r in result.returns_by_construction.items():
        stats = compute_stats(r)
        split = int((1.0 - oos_fraction) * len(r))
        is_r, oos_r = r[:split], r[split:]
        # sr_variance ~ Var of the per-trial Sharpe estimates; with one realized series
        # per construction we use the standard small-sample proxy 1/n_obs (0 if too short).
        sr_variance = (1.0 / len(r)) if len(r) > 1 else 0.0
        verdict = evaluate(
            is_r, oos_r, 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,  # momentum + low-vol are documented risk premia
        )
        out["constructions"][c] = {
            "stats": stats.model_dump(),
            "verdict": verdict.model_dump(),
        }
    return out
```

- [ ] **Step 4: Run.** `.venv/bin/python -m pytest tests/integration/test_equity_backtest_runner.py -v` → all PASS.

- [ ] **Step 5: mypy.** `.venv/bin/python -m mypy src/fxhnt/application/equity_backtest_runner.py` → no errors.

- [ ] **Step 6: Commit.**
```bash
git add src/fxhnt/application/equity_backtest_runner.py tests/integration/test_equity_backtest_runner.py
git commit -m "feat(b3b): gauntlet verdict + JSON report per construction (DSR/OOS, n_trials=3)

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

---

## Task 6: CLI command `backtest-equity`

**Files:**
- Modify: `src/fxhnt/cli.py`
- Test: `tests/integration/test_equity_backtest_runner.py` (append a CLI smoke test) OR `tests/unit/test_cli.py` if one exists — follow the pattern used by the existing `ingest-historical` command test.

- [ ] **Step 1: Read** `src/fxhnt/cli.py` to match the Typer style of the existing `ingest-historical` command (how it builds `Settings`, `DuckDbFeatureStore(settings.backtest_warehouse_path)`, and echoes a summary line).

- [ ] **Step 2: Write a failing CLI test** mirroring the existing CLI-test pattern. Build a synthetic warehouse at a tmp path, point `FXHNT_BACKTEST_WAREHOUSE_PATH` at it (monkeypatch env or pass `--warehouse`), invoke the command via Typer's `CliRunner`, assert exit code 0 and that a report JSON file is written with the three constructions. Example (adapt to the house CLI-test idiom found in Step 1):

```python
import json
from typer.testing import CliRunner
from fxhnt.cli import app

def test_cli_backtest_equity_writes_report(tmp_path, monkeypatch):
    wh = str(tmp_path / "wh.duckdb")
    _build_warehouse(wh).close()
    out = tmp_path / "report.json"
    monkeypatch.setenv("FXHNT_BACKTEST_WAREHOUSE_PATH", wh)
    res = CliRunner().invoke(app, ["backtest-equity", "--n", "10", "--out", str(out)])
    assert res.exit_code == 0, res.output
    report = json.loads(out.read_text())
    assert set(report["constructions"]) == {"long", "ls", "tilt"}
```

- [ ] **Step 3: Run, verify failure.** `.venv/bin/python -m pytest -k backtest_equity -v` → FAIL.

- [ ] **Step 4: Implement** the command in `src/fxhnt/cli.py`:

```python
@app.command("backtest-equity")
def backtest_equity(
    n: int = typer.Option(150, help="PIT universe size (top-N by trailing dollar-volume)."),
    cost_bps: float = typer.Option(15.0, help="Cost in bps per unit turnover."),
    borrow_annual: float = typer.Option(0.0, help="Annual borrow cost on short notional."),
    out: str = typer.Option("/backtest-data/equity_backtest_report.json", help="Report JSON path."),
) -> None:
    """Walk-forward backtest of the 3 price-only equity-factor constructions against
    the dedicated survivorship-free backtest warehouse → gauntlet verdict + JSON report."""
    import json
    from fxhnt.config import Settings
    from fxhnt.adapters.warehouse.duckdb_feature_store import DuckDbFeatureStore
    from fxhnt.application.equity_backtest_runner import (
        EquityBacktestRunner, evaluate_constructions,
    )

    s = Settings()
    store = DuckDbFeatureStore(s.backtest_warehouse_path)
    try:
        result = EquityBacktestRunner(
            store, n=n, cost_bps_per_turnover=cost_bps, borrow_annual=borrow_annual,
        ).run()
        report = evaluate_constructions(
            result,
            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,
        )
    finally:
        store.close()
    with open(out, "w") as fh:
        json.dump(report, fh, indent=2)
    for c, block in report["constructions"].items():
        v = block["verdict"]
        typer.echo(f"backtest-equity {c}: passed={v['passed']} dsr={v['dsr']:.3f} "
                   f"is_sharpe={v['is_sharpe']:.2f} oos_sharpe={v['oos_sharpe']:.2f} "
                   f"sharpe={block['stats']['sharpe']:.2f} -> {out}")
```

If the CLI test passes `--out` as the report path but the command reads the warehouse from env, ensure the command honours `FXHNT_BACKTEST_WAREHOUSE_PATH` (it does, via `Settings()`); if the house pattern threads `--warehouse` instead, add that option to match `ingest-historical`.

- [ ] **Step 5: Run.** `.venv/bin/python -m pytest -k backtest_equity -v` → PASS, then full `.venv/bin/python -m pytest -q` → all PASS.

- [ ] **Step 6: mypy.** `.venv/bin/python -m mypy src/fxhnt/cli.py` → no new errors.

- [ ] **Step 7: Commit.**
```bash
git add src/fxhnt/cli.py tests/
git commit -m "feat(b3b): fxhnt backtest-equity CLI — run walk-forward verdict + write report JSON

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

---

## Known caveat — real-data memory (flag, do not fix now)
The Task-4 runner calls `read_panel` over **all** member symbols up front (3 features). That is fine for the synthetic tests and for a few-thousand-name universe, but the full survivorship-free set (~22k names × ~30 yr) would not fit in memory as Python dicts. Before the *real* run (after the ingest completes), add a pre-filter: read `catalog()` first, keep only names with enough history and non-trivial dollar-volume, and/or chunk the panel read by rebalance window. Build + test now on synthetic data; this optimization is a deliberate follow-up, logged here so it is not a silent OOM later (cf. the B3a ingest OOM).

## Out of scope (do NOT build)
- Fundamentals factors (value/quality) — gated on the Tiingo add-on; kept in the domain unused.
- Cockpit `backtest_summary` wiring — optional follow-up after we see a real verdict.
- Nightly automation — the backtest is run on demand.
- Live execution.

## Final review (after all tasks)
Dispatch a final code reviewer over the whole B3b diff, then run the full suite + mypy once more. Do NOT push; report the commit range and a summary so we can run the real backtest against the warehouse once the ingest completes.
