fix(backtest): settle delisted US holdings (engine v3), PIT membership before 2011, registry causality test

- execution/trader: a held US stock with no print for 5 sessions that has left
  the index is sold at its last print (execution.SETTLE_AFTER_SESSIONS), in both
  the batch replay and the live session ledger. Acquired names used to stay at
  their last mark forever: lowrisk_top20 ended 2017-12..2026-10 with 11 of 26
  dead positions, and CAGR rises 1.7-5.5pp once they settle. ENGINE_VERSION
  cash-ledger-pit-v3; the API cache key follows.
- universe_history: current members whose pre-2011 addition the Wikipedia change
  log missed now start at the list page's "Date added" (80 of 94 current members
  added 2001-2010 were open-start, e.g. ISRG, MA, BKNG). Membership from 2016 on
  is unchanged; the research panel cache now keys on the membership intervals.
- research/lab/registered: pure-ETF strategies replay on the ETF panel (they ran
  as all-cash books or crashed); prepare_strategy refuses an ETF strategy that
  lacks its ETF universe.
- research/lab/factors: trend_hzz uses a month's regression betas only from the
  session its forward returns end (short months read 1-2 sessions ahead).
- tests: every registered strategy (US and CN) is truncation-invariant on the
  production signal path; batch and live ledgers settle a delisting identically.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 00:21:49 -07:00
parent a9683b6d1f
commit cd0721eada
12 changed files with 279 additions and 41 deletions

View File

@@ -0,0 +1,86 @@
"""Every registered strategy is causal on the production signal path.
The targets a replay or the live trader trades on a session must not change
when later bars are removed. ``trader._signals_for_sessions`` and the batch
replay compute all sessions in one pass, which equals running each session on
its own only for causal strategies, so a new strategy that reads a later bar
(a missing shift, a full-sample statistic, a backward fill) fails here.
"""
import unittest
import numpy as np
import pandas as pd
import trader
from backtest_runner import prepare_strategy
from universe import UNIVERSES
N_DAYS, N_STOCKS = 700, 60
CUTS = (560, 640) # truncation points (row positions)
START = 480 # first compared session
def _panel(seed: int = 7) -> tuple[dict[str, pd.DataFrame], pd.DataFrame, list[str]]:
rng = np.random.default_rng(seed)
stocks = [f"S{i:02d}" for i in range(N_STOCKS)]
etfs = sorted({t for u in trader.ETF_STRATEGY_UNIVERSES.values() for t in u}
| {t for u in trader.MIXED_STRATEGY_EXTRA_TICKERS.values() for t in u}
| {UNIVERSES[m]["benchmark"] for m in UNIVERSES})
cols = stocks + etfs
idx = pd.bdate_range("2023-01-02", periods=N_DAYS)
r = rng.normal(0.0004, 0.02, (N_DAYS, len(cols)))
close = pd.DataFrame(100 * np.exp(np.cumsum(r, axis=0)), index=idx, columns=cols)
opens = close.shift(1).bfill() * np.exp(rng.normal(0, 0.005, close.shape))
high = np.maximum(opens, close) * (1 + rng.uniform(0, 0.02, close.shape))
low = np.minimum(opens, close) * (1 - rng.uniform(0, 0.02, close.shape))
volume = pd.DataFrame(rng.uniform(1e5, 1e6, close.shape), index=idx, columns=cols)
# Index changes: five names join late (price history before admission is
# observable), five leave, one stops printing like an acquired stock.
member = pd.DataFrame(True, index=idx, columns=stocks)
member.iloc[:300, :5] = False
member.iloc[500:, 5:10] = False
for frame in (close, opens, high, low):
frame.iloc[600:, 10] = np.nan
volume.iloc[600:, 10] = np.nan
panels = {"close": close, "open": opens, "high": high, "low": low, "volume": volume}
return panels, member, stocks
class CausalityContract(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.panels, cls.member, cls.stocks = _panel()
def _targets(self, name: str, market: str, end) -> pd.DataFrame:
pure_etf = trader.canonical_strategy_name(name) in trader.ETF_STRATEGY_UNIVERSES
cols = trader.ETF_STRATEGY_UNIVERSES[name] if pure_etf else self.stocks
start = self.panels["close"].index[START]
return prepare_strategy(name, market, self.panels, list(cols),
member=None if pure_etf else self.member,
start=start, end=end)["targets"]
def test_targets_ignore_later_bars(self):
idx = self.panels["close"].index
leaks, idle = [], []
for market in ("us", "cn"):
for name in trader.STRATEGY_REGISTRY:
if name in trader.AGENT_REVIEW_STRATEGIES or not trader.strategy_supports_market(name, market):
continue
full = self._targets(name, market, None)
if not (full.to_numpy() > 0).any():
idle.append(f"{market}:{name}")
for cut in CUTS:
part = self._targets(name, market, idx[cut])
cols = full.columns.union(part.columns)
a = full.reindex(index=part.index, columns=cols).fillna(0.0)
b = part.reindex(columns=cols).fillna(0.0)
worst = float((a - b).abs().to_numpy().max())
if worst > 1e-9:
leaks.append(f"{market}:{name} (cut {idx[cut].date()}, max diff {worst:.3g})")
self.assertEqual(leaks, [], "targets depend on bars after their session")
self.assertEqual(idle, [], "never invested on the synthetic panel, so the check is vacuous")
if __name__ == "__main__":
unittest.main()

View File

@@ -52,6 +52,31 @@ class ExecutionContracts(unittest.TestCase):
self.assertAlmostEqual(state["daily_equity"][str(day.date())], batch["equity"].loc[day], places=2)
self.assertEqual(state["trade_log"], batch["trade_log"])
def test_a_delisted_stock_is_settled_at_its_last_print_in_both_ledgers(self):
dates = pd.bdate_range("2026-01-05", periods=9)
# A is acquired: last print on day 2, out of the index from day 4.
# H halts on the same day but stays a member, so it stays frozen.
prices = pd.DataFrame({"A": [100, 101, 102] + [np.nan] * 6,
"H": [50, 50, 51] + [np.nan] * 6,
"B": [20.] * 9}, index=dates)
member = pd.DataFrame(True, index=dates, columns=prices.columns)
member.loc[dates[4]:, "A"] = False
targets = pd.DataFrame([[.4, .3, .3]] * 9, index=dates, columns=prices.columns)
options = dict(transaction_cost=.001, fixed_fee=2., integer_shares=True, resize_band=.1)
batch = simulate_book(targets, prices, 10000, market="us", member=member, **options)
settled = [(t["ticker"], t["date"], t["price"]) for t in batch["trade_log"] if t.get("settled")]
self.assertEqual(settled, [("A", str(dates[7].date()), 102.0)])
self.assertIn("H", batch["holdings"])
state = trader.init_state("us", "test", 10000)
fees = {"tx_cost": .001, "fixed_fee": 2.}
for day in dates:
trader._run_session(state, "us", day, "B", prices.ffill(), prices, fees, True, targets.loc[day],
member_row=member.loc[day])
self.assertAlmostEqual(state["daily_equity"][str(day.date())], batch["equity"].loc[day], places=2)
self.assertEqual(state["trade_log"], batch["trade_log"])
china = simulate_book(targets, prices, 10000, market="cn", member=member, **options)
self.assertFalse(any(t.get("settled") for t in china["trade_log"]))
def test_contributions_are_executed_and_fees_affect_unit_returns(self):
dates = pd.bdate_range("2026-01-05", periods=3)
prices = pd.DataFrame({"A": [100., 100., 100.]}, index=dates)