Files
AlphaMaster/rigorous_backtest_audit.py
T
2026-07-07 15:17:53 +08:00

396 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
rigorous_backtest_audit.py — 对仓库内所有可用策略做独立严谨回测
不复用训练 reward;从第一性原理计算 PnL。
支持:全样本 / 前后半段 / Walk-Forward 4折 / 成本压力 / 单品种拆解。
"""
from __future__ import annotations
import json
import math
import sys
from pathlib import Path
import numpy as np
import torch
sys.path.insert(0, str(Path(__file__).parent))
from config import Config
from data_pipeline.data_manager import MT5DataManager
from data_pipeline.fetcher import MT5DataFetcher
from model_core.vocab import FORMULA_VOCAB, VOCAB_VERSION
from model_core.vm import StackVM
from strategy_manager.signal import compute_target_positions_stateless
PERIODS_PER_YEAR = 6240
MIN_EXPOSURE = getattr(Config, "MIN_TRADE_EXPOSURE", 0.05)
# 品种组成本(单边 log)
COST_BY_GROUP = {
"forex": 0.00015,
"precious_metals": 0.00020,
"index": 0.00030,
}
# 待测策略:name -> (path, group, symbols_override or None)
CANDIDATES: dict[str, dict] = {
"precious_metals_current": {
"path": "strategies/best_precious_metals.json",
"group": "precious_metals",
"symbols": None,
},
"precious_metals_v1": {
"path": "strategies/precious_metals_v1.json",
"group": "precious_metals",
"symbols": None,
"formula_key": "formula_tokens",
},
"index_current": {
"path": "strategies/best_index.json",
"group": "index",
"symbols": None,
},
}
def decode_formula(tokens: list[int]) -> str:
names = FORMULA_VOCAB.token_names
return " -> ".join(names[t] if 0 <= t < len(names) else f"?{t}" for t in tokens)
def load_candidate(path: str, formula_key: str = "formula") -> dict | None:
p = Path(path)
if not p.exists():
return None
data = json.load(p.open(encoding="utf-8"))
ver = data.get("vocab_version")
if ver and ver != VOCAB_VERSION:
return {"error": f"vocab mismatch {ver} != {VOCAB_VERSION}"}
formula = data.get(formula_key) or data.get("formula")
if not formula:
return {"error": "no formula"}
return {
"formula": [int(t) for t in formula],
"best_score": data.get("best_score") or data.get("train_best_score"),
"status": data.get("status", ""),
"readable": data.get("formula_readable") or decode_formula(formula),
}
def independent_backtest(
factor: torch.Tensor,
target_ret: torch.Tensor,
cost_rate: float,
) -> dict:
pos = compute_target_positions_stateless(factor)
prev = torch.zeros_like(pos)
prev[:, 1:] = pos[:, :-1]
turnover = (pos - prev).abs()
pnl = pos * target_ret - turnover * cost_rate
port = pnl.mean(dim=0)
T = int(port.shape[0])
def _ann(x: torch.Tensor) -> float:
return float(x.mean().item() * PERIODS_PER_YEAR)
def _sharpe(x: torch.Tensor) -> float:
m, s = x.mean().item(), x.std(unbiased=False).item()
return float(m / (s + 1e-8) * math.sqrt(PERIODS_PER_YEAR)) if s > 1e-8 else 0.0
def _sortino(x: torch.Tensor) -> float:
m = x.mean().item()
down = x[x < 0]
ds = down.std(unbiased=False).item() if down.numel() else 0.0
floor = max(x.std(unbiased=False).item() * 0.2, 1e-8)
ds = max(ds, floor)
return float(np.clip(m / ds * math.sqrt(PERIODS_PER_YEAR), -20, 20))
def _mdd(x: torch.Tensor) -> float:
cum = torch.cumsum(x, dim=0)
peak = torch.cummax(cum, dim=0).values
return float((peak - cum).max().item())
half = T // 2
h1, h2 = port[:half], port[half:]
# Walk-forward 4 folds (val only)
n_folds = 4
fold_size = max(1, T // n_folds)
wf = []
for k in range(1, n_folds):
vs = fold_size * k
ve = min(vs + fold_size, T)
if vs >= T:
break
vp = port[vs:ve]
wf.append({
"fold": k,
"bars": ve - vs,
"ann_ret": _ann(vp),
"sharpe": _sharpe(vp),
"mdd": _mdd(vp),
"positive": _ann(vp) > 0,
})
cost_stress = {}
for mult in (1.0, 2.0, 3.0, 5.0):
sp = (pos * target_ret - turnover * cost_rate * mult).mean(dim=0)
cost_stress[f"{mult}x"] = {
"ann_ret": _ann(sp),
"sharpe": _sharpe(sp),
"profitable": _ann(sp) > 0,
}
long_r = (pos > MIN_EXPOSURE).float().mean().item()
short_r = (pos < -MIN_EXPOSURE).float().mean().item()
max_side = max(long_r, short_r)
per_sym = []
N = pnl.shape[0]
for n in range(N):
sp = pnl[n]
per_sym.append({
"total_ret": float(sp.sum().item()),
"ann_ret": _ann(sp),
"sharpe": _sharpe(sp),
"sortino": _sortino(sp),
"mdd": _mdd(sp),
"long_pct": float((pos[n] > MIN_EXPOSURE).float().mean().item()),
"short_pct": float((pos[n] < -MIN_EXPOSURE).float().mean().item()),
})
ann = _ann(port)
sharpe = _sharpe(port)
mdd = _mdd(port)
verdict = "VALID"
issues: list[str] = []
if ann < 0.02:
verdict = "INVALID"
issues.append(f"年化 {ann*100:.2f}% < 2%")
if sharpe < 0.5:
if verdict == "VALID":
verdict = "SUSPICIOUS"
issues.append(f"Sharpe {sharpe:.3f} < 0.5")
if mdd > 0.10:
if verdict == "VALID":
verdict = "SUSPICIOUS"
issues.append(f"MDD {mdd*100:.2f}% > 10%")
if mdd > 0.20:
verdict = "INVALID"
if max_side > 0.85:
if verdict == "VALID":
verdict = "SUSPICIOUS"
issues.append(f"单边占比 {max_side*100:.1f}% > 85%")
if _ann(h1) * _ann(h2) < 0:
if verdict == "VALID":
verdict = "SUSPICIOUS"
issues.append(f"前后半段年化异号 H1={_ann(h1)*100:.2f}% H2={_ann(h2)*100:.2f}%")
wf_pos = sum(1 for w in wf if w["positive"])
if wf and wf_pos < len(wf) * 0.5:
if verdict == "VALID":
verdict = "SUSPICIOUS"
issues.append(f"WF 仅 {wf_pos}/{len(wf)} 折为正")
if not cost_stress["2.0x"]["profitable"]:
if verdict == "VALID":
verdict = "SUSPICIOUS"
issues.append("2x 成本下亏损")
return {
"verdict": verdict,
"issues": issues,
"T": T,
"years": T / PERIODS_PER_YEAR,
"ann_ret": ann,
"total_ret": float(port.sum().item()),
"sharpe": sharpe,
"sortino": _sortino(port),
"mdd": mdd,
"calmar": ann / (mdd + 1e-8),
"long_pct": long_r,
"short_pct": short_r,
"max_side": max_side,
"h1_ann": _ann(h1),
"h2_ann": _ann(h2),
"h1_sharpe": _sharpe(h1),
"h2_sharpe": _sharpe(h2),
"walk_forward": wf,
"cost_stress": cost_stress,
"per_symbol": per_sym,
"n_pos_syms": sum(1 for p in per_sym if p["total_ret"] > 0),
"n_syms": N,
}
def run_one(name: str, meta: dict, fetcher: MT5DataFetcher) -> dict | None:
loaded = load_candidate(meta["path"], meta.get("formula_key", "formula"))
if loaded is None:
print(f"\n[{name}] SKIP: file missing {meta['path']}")
return None
if "error" in loaded:
print(f"\n[{name}] SKIP: {loaded['error']}")
return None
group = meta["group"]
symbols = meta.get("symbols") or Config.SYMBOL_GROUPS[group]
cost = COST_BY_GROUP.get(group, Config.COST_RATE)
print(f"\n{'='*72}")
print(f" [{name}] group={group}")
print(f" symbols={symbols}")
print(f" formula: {loaded['readable']}")
print(f" train_score={loaded.get('best_score')} status={loaded.get('status') or '-'}")
print(f" cost_rate={cost}")
print(f"{'='*72}")
orig = Config.SYMBOLS[:]
Config.SYMBOLS = list(symbols)
try:
mgr = MT5DataManager(fetcher)
mgr.load()
syms = mgr.symbols
T = mgr.raw_dict["open"].shape[1]
times = mgr.raw_dict.get("time")
t0 = int(times[0, 0]) if times is not None else 0
t1 = int(times[0, -1]) if times is not None else 0
from datetime import datetime, timezone
if t0 and t1:
d0 = datetime.fromtimestamp(t0, tz=timezone.utc).strftime("%Y-%m-%d")
d1 = datetime.fromtimestamp(t1, tz=timezone.utc).strftime("%Y-%m-%d")
print(f" loaded: {syms} T={T} ({T/PERIODS_PER_YEAR:.2f}y) {d0} .. {d1}")
else:
print(f" loaded: {syms} T={T} ({T/PERIODS_PER_YEAR:.2f}y)")
vm = StackVM()
factor = vm.execute(loaded["formula"], mgr.feat_tensor)
if factor is None:
print(" ERROR: formula execution failed")
return {"error": "vm failed"}
bt = independent_backtest(factor, mgr.target_ret, cost)
bt["name"] = name
bt["group"] = group
bt["symbols"] = syms
bt["formula"] = loaded["formula"]
bt["readable"] = loaded["readable"]
bt["train_score"] = loaded.get("best_score")
print(f"\n 组合: ann={bt['ann_ret']*100:+.2f}% Sharpe={bt['sharpe']:+.3f} "
f"Sortino={bt['sortino']:+.3f} MDD={bt['mdd']*100:.2f}% "
f"Calmar={bt['calmar']:+.2f}")
print(f" 多空: L={bt['long_pct']*100:.1f}% S={bt['short_pct']*100:.1f}% "
f"max_side={bt['max_side']*100:.1f}%")
print(f" 一致性: H1={bt['h1_ann']*100:+.2f}% H2={bt['h2_ann']*100:+.2f}% "
f"H1_S={bt['h1_sharpe']:+.2f} H2_S={bt['h2_sharpe']:+.2f}")
wf_pos = sum(1 for w in bt["walk_forward"] if w["positive"])
print(f" WF: {wf_pos}/{len(bt['walk_forward'])} 折正收益")
for mult, cs in bt["cost_stress"].items():
print(f" cost {mult}: ann={cs['ann_ret']*100:+.2f}% sharpe={cs['sharpe']:+.3f} "
f"ok={'Y' if cs['profitable'] else 'N'}")
print(f"\n 品种级:")
for i, sym in enumerate(syms):
ps = bt["per_symbol"][i]
tag = "OK" if ps["total_ret"] > 0 else "X"
print(f" {sym:14s}: ann={ps['ann_ret']*100:+7.2f}% Sharpe={ps['sharpe']:+6.3f} "
f"MDD={ps['mdd']*100:5.2f}% L/S={ps['long_pct']*100:.0f}/{ps['short_pct']*100:.0f} [{tag}]")
print(f"\n 判定: {bt['verdict']}")
for iss in bt["issues"]:
print(f" ! {iss}")
if not bt["issues"]:
print(" 全部检查通过")
# 单品种拆解(同一公式,N=1)
print(f"\n --- 单品种独立回测(同公式,逐品种加载)---")
solo_results = []
for sym in symbols:
Config.SYMBOLS = [sym]
try:
sm = MT5DataManager(fetcher)
sm.load()
if sym not in sm.symbols:
continue
f1 = vm.execute(loaded["formula"], sm.feat_tensor)
if f1 is None:
continue
sb = independent_backtest(f1, sm.target_ret, cost)
solo_results.append({"symbol": sym, **sb})
print(f" {sym:14s} solo: ann={sb['ann_ret']*100:+7.2f}% "
f"Sharpe={sb['sharpe']:+.3f} MDD={sb['mdd']*100:.2f}% "
f"verdict={sb['verdict']}")
except Exception as e:
print(f" {sym:14s} solo ERROR: {e}")
bt["solo"] = solo_results
return bt
finally:
Config.SYMBOLS = orig
def main():
offline = "--offline" in sys.argv
mode = "offline" if offline else "MT5/live"
print(f"\nRigorous Backtest Audit | vocab={VOCAB_VERSION} | mode={mode}\n")
results: dict[str, dict] = {}
with MT5DataFetcher(offline=offline) as fetcher:
if not offline:
try:
fetcher.connect()
except Exception as e:
print(f"MT5 connect failed: {e}\nRetry with --offline if cache exists.")
sys.exit(1)
for name, meta in CANDIDATES.items():
r = run_one(name, meta, fetcher)
if r:
results[name] = r
print(f"\n\n{'='*88}")
print(" 汇总(组合等权)")
print(f"{'='*88}")
print(f"{'策略':<26} {'年化%':>8} {'Sharpe':>8} {'MDD%':>8} {'WF+':>6} {'2x成本':>8} {'盈利品种':>10} {'判定':>12}")
print("-" * 88)
for name, r in results.items():
if "error" in r:
print(f"{name:<26} ERROR")
continue
wf_pos = sum(1 for w in r["walk_forward"] if w["positive"])
wf_n = len(r["walk_forward"])
ok2x = "Y" if r["cost_stress"]["2.0x"]["profitable"] else "N"
print(f"{name:<26} {r['ann_ret']*100:>8.2f} {r['sharpe']:>8.3f} {r['mdd']*100:>8.2f} "
f"{wf_pos}/{wf_n:<4} {ok2x:>8} {r['n_pos_syms']}/{r['n_syms']:<8} {r['verdict']:>12}")
# 单品种最优
print(f"\n{'='*88}")
print(" 单品种拆解(同公式 solo)")
print(f"{'='*88}")
solo_rows = []
for name, r in results.items():
for s in r.get("solo", []):
solo_rows.append((s["ann_ret"], name, s["symbol"], s))
solo_rows.sort(reverse=True, key=lambda x: x[0])
print(f"{'品种':<14} {'来源策略':<26} {'年化%':>8} {'Sharpe':>8} {'MDD%':>8} {'判定':>12}")
print("-" * 88)
for _, name, sym, s in solo_rows:
print(f"{sym:<14} {name:<26} {s['ann_ret']*100:>8.2f} {s['sharpe']:>8.3f} "
f"{s['mdd']*100:>8.2f} {s['verdict']:>12}")
out = Path("backtest_output/rigorous_audit.json")
out.parent.mkdir(parents=True, exist_ok=True)
serial = {}
for k, v in results.items():
if "error" in v:
serial[k] = v
continue
serial[k] = {key: val for key, val in v.items() if key != "solo" or True}
if "solo" in v:
serial[k]["solo"] = [
{kk: vv for kk, vv in s.items() if kk not in ("walk_forward", "cost_stress", "per_symbol", "issues")}
for s in v["solo"]
]
out.write_text(json.dumps(serial, indent=2, ensure_ascii=False), encoding="utf-8")
print(f"\n详细 JSON → {out}")
if __name__ == "__main__":
main()