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:
86
tests/test_causality_contract.py
Normal file
86
tests/test_causality_contract.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user