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:
45
src/fxhnt/domain/equity_backtest.py
Normal file
45
src/fxhnt/domain/equity_backtest.py
Normal 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)]
|
||||
42
tests/unit/test_equity_backtest_domain.py
Normal file
42
tests/unit/test_equity_backtest_domain.py
Normal 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
|
||||
Reference in New Issue
Block a user