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:
@@ -445,9 +445,12 @@ def trend_hzz(p):
|
||||
b = np.linalg.lstsq(Xo, y[ok].values, rcond=None)[0][1:]
|
||||
betas.append(pd.Series(b, index=lags, name=d))
|
||||
B = pd.DataFrame(betas)
|
||||
# the regression at month d uses returns through d+21, so it is usable at d+21;
|
||||
# shift by one month, then average the last 12 known betas
|
||||
Ebeta = B.shift(1).rolling(12, min_periods=6).mean()
|
||||
# the regression at month-end d uses returns through d+21 sessions, so its
|
||||
# betas are known from that session on (a month can be shorter than 21
|
||||
# sessions); average the last 12 known betas
|
||||
known = c.index.get_indexer(B.index) + 21
|
||||
B = B[known < len(c.index)].set_axis(c.index[known[known < len(c.index)]], axis=0)
|
||||
Ebeta = B.rolling(12, min_periods=6).mean()
|
||||
Ed = Ebeta.reindex(c.index, method="ffill")
|
||||
return sum(feats[L].mul(Ed[L], axis=0) for L in lags)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ Panels are cached in data/research/*.panel.pkl; delete them after refetching.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pickle
|
||||
@@ -103,11 +104,18 @@ def _tiingo_sig() -> list:
|
||||
return [len(files), round(mtime, 3)]
|
||||
|
||||
|
||||
def _membership_sig() -> str:
|
||||
with open(os.path.join("data", "sp500_history.json")) as fh:
|
||||
intervals = json.load(fh)["intervals"]
|
||||
return hashlib.sha256(json.dumps(intervals, sort_keys=True).encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def load_us() -> Panel:
|
||||
"""Cached US panel, rebuilt whenever the Tiingo overlay changes."""
|
||||
"""Cached US panel, rebuilt whenever the Tiingo overlay or the S&P 500
|
||||
membership intervals change."""
|
||||
path = os.path.join(RAW_DIR, "us.panel.pkl")
|
||||
key = os.path.join(RAW_DIR, "us.panel.key")
|
||||
sig = _tiingo_sig()
|
||||
sig = _tiingo_sig() + [_membership_sig()]
|
||||
if os.path.exists(path) and os.path.exists(key):
|
||||
with open(key) as fh:
|
||||
if json.load(fh) == sig:
|
||||
|
||||
@@ -11,8 +11,9 @@ import pandas as pd
|
||||
|
||||
import metrics
|
||||
import data_manager
|
||||
from backtest_runner import (ENGINE_VERSION, load_stock_panels, prepare_strategy, replay_prepared)
|
||||
from trader import make_strategy
|
||||
from backtest_runner import (ENGINE_VERSION, load_etf_panels, load_stock_panels, prepare_strategy,
|
||||
replay_prepared)
|
||||
from trader import ETF_STRATEGY_UNIVERSES, make_strategy
|
||||
from strategies.robust_momentum import RobustMomentumStrategy
|
||||
from research.lab.trials import append_trials
|
||||
|
||||
@@ -26,8 +27,19 @@ def evaluate_registered(args):
|
||||
market = 'cn' if args.market.startswith('cn') else 'us'
|
||||
destination = Path(args.output or f'data/research/results/registered_{market}')
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
panels = load_stock_panels(market, args.registered, None, refresh=False)
|
||||
end = args.end or str(panels['panels']['close'].index[-1].date())
|
||||
etf_names = [n for n in args.registered if n in ETF_STRATEGY_UNIVERSES]
|
||||
stock_names = [n for n in args.registered if n not in ETF_STRATEGY_UNIVERSES]
|
||||
stock = load_stock_panels(market, stock_names, None, refresh=False) if stock_names else None
|
||||
etfs = load_etf_panels(market, etf_names, refresh=False) if etf_names else None
|
||||
|
||||
def source(name):
|
||||
"""(panels, universe, membership) the strategy is replayed on, as in run_market."""
|
||||
if name in ETF_STRATEGY_UNIVERSES:
|
||||
return etfs, ETF_STRATEGY_UNIVERSES[name], None
|
||||
return stock['panels'], stock['stocks'], stock['member']
|
||||
|
||||
ends = [p['close'].index[-1] for p in ([stock['panels']] if stock else []) + ([etfs] if etfs else [])]
|
||||
end = args.end or str(min(ends).date())
|
||||
start = args.start or '2018-01-01'
|
||||
profiles = ['baseline', 'resize20', 'buffer3'] + (['cash_shy'] if market == 'us' else [])
|
||||
protocol = {
|
||||
@@ -40,8 +52,10 @@ def evaluate_registered(args):
|
||||
for p in [Path('execution.py'), Path('signals.py'), Path('backtest_runner.py')]
|
||||
+ sorted(Path('strategies').glob('*.py'))},
|
||||
'data_hashes': {str(p): hashlib.sha256(p.read_bytes()).hexdigest()
|
||||
for p in [Path('data') / f'{market}.csv', Path('data') / f'{market}_open.csv',
|
||||
Path('data/sp500_history.json' if market == 'us' else 'data/csi300_history.json')]},
|
||||
for p in ([Path('data') / f'{market}.csv', Path('data') / f'{market}_open.csv',
|
||||
Path('data/sp500_history.json' if market == 'us' else 'data/csi300_history.json')]
|
||||
if stock_names else [])
|
||||
+ ([Path('data/etfs.csv'), Path('data/etfs_open.csv')] if etf_names else [])},
|
||||
}
|
||||
protocol_path = destination / 'protocol.json'
|
||||
if protocol_path.exists():
|
||||
@@ -52,7 +66,8 @@ def evaluate_registered(args):
|
||||
'fee_base': 1.88 if market == 'us' else 0, 'fee_per_share': .009 if market == 'us' else 0}
|
||||
for name in args.registered:
|
||||
base_strategy = make_strategy(name, market)
|
||||
base_plan = prepare_strategy(name, market, panels['panels'], panels['stocks'], member=panels['member'],
|
||||
panels, universe, member = source(name)
|
||||
base_plan = prepare_strategy(name, market, panels, universe, member=member,
|
||||
start=start, end=end, strategy=base_strategy)
|
||||
if base_plan['warmup_rows'] < 315:
|
||||
raise ValueError('Registered comparison requires >=315 pre-window sessions; move --start or add history')
|
||||
@@ -66,17 +81,17 @@ def evaluate_registered(args):
|
||||
if not isinstance(base_strategy, RobustMomentumStrategy):
|
||||
continue
|
||||
changed = copy.deepcopy(base_strategy); changed.buffer = 3.0
|
||||
plan = prepare_strategy(name, market, panels['panels'], panels['stocks'], member=panels['member'],
|
||||
plan = prepare_strategy(name, market, panels, universe, member=member,
|
||||
start=start, end=end, strategy=changed)
|
||||
elif profile == 'cash_shy':
|
||||
if 'SHY' not in panels['panels']['close']:
|
||||
if 'SHY' not in panels['close']:
|
||||
raise ValueError('SHY bars are required for a traded cash sleeve')
|
||||
plan = dict(base_plan)
|
||||
plan['prices'] = base_plan['prices'].copy()
|
||||
plan['prices']['SHY'] = panels['panels']['close']['SHY'].reindex(dates)
|
||||
plan['prices']['SHY'] = panels['close']['SHY'].reindex(dates)
|
||||
if base_plan.get('volume') is not None:
|
||||
plan['volume'] = base_plan['volume'].copy()
|
||||
plan['volume']['SHY'] = panels['panels']['volume']['SHY'].reindex(dates)
|
||||
plan['volume']['SHY'] = panels['volume']['SHY'].reindex(dates)
|
||||
target = base_plan['targets'].reindex(columns=plan['prices'].columns, fill_value=0).copy()
|
||||
target['SHY'] += (1-target.sum(axis=1)).clip(lower=0)
|
||||
plan['targets'] = target
|
||||
|
||||
Reference in New Issue
Block a user