Share point-in-time signals and a cash-funded order ledger across backtests and trading. Correct eligibility, missing-price fills, quality factors, regime inputs, warmup windows, and cash-injection accounting. Add reproducible registered-strategy comparisons and paired forward candidates with a 20% resize band. Validation: 125 Python tests, 23 web tests, TypeScript/Vite build, 87 strategy causality checks, and real-data target/order/equity parity for 10 strategies.
112 lines
6.7 KiB
Python
112 lines
6.7 KiB
Python
"""Fixed-budget validation of registered strategies on the shared execution engine."""
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import hashlib
|
|
import json
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
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 strategies.robust_momentum import RobustMomentumStrategy
|
|
from research.lab.trials import append_trials
|
|
|
|
|
|
def _stats(equity):
|
|
return {k: v for k, v in metrics.raw_summary(equity).items()
|
|
if k in ('annualizedReturn', 'maxDrawdown', 'sharpeRatio', 'annualizedVolatility')}
|
|
|
|
|
|
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())
|
|
start = args.start or '2018-01-01'
|
|
profiles = ['baseline', 'resize20', 'buffer3'] + (['cash_shy'] if market == 'us' else [])
|
|
protocol = {
|
|
'created_at': datetime.now(timezone.utc).isoformat(), 'engine': ENGINE_VERSION,
|
|
'market': market, 'strategies': args.registered, 'start': start, 'end': end,
|
|
'profiles': profiles, 'one_way_costs': [.001, .0025], 'capital': 10000 if market == 'us' else 70000,
|
|
'selection_rule': 'CAGR improves at both costs; drawdown deterioration <= 2pp; improves in >=2/3 time blocks',
|
|
'role': 'historical development comparison; not independent OOS; do not auto-promote',
|
|
'source_hashes': {str(p): hashlib.sha256(p.read_bytes()).hexdigest()
|
|
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')]},
|
|
}
|
|
protocol_path = destination / 'protocol.json'
|
|
if protocol_path.exists():
|
|
raise FileExistsError(f'Choose a new --output to preserve the completed study: {destination}')
|
|
protocol_path.write_text(json.dumps(protocol, indent=2))
|
|
rows, verdicts = [], []
|
|
fees = {'tx_cost': .001, 'fixed_fee': 2 if market == 'us' else 5,
|
|
'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'],
|
|
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')
|
|
dates = base_plan['prices'].index
|
|
cuts = [0, len(dates)//3, 2*len(dates)//3, len(dates)]
|
|
for profile in profiles:
|
|
plan, band = base_plan, .1
|
|
if profile == 'resize20':
|
|
band = .2
|
|
elif profile == 'buffer3':
|
|
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'],
|
|
start=start, end=end, strategy=changed)
|
|
elif profile == 'cash_shy':
|
|
if 'SHY' not in panels['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)
|
|
if base_plan.get('volume') is not None:
|
|
plan['volume'] = base_plan['volume'].copy()
|
|
plan['volume']['SHY'] = panels['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
|
|
for cost in protocol['one_way_costs']:
|
|
run = replay_prepared(plan, protocol['capital'], **{**fees, 'tx_cost': cost}, resize_band=band)
|
|
row = {'strategy': name, 'profile': profile, 'cost': cost, **_stats(run['equity']),
|
|
'orders': len(run['trade_log']), 'fees': sum(t['commission'] for t in run['trade_log']),
|
|
'minimum_cash': float(run['cash'].min()), 'blocks': []}
|
|
for a, b in zip(cuts[:-1], cuts[1:]):
|
|
row['blocks'].append(_stats(run['equity'].iloc[a:b+1]))
|
|
rows.append(row)
|
|
append_trials(destination/'trials.jsonl', [row], protocol)
|
|
print(name, profile, cost, f"CAGR {row['annualizedReturn']:.2%}",
|
|
f"DD {row['maxDrawdown']:.2%}", flush=True)
|
|
baseline = {r['cost']: r for r in rows if r['strategy'] == name and r['profile'] == 'baseline'}
|
|
for profile in profiles[1:]:
|
|
candidates = [r for r in rows if r['strategy'] == name and r['profile'] == profile]
|
|
if not candidates:
|
|
continue
|
|
passed = all(r['annualizedReturn'] > baseline[r['cost']]['annualizedReturn']
|
|
and r['maxDrawdown'] >= baseline[r['cost']]['maxDrawdown']-.02
|
|
and sum(a['annualizedReturn'] > b['annualizedReturn']
|
|
for a,b in zip(r['blocks'],baseline[r['cost']]['blocks'])) >= 2
|
|
for r in candidates)
|
|
verdicts.append({'strategy': name, 'profile': profile,
|
|
'verdict': 'candidate_for_forward_only' if passed else 'retain_baseline'})
|
|
summary = {'protocol': protocol, 'comparisons': rows, 'decisions': verdicts,
|
|
'boundary': 'Missing historical constituents and adjusted-share execution remain limitations.'}
|
|
(destination/'results.json').write_text(json.dumps(summary, indent=2, allow_nan=False))
|
|
pd.DataFrame([{k:v for k,v in r.items() if k!='blocks'} for r in rows]).to_csv(destination/'results.csv',index=False)
|
|
print('Saved',destination/'results.json',flush=True)
|
|
return summary
|