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

@@ -477,6 +477,14 @@ def index_members(market: str, days: pd.DatetimeIndex) -> pd.DataFrame | None:
return universe_history.pit_membership(market, days)
def settlement_members(market: str, strategy_name: str, days: pd.DatetimeIndex) -> pd.DataFrame | None:
"""Index membership on ``days`` for delisting settlement; None where it does
not apply (A-shares, pure-ETF strategies)."""
if market != "us" or strategy_data_market(market, strategy_name) == "etfs":
return None
return index_members(market, days)
def pit_candidates(market: str, strategy_name: str, since: pd.Timestamp | None) -> list[str]:
"""Tickers that were index members on any day from ``since`` to today, so a
catch-up or replay has prices for names that have left the index since."""
@@ -857,13 +865,16 @@ def _signals_for_sessions(strategy_name: str, open_data: pd.DataFrame,
def _run_session(state: dict, market: str, day: pd.Timestamp, benchmark: str,
close_data: pd.DataFrame, close_raw: pd.DataFrame, fees: dict,
integer_shares: bool, signal_row: pd.Series,
volume_row: pd.Series | None = None) -> dict:
volume_row: pd.Series | None = None,
member_row: pd.Series | None = None) -> dict:
"""Execute ``signal_row`` (targets from ``day``'s open; a BarStrategy: bars
through the previous session, see ``session_signals``) at ``day``'s close
and record the day.
Tickers without a real closing print on ``day`` (forward-filled price) are
valued but not traded.
valued but not traded, except a held US stock that stopped printing and
left the index (``member_row``), which is settled at its last print as in
``execution.simulate_book``.
"""
day_str = str(day.date())
if state.get("engine_version") != ENGINE_VERSION:
@@ -879,18 +890,30 @@ def _run_session(state: dict, market: str, day: pd.Timestamp, benchmark: str,
)
raw_row = close_raw.loc[day]
frozen = {t for t in all_held if t not in raw_row.index or pd.isna(raw_row[t])}
# Imported here so a running monitor's hot reload of this module does not
# need an execution module newer than the one it started with.
from execution import delisting_settlements
upto = close_raw.loc[:day]
without_print = {t: int((upto.index > upto[t].last_valid_index()).sum())
if t in upto.columns and upto[t].notna().any() else len(upto)
for t in state["holdings"]}
settle = delisting_settlements(market, state["holdings"], without_print, member_row)
target_weights = {t: w for t, w in target_weights.items() if t not in settle}
pre_value = portfolio_value(state["holdings"], close_prices, state["cash"])
previous_rows = close_data.loc[close_data.index < day]
previous = previous_rows.iloc[-1] if len(previous_rows) else pd.Series(dtype=float)
allowed_buy, allowed_sell, sell_tax = market_permissions(market, day, raw_row, previous, volume_row)
n_log = len(state["trade_log"])
actual = rebalance(state, target_weights, close_prices, set(raw_row.dropna().index),
actual = rebalance(state, target_weights, close_prices, set(raw_row.dropna().index) | settle,
transaction_cost=fees["tx_cost"], fixed_fee=fees["fixed_fee"],
fee_base=fees.get("fee_base", 0), fee_per_share=fees.get("fee_per_share", 0),
integer_shares=integer_shares, lot_size=lot_size,
trade_date=day_str, resize_band=strategy_resize_band(state["strategy"]),
can_buy=allowed_buy, can_sell=allowed_sell, sell_tax=sell_tax)
can_buy=allowed_buy, can_sell=allowed_sell | settle, sell_tax=sell_tax)
for trade in actual:
if trade["ticker"] in settle:
trade["settled"] = True
trades = [{**t, "direction": t["action"], "est_value": t["value"],
"shares_delta": t["shares"] * (1 if t["action"] == "BUY" else -1)} for t in actual]
post_value = portfolio_value(state["holdings"], close_prices, state["cash"])
@@ -1071,10 +1094,12 @@ def cmd_evening(args):
lot_size = MARKET_LOT_SIZE.get(market, 1)
first_log = len(state["trade_log"])
volume = data_manager.load_raw(strategy_data_market(market, strategy_name), "volume")
members = settlement_members(market, strategy_name, pd.DatetimeIndex([target_date]))
_run_session(state, market, target_date, benchmark, close_data, close_raw,
_strategy_fees(args, market), args.integer_shares,
pd.Series(target_weights, dtype=float),
volume_row=volume.reindex(close_data.index).loc[target_date] if volume is not None else None)
volume_row=volume.reindex(close_data.index).loc[target_date] if volume is not None else None,
member_row=members.loc[target_date] if members is not None else None)
exec_trades = [{**t, "direction": t["action"], "est_value": t["value"]}
for t in state["trade_log"][first_log:]]
@@ -1463,13 +1488,15 @@ def cmd_simulate(args):
total_commission = 0.0
volume = data_manager.load_raw(strategy_data_market(market, strategy_name), "volume")
members = settlement_members(market, strategy_name, trading_days)
for i, day in enumerate(trading_days):
day_str = str(day.date())
stats = _run_session(state, market, day, benchmark, close_data, close_raw,
_strategy_fees(args, market), args.integer_shares,
full_weights.loc[day],
volume_row=(volume.loc[day] if day in volume.index else pd.Series(dtype=float)) if volume is not None else None)
volume_row=(volume.loc[day] if day in volume.index else pd.Series(dtype=float)) if volume is not None else None,
member_row=members.loc[day] if members is not None else None)
post_value = stats["value"]
day_trades, day_commission = stats["trades"], stats["commission"]
total_trades += day_trades
@@ -1864,10 +1891,12 @@ def _process_sessions(args, state: dict | None, start: pd.Timestamp | None = Non
fees = _strategy_fees(args, market)
volume = data_manager.load_raw(strategy_data_market(market, strategy_name), "volume")
signals = session_signals(market, strategy_name, tickers, todo, close_raw, open_data)
members = settlement_members(market, strategy_name, todo)
for day in todo:
r = _run_session(state, market, day, benchmark, close_data, close_raw, fees,
args.integer_shares, signal_row=signals.loc[day],
volume_row=(volume.loc[day] if day in volume.index else pd.Series(dtype=float)) if volume is not None else None)
volume_row=(volume.loc[day] if day in volume.index else pd.Series(dtype=float)) if volume is not None else None,
member_row=members.loc[day] if members is not None else None)
frozen = f" | no fresh price: {', '.join(r['frozen'])}" if r["frozen"] else ""
print(f"[{label}] {r['date']} {strategy_name:<34} value {r['value']:>12,.2f} "
f"({r['daily_return']:+.2%}) trades {r['trades']:>2} "