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

@@ -7,13 +7,31 @@ import math
import numpy as np
import pandas as pd
ENGINE_VERSION = "cash-ledger-pit-v2"
ENGINE_VERSION = "cash-ledger-pit-v3"
# A held US stock that stops printing (cash acquisition, delisting) is sold at
# its last print once it has gone this many sessions without one and is no
# longer an index member; a halted member stays frozen. Without this the
# position stays at its last mark forever and the capital is never
# reinvested. A-shares are excluded: a suspended CSI 300 name can leave the
# index while it still cannot be sold.
SETTLE_AFTER_SESSIONS = 5
def portfolio_value(holdings: dict, prices: dict, cash: float) -> float:
return cash + sum(shares * prices.get(t, 0.0) for t, shares in holdings.items())
def delisting_settlements(market, holdings: dict, sessions_without_print: dict,
members: pd.Series | None) -> set:
"""Held names to sell at their last print today. ``members`` is the day's
index membership; tickers outside it (ETFs) are never settled."""
if market != "us" or members is None:
return set()
return {t for t in holdings
if sessions_without_print.get(t, 0) >= SETTLE_AFTER_SESSIONS and not members.get(t, True)}
def lot_rule(ticker: str, lot_size: int) -> tuple[int, int]:
# STAR orders start at 200 shares, then increase by one (SSE rule 6.7).
return (1, 200) if lot_size == 100 and ticker.startswith("688") and ticker.endswith(".SS") else (lot_size, lot_size)
@@ -282,8 +300,11 @@ def simulate_book(targets: pd.DataFrame, prices: pd.DataFrame, capital: float,
can_buy: pd.DataFrame | None = None, can_sell: pd.DataFrame | None = None,
sell_tax: pd.Series | None = None, market: str | None = None,
previous_prices: pd.DataFrame | None = None,
volume: pd.DataFrame | None = None, **options):
"""Targets are executed at their row's close; stale marks never authorize fills."""
volume: pd.DataFrame | None = None,
member: pd.DataFrame | None = None, **options):
"""Targets are executed at their row's close; stale marks never authorize
fills, except a delisting settlement (``delisting_settlements``, which
needs the point-in-time ``member`` panel)."""
raw = prices if fresh_prices is None else fresh_prices.reindex_like(prices)
marks = prices.ffill()
previous = marks.shift(1) if previous_prices is None else previous_prices.reindex_like(prices)
@@ -295,6 +316,7 @@ def simulate_book(targets: pd.DataFrame, prices: pd.DataFrame, capital: float,
state = {"holdings": {}, "cash": float(capital), "trade_log": []}
equity, units, values, cash = [], [], [], []
unit_count = float(capital)
without_print = {}
for i, day in enumerate(prices.index):
quote = marks.iloc[i].dropna().to_dict()
state["cash"] *= 1 + float(rates.iloc[i])
@@ -307,12 +329,20 @@ def simulate_book(targets: pd.DataFrame, prices: pd.DataFrame, capital: float,
fresh = set(raw.columns[(raw.iloc[i].notna() & (raw.iloc[i] > 0)).to_numpy()])
allowed_buy, allowed_sell, tax = market_permissions(market, day, raw.iloc[i], previous.iloc[i],
volume.loc[day] if volume is not None else None)
without_print = {t: 0 if t in fresh else without_print.get(t, 0) + 1 for t in state["holdings"]}
settle = delisting_settlements(market, state["holdings"], without_print,
member.loc[day] if member is not None else None)
row = targets.iloc[i]
rebalance(state, row[row != 0].to_dict(), quote, fresh,
can_buy=set(can_buy.columns[can_buy.loc[day]]) if can_buy is not None else allowed_buy,
can_sell=set(can_sell.columns[can_sell.loc[day]]) if can_sell is not None else allowed_sell,
sell_tax=float(sell_tax.loc[day]) if sell_tax is not None else tax,
trade_date=str(day.date()), **options)
wanted = {t: w for t, w in row[row != 0].items() if t not in settle}
log = rebalance(state, wanted, quote, fresh | settle,
can_buy=set(can_buy.columns[can_buy.loc[day]]) if can_buy is not None else allowed_buy,
can_sell=(set(can_sell.columns[can_sell.loc[day]]) if can_sell is not None
else allowed_sell) | settle,
sell_tax=float(sell_tax.loc[day]) if sell_tax is not None else tax,
trade_date=str(day.date()), **options)
for trade in log:
if trade["ticker"] in settle:
trade["settled"] = True
value = portfolio_value(state["holdings"], quote, state["cash"])
equity.append(value)
units.append(value / unit_count * capital)