feat(b3b): PIT universe_asof + month-end rebalance-date domain (survivorship-free)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-06-17 12:25:41 +02:00
parent a93cf05fab
commit dc9bfeb687
2 changed files with 87 additions and 0 deletions

View File

@@ -0,0 +1,45 @@
"""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)]

View File

@@ -0,0 +1,42 @@
from fxhnt.domain.equity_backtest import (
universe_asof,
month_end_rebalance_dates,
_epoch_day,
)
MEMBERS = [
("LIVE", "NASDAQ", "2000-01-01", "2026-06-01"),
("DEAD", "NYSE", "1998-01-01", "2005-03-15"),
("IPO", "NASDAQ", "2010-06-01", "2026-06-01"),
]
def test_universe_asof_excludes_not_yet_listed_and_delisted():
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():
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"]
assert month_end_rebalance_dates(dates) == ["2020-01-31", "2020-02-27", "2020-03-02"]
def test_epoch_day_roundtrip():
assert _epoch_day("1970-01-01") == 0
assert _epoch_day("1970-01-02") == 1