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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user