- pytest-xdist added to dev deps; addopts='-n auto' runs the ~1940-test suite across all cores (~6-9min -> ~1.5min); registered a 'slow' marker for a serial '-m "not slow"' loop. - equity_backtest_runner liquidity fixture: trimmed the 3000-bar (8yr) LIVELONG/ILLIQUID histories to 800 bars (still >> min_history=300, multi-year for peak-yearly ranking) — the peak-yearly aggregation over 8yr was the cost: 50s/30s/19s tests -> ~5.8s each, assertions unchanged. - paper_sim perf guards: made them parallel-robust (the wall-clock <1.6s bound flaked under -n auto CPU contention). full-history guard relaxed to <10s (it targets an O(D²) blowup = orders of magnitude, not a constant factor); cheap-reapply made RELATIVE (< full/10, ratio is contention-invariant). Both marked slow. Full suite 1940 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
698 lines
32 KiB
Python
698 lines
32 KiB
Python
import datetime as dt
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from fxhnt.adapters.warehouse.duckdb_feature_store import DuckDbFeatureStore
|
|
from fxhnt.application.equity_backtest_runner import (
|
|
BacktestRunResult,
|
|
EquityBacktestRunner,
|
|
evaluate_constructions,
|
|
select_backtest_universe,
|
|
)
|
|
from fxhnt.domain.equity_backtest import month_end_rebalance_dates
|
|
|
|
_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"}
|
|
for series in result.returns_by_construction.values():
|
|
assert len(series) > 50
|
|
assert np.isfinite(series).all()
|
|
assert float(np.prod(1.0 + result.returns_by_construction["long"]) - 1.0) > 0.0
|
|
|
|
# rebalance_dates lists only ACTUALLY-TRADED periods (len(kept) >= 2),
|
|
# never the 252d-warmup months where no book could be formed.
|
|
month_ends = month_end_rebalance_dates(
|
|
sorted({
|
|
(dt.date(2022, 1, 3) + dt.timedelta(days=i)).isoformat()
|
|
for i in range(520)
|
|
})
|
|
)
|
|
assert len(result.rebalance_dates) >= 1
|
|
assert all(d in month_ends for d in result.rebalance_dates)
|
|
# Warmup (252 trading-day history requirement) means fewer traded periods
|
|
# than the total monthly rebalance count.
|
|
assert len(result.rebalance_dates) < len(month_ends)
|
|
# All 3 synthetic names qualify once warmed up, so the average over TRADED
|
|
# periods is ~3.0 (not ~1.67, which would average in the empty warmup months).
|
|
assert abs(result.n_names_avg - 3.0) < 1e-9
|
|
|
|
|
|
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)
|
|
|
|
|
|
def test_momentum_min_history_gates_inclusion(tmp_path):
|
|
# With an absurdly high min-history requirement, NO name ever has enough
|
|
# history, so every construction series is empty and n_names_avg is 0.
|
|
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=100_000)
|
|
result = runner.run()
|
|
store.close()
|
|
assert result.n_names_avg == 0.0
|
|
for series in result.returns_by_construction.values():
|
|
assert len(series) == 0
|
|
|
|
|
|
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
|
|
assert report["n_names_avg"] >= 0.0
|
|
assert isinstance(report["rebalance_dates"], list)
|
|
import json
|
|
json.dumps(report) # must not raise
|
|
|
|
|
|
def test_evaluate_constructions_short_series_emits_clean_insufficient_data_verdict():
|
|
# FIX #2: a construction with len < 3 must NOT call evaluate() (which would
|
|
# return dsr=NaN and break json.dumps). Instead emit a clean fallback verdict.
|
|
result = BacktestRunResult(
|
|
returns_by_construction={c: np.array([0.01, 0.02]) for c in ("long", "ls", "tilt")},
|
|
rebalance_dates=[],
|
|
n_names_avg=0.0,
|
|
)
|
|
report = evaluate_constructions(result)
|
|
assert set(report["constructions"]) == {"long", "ls", "tilt"}
|
|
for c, block in report["constructions"].items():
|
|
v = block["verdict"]
|
|
assert v["passed"] is False
|
|
assert v["reasons"] == ["insufficient data"]
|
|
assert v["dsr"] == 0.0
|
|
assert v["pvalue"] == 1.0
|
|
assert v["n_trials"] == 3
|
|
# fallback verdict must have the SAME keys as Verdict.model_dump()
|
|
assert set(v) == {"passed", "dsr", "is_sharpe", "oos_sharpe",
|
|
"n_trials", "reasons", "pvalue"}
|
|
import json
|
|
json.dumps(report) # must not raise (no NaN)
|
|
|
|
|
|
def _build_liquidity_warehouse(path: str) -> DuckDbFeatureStore:
|
|
"""5 symbols with deliberately differing history-length and dollar-volume so
|
|
the peak-yearly-liquidity pre-filter can be exercised:
|
|
|
|
- LIVELONG : long history, HIGH dollar-volume throughout (top liquidity)
|
|
- DELISTED : SHORT lifetime (well above min_history though) but VERY HIGH
|
|
dollar-volume while alive — must be INCLUDED (survivorship-safe;
|
|
peak-yearly ranking sees its liquid alive years)
|
|
- ILLIQUID : long history but tiny dollar-volume throughout — ranks low,
|
|
dropped by a small top_k
|
|
- RECENTIPO : short-but-sufficient history, moderate dollar-volume
|
|
- TOOSHORT : fewer than min_history bars — must be EXCLUDED
|
|
"""
|
|
store = DuckDbFeatureStore(path)
|
|
|
|
def write(sym: str, start_iso: str, n_bars: int, close: float, vol: float) -> None:
|
|
start = dt.date.fromisoformat(start_iso)
|
|
rows = []
|
|
for i in range(n_bars):
|
|
day = start + dt.timedelta(days=i)
|
|
ts = (day - dt.date(1970, 1, 1)).days * _SPD
|
|
rows.append((ts, {"adjclose": close, "close": close, "volume": vol}))
|
|
store.write_features(sym, rows)
|
|
store.upsert_membership([(sym, "NYSE", start_iso,
|
|
(start + dt.timedelta(days=n_bars)).isoformat())])
|
|
|
|
write("LIVELONG", "2015-01-01", 800, close=100.0, vol=5_000_000.0) # dv = 5e8; 800 bars >> min_history, multi-year for peak-yearly
|
|
write("DELISTED", "2012-01-01", 600, close=200.0, vol=8_000_000.0) # dv = 1.6e9 (highest)
|
|
write("ILLIQUID", "2015-01-01", 800, close=10.0, vol=1_000.0) # dv = 1e4 (lowest)
|
|
write("RECENTIPO", "2023-01-01", 500, close=50.0, vol=2_000_000.0) # dv = 1e8
|
|
write("TOOSHORT", "2024-01-01", 50, close=100.0, vol=9_000_000.0) # < min_history
|
|
return store
|
|
|
|
|
|
def test_select_backtest_universe_filters_and_ranks(tmp_path):
|
|
store = _build_liquidity_warehouse(str(tmp_path / "liq.duckdb"))
|
|
liq = select_backtest_universe(store, min_history=300, top_k=2500)
|
|
store.close()
|
|
|
|
# dict[str, float] with positive values
|
|
assert isinstance(liq, dict)
|
|
assert all(isinstance(s, str) for s in liq)
|
|
assert all(isinstance(v, float) and v > 0.0 for v in liq.values())
|
|
|
|
# TOOSHORT (< min_history bars) is excluded
|
|
assert "TOOSHORT" not in liq
|
|
# DELISTED short-but-liquid name is INCLUDED (survivorship-safe)
|
|
assert "DELISTED" in liq
|
|
# long-history liquid name present
|
|
assert "LIVELONG" in liq
|
|
|
|
# ranking by peak-yearly dollar-volume: DELISTED (1.6e9) > LIVELONG (5e8) > ILLIQUID (1e4)
|
|
assert liq["DELISTED"] > liq["LIVELONG"] > liq["ILLIQUID"]
|
|
|
|
|
|
def test_select_backtest_universe_top_k_drops_illiquid_tail(tmp_path):
|
|
store = _build_liquidity_warehouse(str(tmp_path / "liq.duckdb"))
|
|
# top_k=2 keeps only the two most-liquid (DELISTED, LIVELONG); ILLIQUID dropped
|
|
liq = select_backtest_universe(store, min_history=300, top_k=2)
|
|
store.close()
|
|
assert len(liq) == 2
|
|
assert set(liq) == {"DELISTED", "LIVELONG"}
|
|
assert "ILLIQUID" not in liq
|
|
|
|
|
|
def test_select_candidate_universe_tie_broken_by_symbol_deterministically(tmp_path):
|
|
# FIX #3: two symbols with identical peak dollar-volume must order
|
|
# deterministically by symbol ascending (stable tiebreaker).
|
|
store = DuckDbFeatureStore(str(tmp_path / "tie.duckdb"))
|
|
|
|
def write(sym: str, start_iso: str, n_bars: int, close: float, vol: float) -> None:
|
|
start = dt.date.fromisoformat(start_iso)
|
|
rows = []
|
|
for i in range(n_bars):
|
|
day = start + dt.timedelta(days=i)
|
|
ts = (day - dt.date(1970, 1, 1)).days * _SPD
|
|
rows.append((ts, {"adjclose": close, "close": close, "volume": vol}))
|
|
store.write_features(sym, rows)
|
|
store.upsert_membership([(sym, "NYSE", start_iso,
|
|
(start + dt.timedelta(days=n_bars)).isoformat())])
|
|
|
|
# ZTIE and ATIE have identical close*volume over identical date ranges →
|
|
# identical peak-yearly dollar-volume → pure tie. top_k=1 must always keep
|
|
# the symbol-ascending winner (ATIE), never ZTIE, on every run.
|
|
write("ZTIE", "2015-01-01", 400, close=100.0, vol=1_000_000.0)
|
|
write("ATIE", "2015-01-01", 400, close=100.0, vol=1_000_000.0)
|
|
|
|
full = store.select_candidate_universe(min_history=300, top_k=10)
|
|
assert full["ZTIE"] == full["ATIE"] # genuine tie in the ranking key
|
|
|
|
for _ in range(3):
|
|
top1 = store.select_candidate_universe(min_history=300, top_k=1)
|
|
assert set(top1) == {"ATIE"}
|
|
store.close()
|
|
|
|
|
|
def test_select_candidate_universe_filters_and_ranks_via_store(tmp_path):
|
|
# Store-level mirror of test_select_backtest_universe_filters_and_ranks:
|
|
# min_history exclusion, delisted-but-liquid inclusion, top_k limit.
|
|
store = _build_liquidity_warehouse(str(tmp_path / "liq.duckdb"))
|
|
liq = store.select_candidate_universe(min_history=300, top_k=2500)
|
|
assert "TOOSHORT" not in liq # min_history exclusion
|
|
assert "DELISTED" in liq # survivorship-safe inclusion
|
|
assert liq["DELISTED"] > liq["LIVELONG"] > liq["ILLIQUID"] # ranking
|
|
top2 = store.select_candidate_universe(min_history=300, top_k=2)
|
|
assert set(top2) == {"DELISTED", "LIVELONG"} # top_k limit
|
|
store.close()
|
|
|
|
|
|
def test_candidate_path_deterministic(tmp_path):
|
|
# FIX #3 / project determinism requirement: the candidate path must produce
|
|
# byte-identical series across repeated runs.
|
|
store = _build_warehouse(str(tmp_path / "wh.duckdb"))
|
|
runner = EquityBacktestRunner(
|
|
store, n=10, candidate_top_k=100, candidate_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)
|
|
|
|
|
|
def test_candidate_path_with_large_top_k_matches_membership(tmp_path):
|
|
# Equivalence sanity: with top_k >= number of symbols (3) and a low
|
|
# candidate_min_history, all 3 synthetic names qualify for the candidate set.
|
|
# The candidate path ranks by STATIC peak-yearly liquidity while the None path
|
|
# ranks by TRAILING dollar-volume; with n=10 (>= 3 names) BOTH paths hold ALL
|
|
# three names every traded period, so the HELD sets — and thus weights and the
|
|
# daily 'ls' series — are identical. n_names_avg is therefore identical (~3.0).
|
|
store = _build_warehouse(str(tmp_path / "wh.duckdb"))
|
|
none_path = EquityBacktestRunner(
|
|
store, n=10, momentum_min_history=252,
|
|
).run()
|
|
cand_path = EquityBacktestRunner(
|
|
store, n=10, momentum_min_history=252,
|
|
candidate_top_k=100, candidate_min_history=10,
|
|
).run()
|
|
store.close()
|
|
# both paths hold all 3 names every traded period
|
|
assert abs(none_path.n_names_avg - 3.0) < 1e-9
|
|
assert abs(cand_path.n_names_avg - 3.0) < 1e-9
|
|
assert cand_path.n_names_avg == pytest.approx(none_path.n_names_avg)
|
|
# identical held sets → identical series across all three constructions
|
|
for c in ("long", "ls", "tilt"):
|
|
assert np.array_equal(
|
|
none_path.returns_by_construction[c],
|
|
cand_path.returns_by_construction[c],
|
|
)
|
|
|
|
|
|
def test_runner_candidate_path_matches_membership_gating(tmp_path):
|
|
store = _build_warehouse(str(tmp_path / "wh.duckdb"))
|
|
runner = EquityBacktestRunner(
|
|
store, n=10, candidate_top_k=100, candidate_min_history=252,
|
|
)
|
|
result = runner.run()
|
|
store.close()
|
|
# bounded path works end-to-end: 3 construction series, finite, non-trivial
|
|
assert set(result.returns_by_construction) == {"long", "ls", "tilt"}
|
|
for series in result.returns_by_construction.values():
|
|
assert len(series) > 50
|
|
assert np.isfinite(series).all()
|
|
assert result.n_names_avg > 0.0
|
|
|
|
|
|
def _build_junk_warehouse(path: str) -> DuckDbFeatureStore:
|
|
"""Good liquid names alongside data-hygiene junk:
|
|
|
|
- GOODA / GOODB : clean liquid names, median close >> 1.0 -> must be INCLUDED
|
|
- ZXZZT : NASDAQ TEST TICKER (the *ZZT family) with junk prices
|
|
(0.0001 -> 2e5) -> must be EXCLUDED by symbol pattern
|
|
- PENNY : real-looking name but median close 0.02 (< min_price 1.0)
|
|
-> must be EXCLUDED by min_price floor
|
|
"""
|
|
store = DuckDbFeatureStore(path)
|
|
|
|
def write(sym: str, start_iso: str, closes: list[float], vol: float) -> None:
|
|
start = dt.date.fromisoformat(start_iso)
|
|
rows = []
|
|
for i, px in enumerate(closes):
|
|
day = start + dt.timedelta(days=i)
|
|
ts = (day - dt.date(1970, 1, 1)).days * _SPD
|
|
rows.append((ts, {"adjclose": px, "close": px, "volume": vol}))
|
|
store.write_features(sym, rows)
|
|
store.upsert_membership([(sym, "NASDAQ", start_iso,
|
|
(start + dt.timedelta(days=len(closes))).isoformat())])
|
|
|
|
n = 20
|
|
write("GOODA", "2020-01-01", [100.0] * n, 5_000_000.0) # median 100, dv 5e8
|
|
write("GOODB", "2020-01-01", [50.0] * n, 3_000_000.0) # median 50, dv 1.5e8
|
|
# ZXZZT: junk prices ramping 0.0001 -> 2e5; high notional but a TEST ticker
|
|
junk = [0.0001 * (10 ** (i / 2.0)) for i in range(n)]
|
|
write("ZXZZT", "2020-01-01", junk, 9_000_000.0)
|
|
# PENNY: tight penny-stock prices, median 0.02 (< min_price)
|
|
write("PENNY", "2020-01-01", [0.02] * n, 9_000_000.0)
|
|
return store
|
|
|
|
|
|
def test_select_candidate_universe_excludes_test_and_penny_tickers(tmp_path):
|
|
# FIX A: ZXZZT (test-ticker pattern) and PENNY (median close < min_price)
|
|
# must both be EXCLUDED; the clean liquid names must be INCLUDED.
|
|
store = _build_junk_warehouse(str(tmp_path / "junk.duckdb"))
|
|
liq = store.select_candidate_universe(min_history=10, top_k=50, min_price=1.0)
|
|
store.close()
|
|
assert "ZXZZT" not in liq # test-ticker symbol pattern
|
|
assert "PENNY" not in liq # median close 0.02 < min_price 1.0
|
|
assert "GOODA" in liq
|
|
assert "GOODB" in liq
|
|
|
|
|
|
def test_runner_guards_keep_stats_finite_with_junk_ticker(tmp_path):
|
|
# FIX A + FIX B end-to-end: a warehouse containing a junk test ticker that,
|
|
# WITHOUT the guards, would inject billion-x daily returns and blow up ann_vol.
|
|
# WITH the guards (candidate min_price filter + per-day return sanity), the
|
|
# resulting series must be finite and contain no |daily return| > max_day_ret.
|
|
store = _build_junk_warehouse(str(tmp_path / "junk.duckdb"))
|
|
runner = EquityBacktestRunner(
|
|
store, n=10, momentum_min_history=2,
|
|
candidate_top_k=100, candidate_min_history=10,
|
|
candidate_min_price=1.0, max_day_ret=10.0, min_day_ret=-0.9,
|
|
)
|
|
result = runner.run()
|
|
store.close()
|
|
for series in result.returns_by_construction.values():
|
|
assert np.isfinite(series).all()
|
|
assert all(abs(float(r)) <= 10.0 for r in series)
|
|
|
|
|
|
def test_monthly_dollar_volume_buckets_by_calendar_month(tmp_path):
|
|
# Pass 1 SQL: per (symbol, calendar-month) avg dollar-vol / bars / avg-close,
|
|
# with a CORRECT calendar conversion (year(ts)*12 + month(ts) - 1), test
|
|
# tickers excluded, ordered by symbol, ym.
|
|
store = DuckDbFeatureStore(str(tmp_path / "mdv.duckdb"))
|
|
items = []
|
|
# AAA: Jan 2020 (3 bars) and Feb 2020 (2 bars)
|
|
aaa_dates = ["2020-01-06", "2020-01-13", "2020-01-21", "2020-02-03", "2020-02-10"]
|
|
rows = [(_ts(d), {"close": 100.0, "volume": 1_000_000.0, "adjclose": 100.0}) for d in aaa_dates]
|
|
items.append(("AAA", rows))
|
|
# BBB: Jan 2020 only (1 bar), higher dv
|
|
items.append(("BBB", [(_ts("2020-01-15"), {"close": 200.0, "volume": 2_000_000.0, "adjclose": 200.0})]))
|
|
# ZXZZT test ticker -> excluded
|
|
items.append(("ZXZZT", [(_ts("2020-01-15"), {"close": 1.0, "volume": 9.0, "adjclose": 1.0})]))
|
|
store.write_features_bulk(items)
|
|
rows_out = store.monthly_dollar_volume()
|
|
store.close()
|
|
|
|
syms = {r[0] for r in rows_out}
|
|
assert "ZXZZT" not in syms
|
|
assert {"AAA", "BBB"} == syms
|
|
|
|
jan_ym = 2020 * 12 + 0
|
|
feb_ym = 2020 * 12 + 1
|
|
by_key = {(r[0], r[1]): r for r in rows_out}
|
|
# AAA Jan: 3 bars, dv = 100*1e6 = 1e8, avg_close 100
|
|
assert by_key[("AAA", jan_ym)][2] == pytest.approx(1e8)
|
|
assert by_key[("AAA", jan_ym)][3] == 3
|
|
assert by_key[("AAA", jan_ym)][4] == pytest.approx(100.0)
|
|
# AAA Feb: 2 bars
|
|
assert by_key[("AAA", feb_ym)][3] == 2
|
|
# BBB Jan: dv = 200*2e6 = 4e8
|
|
assert by_key[("BBB", jan_ym)][2] == pytest.approx(4e8)
|
|
# ordered by symbol, ym
|
|
assert rows_out == sorted(rows_out, key=lambda r: (r[0], r[1]))
|
|
|
|
|
|
def test_trading_days_epoch_sorted_distinct(tmp_path):
|
|
store = DuckDbFeatureStore(str(tmp_path / "td.duckdb"))
|
|
items = [
|
|
("AAA", [(_ts("2020-01-06"), {"adjclose": 1.0}), (_ts("2020-01-07"), {"adjclose": 1.0})]),
|
|
("BBB", [(_ts("2020-01-07"), {"adjclose": 1.0}), (_ts("2020-01-08"), {"adjclose": 1.0})]),
|
|
]
|
|
store.write_features_bulk(items)
|
|
days = store.trading_days_epoch()
|
|
store.close()
|
|
expected = [
|
|
(dt.date.fromisoformat(d) - dt.date(1970, 1, 1)).days
|
|
for d in ("2020-01-06", "2020-01-07", "2020-01-08")
|
|
]
|
|
assert days == expected
|
|
|
|
|
|
def _build_delisting_warehouse(path: str) -> DuckDbFeatureStore:
|
|
"""3 names over ~2 years; DEAD's adjclose series TERMINATES partway (day ~300
|
|
of 520) — a delisting — while AAA/BBB run the full length. DEAD's membership
|
|
end_date is set to the FULL window end (not its data terminus) so the point-in-
|
|
time alive-gate keeps selecting DEAD into the month its prices terminate — that
|
|
is exactly the holding period in which the delisting return must be booked. The
|
|
dataset (driven by AAA/BBB) extends well past DEAD's terminus so it is detected
|
|
as a delisting (terminus < all_days[-1] - gap)."""
|
|
store = DuckDbFeatureStore(path)
|
|
start = dt.date(2022, 1, 3)
|
|
n = 520
|
|
dead_n = 300 # DEAD stops trading partway through
|
|
full_end = (start + dt.timedelta(days=n - 1)).isoformat()
|
|
# DEAD has the STRONGEST momentum so it lands in the long-only top quintile
|
|
# (3 names -> top is the single highest scorer) while it is alive — only then
|
|
# does the 'long' book hold it and realize its delisting return.
|
|
drifts = {"AAA": 0.0008, "BBB": 0.0005, "DEAD": 0.0015}
|
|
items = []
|
|
members = []
|
|
for sym, dr in drifts.items():
|
|
bars = dead_n if sym == "DEAD" else n
|
|
rows = []
|
|
px = 100.0
|
|
for i in range(bars):
|
|
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", full_end))
|
|
store.write_features_bulk(items)
|
|
store.upsert_membership(members)
|
|
return store
|
|
|
|
|
|
def test_runner_books_delisting_losses(tmp_path):
|
|
# FIX T9 end-to-end: DEAD's series terminates at day ~300 of 520 (a delisting).
|
|
# Running with delisting_return=-0.30 must book DEAD's terminal loss, making the
|
|
# long-book cumulative return STRICTLY LOWER than the delisting_return=0.0 run
|
|
# (which silently holds DEAD flat — the survivorship-in-return-booking bias).
|
|
store = _build_delisting_warehouse(str(tmp_path / "delist.duckdb"))
|
|
|
|
# Confirm delist_day detection FIRES: build the same adj panel + all_days the
|
|
# runner sees and assert DEAD's terminus is more than gap days before the end.
|
|
members = store.read_membership()
|
|
syms = [m[0] for m in members]
|
|
adj = store.read_panel(syms, "adjclose")
|
|
all_days = sorted({d for s in adj.values() for d in s})
|
|
dead_terminus = max(adj["DEAD"])
|
|
gap = 30
|
|
assert dead_terminus < all_days[-1] - gap # DEAD detected as a delisting
|
|
assert max(adj["AAA"]) >= all_days[-1] - gap # AAA runs to the end (not delisted)
|
|
|
|
runner_kwargs = dict(n=10, cost_bps_per_turnover=0.0, borrow_annual=0.0,
|
|
momentum_min_history=100)
|
|
loss = EquityBacktestRunner(store, delisting_return=-0.30, **runner_kwargs).run()
|
|
flat = EquityBacktestRunner(store, delisting_return=0.0, **runner_kwargs).run()
|
|
store.close()
|
|
|
|
cum_loss = float(np.prod(1.0 + loss.returns_by_construction["long"]) - 1.0)
|
|
cum_flat = float(np.prod(1.0 + flat.returns_by_construction["long"]) - 1.0)
|
|
assert cum_loss < cum_flat # the booked delisting loss drags the long book down
|
|
|
|
|
|
def _build_pit_warehouse(path: str) -> DuckDbFeatureStore:
|
|
"""4 names over ~3 years for the PIT (point-in-time) path. LATEBLOOM trades the
|
|
whole window but has TINY volume early and HUGE volume late — under the OLD peak/
|
|
full-history ranking it would be selected from day one (lookahead); under PIT it
|
|
only enters the universe once its TRAILING volume overtakes. AAA/BBB/CCC are the
|
|
usual liquid cross-section."""
|
|
store = DuckDbFeatureStore(path)
|
|
start = dt.date(2021, 1, 4)
|
|
n = 760 # ~3 trading years
|
|
drifts = {"AAA": 0.0012, "BBB": 0.0004, "CCC": -0.0008, "LATEBLOOM": 0.0020}
|
|
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
|
|
if sym == "LATEBLOOM":
|
|
# tiny volume in the first ~2/3, huge after — trailing dv flips late
|
|
vol = 100.0 if i < 500 else 50_000_000.0
|
|
else:
|
|
vol = 1_000_000.0
|
|
rows.append((ts, {"adjclose": px, "close": px, "volume": vol}))
|
|
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_pit_path_runs(tmp_path):
|
|
# PIT path end-to-end: 3 finite construction series, names held, and the
|
|
# decisive lookahead guard — LATEBLOOM (the would-be top scorer by momentum,
|
|
# only liquid LATE) must NOT be in the EARLY-period universe under top_n=2.
|
|
store = _build_pit_warehouse(str(tmp_path / "pit.duckdb"))
|
|
|
|
# Inspect the PIT universe-by-month directly to assert no lookahead.
|
|
from fxhnt.domain.equity_backtest import (
|
|
epoch_day, month_end_rebalance_dates, pit_universe_by_month,
|
|
)
|
|
monthly = store.monthly_dollar_volume()
|
|
days = store.trading_days_epoch()
|
|
all_iso = [(dt.date(1970, 1, 1) + dt.timedelta(days=d)).isoformat() for d in days]
|
|
rebals = month_end_rebalance_dates(all_iso)
|
|
rebal_yms = []
|
|
for r in rebals:
|
|
y, m, _ = r.split("-")
|
|
rebal_yms.append(int(y) * 12 + int(m) - 1)
|
|
uni_by_ym = pit_universe_by_month(
|
|
monthly, rebal_yms, window_months=12, top_n=2, min_price=1.0, min_bars=60,
|
|
)
|
|
# Early rebalances (first liquid-warmed months) must EXCLUDE LATEBLOOM.
|
|
early_ym = rebal_yms[14] # ~14 months in: LATEBLOOM still illiquid
|
|
assert "LATEBLOOM" not in uni_by_ym[early_ym]
|
|
# A LATE rebalance: LATEBLOOM's trailing volume has overtaken -> it appears.
|
|
late_ym = rebal_yms[-1]
|
|
assert "LATEBLOOM" in uni_by_ym[late_ym]
|
|
|
|
runner = EquityBacktestRunner(
|
|
store, n=2, pit=True, trailing_window_months=12, min_bars_window=60,
|
|
momentum_min_history=252, candidate_min_price=1.0,
|
|
)
|
|
result = runner.run()
|
|
store.close()
|
|
assert set(result.returns_by_construction) == {"long", "ls", "tilt"}
|
|
for series in result.returns_by_construction.values():
|
|
assert len(series) > 50
|
|
assert np.isfinite(series).all()
|
|
assert result.n_names_avg > 0.0
|
|
|
|
|
|
def test_runner_pit_path_deterministic(tmp_path):
|
|
store = _build_pit_warehouse(str(tmp_path / "pit.duckdb"))
|
|
kw = dict(n=2, pit=True, trailing_window_months=12, min_bars_window=60,
|
|
momentum_min_history=252)
|
|
a = EquityBacktestRunner(store, **kw).run().returns_by_construction["ls"]
|
|
b = EquityBacktestRunner(store, **kw).run().returns_by_construction["ls"]
|
|
store.close()
|
|
assert np.array_equal(a, b)
|
|
|
|
|
|
def _build_pit_warehouse_with_calendar_gap(path: str) -> DuckDbFeatureStore:
|
|
"""PIT warehouse where the selected names (AAA/BBB/CCC, liquid) each SKIP one
|
|
interior calendar day (a per-name price gap), while an ILLIQUID name (CAL) — never
|
|
selected into any month's top-N — trades EVERY calendar day. The full trading
|
|
calendar (trading_days_epoch(), union of ALL names incl. CAL) is therefore strictly
|
|
denser than the union of only the SELECTED names' adjclose panels. On a gap day a
|
|
held name is hold-flat while another selected name still books, matching the
|
|
non-PIT path's full-calendar hold-flat convention."""
|
|
store = DuckDbFeatureStore(path)
|
|
start = dt.date(2021, 1, 4)
|
|
n = 760 # ~3 trading years
|
|
drifts = {"AAA": 0.0012, "BBB": 0.0004, "CCC": -0.0008}
|
|
items = []
|
|
members = []
|
|
# Stagger the per-name gap day so that on each gap day OTHER selected names trade.
|
|
gap_index = {"AAA": 600, "BBB": 601, "CCC": 602}
|
|
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)
|
|
if i == gap_index[sym]:
|
|
continue # this name has NO bar on its gap day (price gap)
|
|
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"))
|
|
# CAL: tiny volume (never top-N) but present on EVERY day — defines the full calendar.
|
|
px = 50.0
|
|
cal_rows = []
|
|
for i in range(n):
|
|
day = start + dt.timedelta(days=i)
|
|
px *= 1.0001
|
|
ts = (day - dt.date(1970, 1, 1)).days * _SPD
|
|
cal_rows.append((ts, {"adjclose": px, "close": px, "volume": 1.0}))
|
|
items.append(("CAL", cal_rows))
|
|
members.append(("CAL", "NYSE", "2000-01-01", "2026-06-01"))
|
|
store.write_features_bulk(items)
|
|
store.upsert_membership(members)
|
|
return store
|
|
|
|
|
|
@pytest.mark.slow
|
|
def test_runner_pit_path_uses_full_trading_calendar_for_hold_days(tmp_path, monkeypatch):
|
|
# FIX #1: the PIT holding-period calendar must come from the FULL trading calendar
|
|
# (trading_days_epoch(), union of ALL warehouse names) — NOT the sparser union of
|
|
# only the SELECTED names' adjclose panels. We compare the real run against a run
|
|
# that simulates the OLD union-derived calendar (monkeypatch trading_days_epoch to
|
|
# return only the selected-union days) and assert the full-calendar series is at
|
|
# least as long, plus determinism + finiteness.
|
|
import fxhnt.application.equity_backtest_runner as runner_mod
|
|
|
|
kw = dict(n=3, pit=True, trailing_window_months=12, min_bars_window=60,
|
|
momentum_min_history=252, candidate_min_price=1.0)
|
|
|
|
# Full-calendar run (post-fix behavior).
|
|
store = _build_pit_warehouse_with_calendar_gap(str(tmp_path / "pit_full.duckdb"))
|
|
full = EquityBacktestRunner(store, **kw).run()
|
|
store.close()
|
|
|
|
# Determinism: same warehouse + settings -> bit-identical series.
|
|
store2 = _build_pit_warehouse_with_calendar_gap(str(tmp_path / "pit_full2.duckdb"))
|
|
full2 = EquityBacktestRunner(store2, **kw).run()
|
|
store2.close()
|
|
for c in ("long", "ls", "tilt"):
|
|
assert np.array_equal(full.returns_by_construction[c], full2.returns_by_construction[c])
|
|
assert np.isfinite(full.returns_by_construction[c]).all()
|
|
|
|
# Union-derived run (simulated OLD behavior): patch trading_days_epoch so the
|
|
# holding-period calendar collapses to the SELECTED-names union (drops the
|
|
# CAL-only days). The fixed runner reads `days` from trading_days_epoch(); the
|
|
# OLD code derived its calendar from the union-only adjclose panel, which here
|
|
# equals the union of AAA/BBB/CCC (CAL is never selected).
|
|
store3 = _build_pit_warehouse_with_calendar_gap(str(tmp_path / "pit_union.duckdb"))
|
|
real_tde = store3.trading_days_epoch
|
|
|
|
def union_only_calendar() -> list[int]:
|
|
adj = store3.read_panel(["AAA", "BBB", "CCC"], "adjclose")
|
|
return sorted({d for s in adj.values() for d in s})
|
|
|
|
monkeypatch.setattr(store3, "trading_days_epoch", union_only_calendar)
|
|
union = EquityBacktestRunner(store3, **kw).run()
|
|
monkeypatch.setattr(store3, "trading_days_epoch", real_tde)
|
|
store3.close()
|
|
|
|
# The full-calendar holding-period series is at least as long as the sparser
|
|
# union-derived one (it never drops a real trading day).
|
|
for c in ("long", "ls", "tilt"):
|
|
assert len(full.returns_by_construction[c]) >= len(union.returns_by_construction[c])
|
|
|
|
|
|
def test_evaluate_constructions_sr_variance_is_cross_construction_computed_once(monkeypatch):
|
|
# FIX #1: sr_variance must be the variance of the per-construction IS Sharpes
|
|
# ACROSS the constructions, computed ONCE and shared by every evaluate() call —
|
|
# NOT the per-construction (1/len(r)) value computed inside the loop.
|
|
import fxhnt.application.equity_backtest_runner as runner_mod
|
|
|
|
rng = np.random.default_rng(0)
|
|
series = {
|
|
"long": rng.normal(0.001, 0.01, 300),
|
|
"ls": rng.normal(0.0, 0.02, 300),
|
|
"tilt": rng.normal(0.0005, 0.015, 300),
|
|
}
|
|
result = BacktestRunResult(
|
|
returns_by_construction=series, rebalance_dates=[], n_names_avg=0.0,
|
|
)
|
|
|
|
seen_sr_variance: list[float] = []
|
|
real_evaluate = runner_mod.evaluate
|
|
|
|
def spy_evaluate(*args, **kwargs):
|
|
seen_sr_variance.append(kwargs["sr_variance"])
|
|
return real_evaluate(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(runner_mod, "evaluate", spy_evaluate)
|
|
evaluate_constructions(result, oos_fraction=0.40)
|
|
|
|
# one evaluate() call per construction, all sharing the SAME sr_variance value
|
|
assert len(seen_sr_variance) == 3
|
|
assert len(set(seen_sr_variance)) == 1
|
|
# and that single value equals the cross-construction variance of IS Sharpes
|
|
is_sharpes = [
|
|
runner_mod.per_period_sharpe(r[: int(0.60 * len(r))]) for r in series.values()
|
|
]
|
|
assert seen_sr_variance[0] == pytest.approx(float(np.var(is_sharpes)))
|