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:
43
trader.py
43
trader.py
@@ -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} "
|
||||
|
||||
Reference in New Issue
Block a user