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

@@ -89,6 +89,13 @@ def load_stock_panels(market: str, strategy_names: list[str], years: int | None,
"coverage": coverage}
def load_etf_panels(market: str, strategy_names: list[str], *, refresh: bool = True) -> dict:
"""Close/open panels in USD for the pure-ETF strategies among ``strategy_names``."""
request = sorted({t for n in strategy_names for t in ETF_STRATEGY_UNIVERSES[n]} | {"SPY"})
etfs = _panels("etfs", price_request(market, request), ["close", "open"], refresh)
return {f: convert_quote_currency(p) for f, p in etfs.items()}
def prepare_strategy(name: str, market: str, panels: dict, cols: list[str], *,
member: pd.DataFrame | None = None, years: int | None = None,
start=None, end=None, strategy=None) -> dict:
@@ -99,6 +106,12 @@ def prepare_strategy(name: str, market: str, panels: dict, cols: list[str], *,
if benchmark in close:
close = close.loc[close[benchmark].notna()]
extra = strategy_extra_tickers(name) + strategy_signal_tickers(name, market)
if name in ETF_STRATEGY_UNIVERSES:
# A pure-ETF strategy handed the stock panel finds none of its tickers
# and would replay as an all-cash book instead of failing.
missing = sorted(set(ETF_STRATEGY_UNIVERSES[name]) - (set(cols) & set(close.columns)))
if missing:
raise ValueError(f"{name} needs its ETF universe; missing {missing}")
cols = sorted(set(cols + extra) & set(close.columns))
bars = {f: panels[f].reindex(index=close.index, columns=cols)
for f in strategy_fields(name)}
@@ -115,7 +128,10 @@ def prepare_strategy(name: str, market: str, panels: dict, cols: list[str], *,
"warmup_rows": int((close.index < days[0]).sum()),
"resize_band": strategy_resize_band(name),
"previous_prices": close.reindex(columns=targets.columns).ffill().shift(1).loc[days],
"volume": panels["volume"].reindex(index=days, columns=targets.columns) if "volume" in panels else None}
"volume": panels["volume"].reindex(index=days, columns=targets.columns) if "volume" in panels else None,
# Tickers outside the membership history (ETFs) count as members: never settled.
"member": (member.reindex(index=days, columns=targets.columns, fill_value=True)
if member is not None else None)}
def replay_prepared(plan: dict, capital: float, tx_cost: float, fixed_fee: float,
@@ -124,6 +140,7 @@ def replay_prepared(plan: dict, capital: float, tx_cost: float, fixed_fee: float
resize_band: float | None = None, cash_returns=None) -> dict:
result = simulate_book(plan["targets"], plan["prices"], capital, market=plan["market"],
previous_prices=plan.get("previous_prices"), volume=plan.get("volume"),
member=plan.get("member"),
transaction_cost=tx_cost, fixed_fee=fixed_fee,
fee_base=fee_base, fee_per_share=fee_per_share,
integer_shares=integer_shares,
@@ -167,11 +184,7 @@ def run_market(market: str, strategy_names: list[str], years: int | None, capita
stocks = [n for n in strategy_names if n not in ETF_STRATEGY_UNIVERSES]
stock = load_stock_panels(market, stocks, years, refresh=refresh) if stocks else None
etf_names = [n for n in strategy_names if n in ETF_STRATEGY_UNIVERSES]
etfs = None
if etf_names:
request = sorted(set(t for n in etf_names for t in ETF_STRATEGY_UNIVERSES[n]) | {"SPY"})
etfs = _panels("etfs", price_request(market, request), ["close", "open"], refresh)
etfs = {f: convert_quote_currency(p) for f, p in etfs.items()}
etfs = load_etf_panels(market, etf_names, refresh=refresh) if etf_names else None
histories = []
for panel, benchmark in ((stock["panels"], stock["benchmark"]) if stock else (None, None),
(etfs, "SPY")):
@@ -213,7 +226,9 @@ def run_market(market: str, strategy_names: list[str], years: int | None, capita
curve.insert(0, {"date": str(first.date()), "value": capital})
coverage = stock["coverage"].loc[first:last].dropna() if stock else pd.Series(dtype=float)
warnings = ["使用复权价格:历史整股、整手及公司行动的模拟仍有近似。",
"缺少完整退市结算和市场冲击数据,收益为研究估计。"]
("美股持仓停止报价 5 个交易日且已移出指数后按最后成交价结算;" if market == "us"
else "停牌或退市的 A 股持仓保持冻结,不做退市结算;")
+ "缺少市场冲击数据,收益为研究估计。"]
if market == "cn":
warnings.append("沪深300历史成分股按月记录;缺少完整历史ST状态及集合竞价数据,涨跌停成交采用保守限制。")
if common_start > requested_start: