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:
46
execution.py
46
execution.py
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user