From dc9bfeb68765074e8e646910efcd902015eecedf Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Wed, 17 Jun 2026 12:25:41 +0200 Subject: [PATCH] feat(b3b): PIT universe_asof + month-end rebalance-date domain (survivorship-free) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/fxhnt/domain/equity_backtest.py | 45 +++++++++++++++++++++++ tests/unit/test_equity_backtest_domain.py | 42 +++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 src/fxhnt/domain/equity_backtest.py create mode 100644 tests/unit/test_equity_backtest_domain.py diff --git a/src/fxhnt/domain/equity_backtest.py b/src/fxhnt/domain/equity_backtest.py new file mode 100644 index 0000000..250c588 --- /dev/null +++ b/src/fxhnt/domain/equity_backtest.py @@ -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)] diff --git a/tests/unit/test_equity_backtest_domain.py b/tests/unit/test_equity_backtest_domain.py new file mode 100644 index 0000000..e9e308b --- /dev/null +++ b/tests/unit/test_equity_backtest_domain.py @@ -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