Files

583 lines
23 KiB
Python
Raw Permalink Normal View History

2026-07-03 01:11:00 +08:00
"""
2026-07-11 14:04:56 +08:00
run_backtest.py — 多因子组合回测(含手续费/滑点、夏普、资金曲线)
2026-07-03 01:11:00 +08:00
训练/回测一律使用本地 Parquet,不连接 MT5 在线拉数。
2026-07-03 01:11:00 +08:00
用法:
python run_backtest.py --strategy-file strategies/best_ADAUSD.json --data-file D:\\K线数据\\ADAUSD_H1.parquet
python run_backtest.py --strategy-file path\\to\\strategy.json
# 若策略 JSON 内含 data_file 字段,可省略 --data-file
python run_backtest.py --commission 0.02 --slippage 0.01
2026-07-11 14:04:56 +08:00
# 单边手续费/滑点(单位 %),默认 0.02 / 0.01
2026-07-03 01:11:00 +08:00
"""
2026-07-03 12:01:30 +08:00
import json, sys, math
2026-07-03 01:11:00 +08:00
from pathlib import Path
2026-07-03 12:01:30 +08:00
import numpy as np
import torch
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
2026-07-03 01:11:00 +08:00
sys.path.insert(0, str(Path(__file__).parent))
from config import Config
from data_pipeline.parquet_manager import ParquetDataManager
2026-07-11 14:04:56 +08:00
from backtest_viz import BacktestEngine
2026-07-03 01:15:41 +08:00
from model_core.vocab import FORMULA_VOCAB, VOCAB_VERSION
from model_core.vm import StackVM
from model_core.features import MT5FeatureEngineer
2026-07-17 16:08:18 +08:00
from model_core.backtest import estimate_periods_per_year
2026-07-03 12:01:30 +08:00
from strategy_manager.signal import compute_target_positions_stateless
_H1_PER_YEAR = 6240
2026-07-11 14:04:56 +08:00
DEFAULT_COMMISSION_PCT = 0.02 # 单边手续费 %
DEFAULT_SLIPPAGE_PCT = 0.01 # 单边滑点 %
2026-07-03 01:11:00 +08:00
def decode_formula(tokens: list[int]) -> str:
names = FORMULA_VOCAB.token_names
2026-07-03 01:15:41 +08:00
return " -> ".join(names[t] if 0 <= t < len(names) else f"?{t}" for t in tokens)
def load_strategy(path: Path) -> dict | None:
if not path.exists():
return None
2026-07-10 18:14:59 +08:00
with open(path, encoding="utf-8") as f:
2026-07-03 01:15:41 +08:00
data = json.load(f)
if isinstance(data, list):
return {"formula": data, "vocab_version": "legacy", "symbol": None}
return data
2026-07-03 01:11:00 +08:00
2026-07-03 12:01:30 +08:00
# ── 统计指标 ──────────────────────────────────────────────────────────────────
def calc_sharpe(pnl: np.ndarray, periods_per_year: int = _H1_PER_YEAR) -> float:
"""年化 Sharpe(无风险利率=0)。"""
m = pnl.mean()
s = pnl.std(ddof=0)
if s < 1e-10:
return 0.0
return float(m / s * math.sqrt(periods_per_year))
def calc_sortino(pnl: np.ndarray, periods_per_year: int = _H1_PER_YEAR) -> float:
"""年化 Sortino(下行标准差)。"""
m = pnl.mean()
down = pnl[pnl < 0]
ds = down.std(ddof=0) if len(down) > 0 else 1e-10
ds = max(ds, abs(m), 1e-10)
return float(np.clip(m / ds * math.sqrt(periods_per_year), -20, 20))
2026-07-11 14:04:56 +08:00
def calc_rolling_sharpe(
pnl: np.ndarray,
window: int = 500,
periods_per_year: int = _H1_PER_YEAR,
) -> np.ndarray:
"""滚动年化夏普;窗口不足处为 nan。"""
T = len(pnl)
out = np.full(T, np.nan, dtype=np.float64)
if T == 0 or window <= 1:
return out
w = min(window, T)
# 累积和 / 累积平方和 → O(T) 滑动窗口
csum = np.concatenate([[0.0], np.cumsum(pnl, dtype=np.float64)])
csq = np.concatenate([[0.0], np.cumsum(pnl.astype(np.float64) ** 2)])
for i in range(w - 1, T):
s = csum[i + 1] - csum[i + 1 - w]
sq = csq[i + 1] - csq[i + 1 - w]
mean = s / w
var = sq / w - mean * mean
std = math.sqrt(var) if var > 0 else 0.0
if std < 1e-12:
out[i] = 0.0
else:
out[i] = float(np.clip(mean / std * math.sqrt(periods_per_year), -20, 20))
return out
2026-07-03 12:01:30 +08:00
2026-07-11 14:04:56 +08:00
def _fmt_pl_ratio(results_map: dict) -> str:
vals = [
d["profit_loss_ratio"]
for d in results_map.values()
if d.get("profit_loss_ratio") is not None
]
if not vals:
return "—"
return f"{sum(vals) / len(vals):.3f}"
2026-07-03 12:01:30 +08:00
# ── 资金曲线图 ────────────────────────────────────────────────────────────────
2026-07-11 14:04:56 +08:00
def _setup_chinese_font() -> None:
"""让 matplotlib 能正确显示中文(Windows 优先微软雅黑)。"""
from matplotlib import font_manager
candidates = [
"Microsoft YaHei",
"SimHei",
"SimSun",
"Noto Sans CJK SC",
"Source Han Sans SC",
"Arial Unicode MS",
]
available = {f.name for f in font_manager.fontManager.ttflist}
for name in candidates:
if name in available:
plt.rcParams["font.sans-serif"] = [name, "DejaVu Sans"]
break
plt.rcParams["axes.unicode_minus"] = False
2026-07-17 16:08:18 +08:00
def plot_equity_curves(results_map: dict, output_dir: str, times_arr: np.ndarray | None = None,
periods_per_year: int = _H1_PER_YEAR):
2026-07-11 14:04:56 +08:00
"""绘制各品种 + 等权组合的资金曲线(中文标注)。
2026-07-03 12:01:30 +08:00
Args:
results_map: {symbol: {"pnl": np.array, "cum_pnl": np.array, ...}}
output_dir: 输出目录
times_arr: 时间戳数组(Unix秒),用于 X 轴刻度
2026-07-17 16:08:18 +08:00
periods_per_year: 年化因子(每年 bar 数),由调用方按数据周期估计
2026-07-03 12:01:30 +08:00
"""
2026-07-11 14:04:56 +08:00
_setup_chinese_font()
2026-07-03 12:01:30 +08:00
Path(output_dir).mkdir(parents=True, exist_ok=True)
syms = list(results_map.keys())
n_syms = len(syms)
2026-07-11 14:04:56 +08:00
fig, ax_eq = plt.subplots(figsize=(18, 7), dpi=110)
2026-07-03 12:01:30 +08:00
colors = ["#1565c0", "#00897b", "#e65100", "#6a1b9a", "#558b2f", "#b71c1c"]
# 等权组合 PnL
all_pnls = np.stack([results_map[s]["pnl"] for s in syms], axis=0)
port_pnl = all_pnls.mean(axis=0)
port_cum = np.cumsum(port_pnl)
T = len(port_cum)
x = np.arange(T)
2026-07-11 14:04:56 +08:00
if n_syms == 1:
sym = syms[0]
2026-07-03 12:01:30 +08:00
cum = results_map[sym]["cum_pnl"]
2026-07-11 14:04:56 +08:00
ax_eq.plot(
x, cum, linewidth=2.0, color="#1565c0",
label=f"{sym}(索提诺 {results_map[sym]['sortino']:+.2f})",
)
ax_eq.fill_between(x, cum, 0, where=cum >= 0, alpha=0.08, color="#1565c0")
ax_eq.fill_between(x, cum, 0, where=cum < 0, alpha=0.08, color="#b71c1c")
title_head = f"{sym} 资金曲线"
show_pnl, show_cum = results_map[sym]["pnl"], cum
else:
for i, sym in enumerate(syms):
cum = results_map[sym]["cum_pnl"]
ax_eq.plot(
x, cum, linewidth=0.8, alpha=0.65, color=colors[i % len(colors)],
label=f"{sym}(索提诺 {results_map[sym]['sortino']:+.2f})",
)
ax_eq.plot(
x, port_cum, linewidth=2.2, color="black",
2026-07-17 16:08:18 +08:00
label=f"等权组合(索提诺 {calc_sortino(port_pnl, periods_per_year):+.2f})",
2026-07-11 14:04:56 +08:00
)
ax_eq.fill_between(x, port_cum, 0, where=port_cum >= 0, alpha=0.06, color="#1565c0")
ax_eq.fill_between(x, port_cum, 0, where=port_cum < 0, alpha=0.06, color="#b71c1c")
title_head = "多因子组合资金曲线"
show_pnl, show_cum = port_pnl, port_cum
2026-07-03 12:01:30 +08:00
ax_eq.axhline(0, color="gray", linewidth=0.5, linestyle="--")
2026-07-11 14:04:56 +08:00
ax_eq.set_ylabel("累计对数收益", fontsize=10)
ax_eq.legend(loc="upper left", fontsize=9, framealpha=0.7)
2026-07-03 12:01:30 +08:00
ax_eq.grid(alpha=0.25)
ax_eq.set_title(
2026-07-11 14:04:56 +08:00
f"{title_head} | "
f"总收益={show_cum[-1]:+.3f} "
2026-07-17 16:08:18 +08:00
f"夏普={calc_sharpe(show_pnl, periods_per_year):+.2f} "
f"索提诺={calc_sortino(show_pnl, periods_per_year):+.2f} "
2026-07-11 14:04:56 +08:00
f"盈亏比={_fmt_pl_ratio(results_map)}",
fontsize=11, pad=8,
2026-07-03 12:01:30 +08:00
)
# X 轴时间刻度
if times_arr is not None and len(times_arr) == T:
from datetime import datetime, timezone
step = max(1, T // 10)
ticks = x[::step]
labels = [
2026-07-11 14:04:56 +08:00
datetime.fromtimestamp(int(times_arr[i]), tz=timezone.utc).strftime("%Y-%m-%d")
2026-07-03 12:01:30 +08:00
for i in range(0, T, step)
]
2026-07-11 14:04:56 +08:00
ax_eq.set_xticks(ticks)
ax_eq.set_xticklabels(labels[:len(ticks)], fontsize=8, rotation=20)
ax_eq.set_xlabel("日期", fontsize=9)
2026-07-03 12:01:30 +08:00
path = str(Path(output_dir) / "portfolio_equity.png")
fig.savefig(path, bbox_inches="tight")
plt.close(fig)
print(f" 资金曲线图已保存 → {path}")
return path
2026-07-11 14:04:56 +08:00
def export_equity_json(
results_map: dict,
output_dir: str,
times_arr: np.ndarray | None = None,
max_points: int = 1500,
rolling_window: int = 500,
2026-07-17 16:08:18 +08:00
periods_per_year: int = _H1_PER_YEAR,
2026-07-11 14:04:56 +08:00
):
"""导出资金曲线原始数据为 JSON,供前端渲染交互式 HTML 图表。
结构:
{
"labels": [...时间标签],
"n_points": int, "total_bars": int,
"rolling_window": int,
"symbols": { sym: { equity, rolling_sharpe, sharpe, sortino,
total_return, profit_loss_ratio } },
"portfolio": { ... } # 多品种时才有
}
"""
syms = list(results_map.keys())
if not syms:
return None
all_pnls = np.stack([results_map[s]["pnl"] for s in syms], axis=0)
port_pnl = all_pnls.mean(axis=0)
port_cum = np.cumsum(port_pnl)
T = len(port_cum)
# 均匀降采样,保证首尾点在内,避免 JSON 过大导致前端卡顿
if T > max_points:
idx = np.unique(np.linspace(0, T - 1, max_points).astype(int))
else:
idx = np.arange(T)
def _sample(arr: np.ndarray) -> list[float | None]:
out = []
for i in idx:
v = arr[i]
if v is None or (isinstance(v, float) and math.isnan(v)):
out.append(None)
else:
out.append(round(float(v), 6))
return out
if times_arr is not None and len(times_arr) == T:
from datetime import datetime, timezone
labels = [
datetime.fromtimestamp(int(times_arr[i]), tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
for i in idx
]
else:
labels = [str(int(i)) for i in idx]
out: dict = {
"labels": labels,
"n_points": int(len(idx)),
"total_bars": int(T),
"rolling_window": int(rolling_window),
"symbols": {},
}
for s in syms:
cum = results_map[s]["cum_pnl"]
2026-07-17 16:08:18 +08:00
roll = calc_rolling_sharpe(results_map[s]["pnl"], window=rolling_window,
periods_per_year=periods_per_year)
2026-07-11 14:04:56 +08:00
pl = results_map[s].get("profit_loss_ratio")
out["symbols"][s] = {
"equity": _sample(cum),
"rolling_sharpe": _sample(roll),
"sharpe": round(float(results_map[s]["sharpe"]), 4),
"sortino": round(float(results_map[s]["sortino"]), 4),
"total_return": round(float(results_map[s]["total_return"]), 6),
"profit_loss_ratio": round(float(pl), 4) if pl is not None else None,
}
if len(syms) > 1:
pl_vals = [
results_map[s]["profit_loss_ratio"]
for s in syms
if results_map[s].get("profit_loss_ratio") is not None
]
port_pl = float(sum(pl_vals) / len(pl_vals)) if pl_vals else None
out["portfolio"] = {
"equity": _sample(port_cum),
2026-07-17 16:08:18 +08:00
"rolling_sharpe": _sample(calc_rolling_sharpe(port_pnl, window=rolling_window,
periods_per_year=periods_per_year)),
"sharpe": round(float(calc_sharpe(port_pnl, periods_per_year)), 4),
"sortino": round(float(calc_sortino(port_pnl, periods_per_year)), 4),
2026-07-11 14:04:56 +08:00
"total_return": round(float(port_cum[-1]), 6),
"profit_loss_ratio": round(port_pl, 4) if port_pl is not None else None,
}
path = Path(output_dir) / "equity_curve.json"
with open(path, "w", encoding="utf-8") as f:
json.dump(out, f, ensure_ascii=False)
print(f" 资金曲线数据已保存 → {path}")
return str(path)
2026-07-03 12:01:30 +08:00
# ── 主流程 ────────────────────────────────────────────────────────────────────
2026-07-03 01:11:00 +08:00
def main():
2026-07-03 12:01:30 +08:00
OUTPUT_DIR = "backtest_output"
2026-07-03 01:15:41 +08:00
single_mode = "--single" in sys.argv
# 回测强制离线:只用本地 Parquet,永不连 MT5 在线
if "--online" in sys.argv or "--mt5" in sys.argv:
print("[ERROR] 回测已禁用在线/MT5 拉数。请使用本地 Parquet(--data-file 或策略内 data_file)。")
sys.exit(1)
2026-07-03 01:11:00 +08:00
2026-07-10 18:14:59 +08:00
strategy_file = None
data_file_arg = None
2026-07-11 14:04:56 +08:00
commission_pct = DEFAULT_COMMISSION_PCT
slippage_pct = DEFAULT_SLIPPAGE_PCT
2026-07-10 18:14:59 +08:00
for i, arg in enumerate(sys.argv):
if arg == "--strategy-file" and i + 1 < len(sys.argv):
strategy_file = sys.argv[i + 1]
elif arg == "--data-file" and i + 1 < len(sys.argv):
data_file_arg = sys.argv[i + 1]
2026-07-11 14:04:56 +08:00
elif arg == "--commission" and i + 1 < len(sys.argv):
commission_pct = float(sys.argv[i + 1])
elif arg == "--slippage" and i + 1 < len(sys.argv):
slippage_pct = float(sys.argv[i + 1])
2026-07-10 18:14:59 +08:00
2026-07-11 14:04:56 +08:00
if commission_pct < 0 or slippage_pct < 0:
print("[ERROR] 手续费/滑点不能为负"); sys.exit(1)
cost_rate_all = (commission_pct + slippage_pct) / 100.0
print(
f"\n交易成本(单边): "
f"手续费={commission_pct:g}% 滑点={slippage_pct:g}% "
f"→ cost_rate={cost_rate_all:.8f}"
)
print("数据模式: 强制离线 Parquet(不连接 MT5)")
2026-07-03 12:01:30 +08:00
2026-07-10 18:14:59 +08:00
# ── 2. 加载策略 ─────────────────────────────────────────────────
strategy_data_file = None
2026-07-03 12:01:30 +08:00
print(f"\n{'='*62}")
2026-07-10 18:14:59 +08:00
if strategy_file:
data = load_strategy(Path(strategy_file))
if data is None:
print(f"[ERROR] 找不到: {strategy_file}"); sys.exit(1)
strategy_data_file = data.get("data_file")
2026-07-10 18:14:59 +08:00
sym = data.get("symbol")
if not sym:
stem = Path(strategy_file).stem
if stem.startswith("best_"):
sym = stem.replace("best_", "", 1)
elif stem.startswith("strategy_"):
# strategy_ADAUSD_step0084_score2.4021 / strategy_ADAUSD (1)
rest = stem.replace("strategy_", "", 1)
sym = rest.split("_step")[0].split(" ")[0]
2026-07-10 18:14:59 +08:00
if not sym:
print("[ERROR] 策略文件未包含 symbol,且无法从文件名识别"); sys.exit(1)
symbol_formulas = {sym: data["formula"]}
sc = data.get("best_score", "N/A")
score_txt = f"{sc:.3f}" if isinstance(sc, (int, float)) else str(sc)
print(f" 模式: 单策略文件 ({Path(strategy_file).name})")
print(f" {sym}: score={score_txt} {decode_formula(data['formula'])}")
if strategy_data_file:
print(f" 策略记录数据: {strategy_data_file}")
2026-07-10 18:14:59 +08:00
elif single_mode:
2026-07-03 01:15:41 +08:00
data = load_strategy(Path(Config.STRATEGY_FILE))
if data is None:
2026-07-03 12:01:30 +08:00
print(f"[ERROR] 找不到: {Config.STRATEGY_FILE}"); sys.exit(1)
strategy_data_file = data.get("data_file")
2026-07-03 12:01:30 +08:00
symbol_formulas = {sym: data["formula"] for sym in Config.SYMBOLS}
print(" 模式: 单公式(所有品种共用)")
2026-07-03 01:15:41 +08:00
else:
symbol_formulas = {}
for sym in Config.SYMBOLS:
path = Path("strategies") / f"best_{sym}.json"
data = load_strategy(path)
if data is None:
2026-07-03 12:01:30 +08:00
print(f" [缺失] {sym}")
2026-07-03 01:15:41 +08:00
continue
ver = data.get("vocab_version", "unknown")
if ver != VOCAB_VERSION:
2026-07-03 12:01:30 +08:00
print(f" [跳过] {sym}: vocab_version 不符 ({ver} vs {VOCAB_VERSION})")
2026-07-03 01:15:41 +08:00
continue
symbol_formulas[sym] = data["formula"]
if not strategy_data_file and data.get("data_file"):
strategy_data_file = data.get("data_file")
2026-07-03 12:01:30 +08:00
sc = data.get("best_score", "N/A")
print(f" {sym}: score={sc:.3f} {decode_formula(data['formula'])}")
2026-07-03 01:15:41 +08:00
2026-07-03 12:01:30 +08:00
if not symbol_formulas:
print("[ERROR] 没有有效策略,请先运行训练"); sys.exit(1)
2026-07-11 14:04:56 +08:00
cost_rates = {sym: cost_rate_all for sym in symbol_formulas}
2026-07-03 12:01:30 +08:00
print(f"{'='*62}\n")
2026-07-03 01:15:41 +08:00
# ── 3. 加载数据(仅本地 Parquet)────────────────────────────────
if not data_file_arg and strategy_data_file:
data_file_arg = str(strategy_data_file).strip() or None
2026-07-03 01:11:00 +08:00
if not data_file_arg:
print(
"[ERROR] 未指定本地 Parquet。\n"
"请传入 --data-file PATH\\TO\\SYMBOL_TF.parquet,\n"
"或使用包含 data_file 字段的策略 JSON(本软件训练生成)。\n"
"回测不会连接 MT5 / 不会使用在线行情。"
)
sys.exit(1)
parquet_path = Path(data_file_arg)
if not parquet_path.exists():
print(f"[ERROR] Parquet 不存在: {parquet_path}")
sys.exit(1)
print(f"正在加载数据(离线 Parquet: {parquet_path})...")
pm = ParquetDataManager(str(parquet_path))
pm.load()
raw_dict = pm.raw_dict
syms = pm.symbols
# 策略品种名与 Parquet 品种不一致时(单策略 + 单品种文件),映射公式到数据品种
if strategy_file and len(symbol_formulas) == 1 and len(syms) == 1:
strat_sym = next(iter(symbol_formulas))
data_sym = syms[0]
if strat_sym != data_sym:
formula = symbol_formulas[strat_sym]
print(f" [映射] 策略品种 {strat_sym} → 数据品种 {data_sym}")
symbol_formulas = {data_sym: formula}
cost_rates = {data_sym: cost_rate_all}
2026-07-03 01:11:00 +08:00
2026-07-10 18:14:59 +08:00
T = raw_dict["open"].shape[1]
times_all = raw_dict.get("time", None)
2026-07-17 16:08:18 +08:00
# 数据驱动年化因子:按实际时间戳估计每年 bar 数,替代写死的 H1=6240。
# A 股日线(~244)、A 股 15min(~3904)、外汇 H1(6240)、加密日线(365) 都会被正确年化。
ppy = estimate_periods_per_year(times_all) if times_all is not None else _H1_PER_YEAR
print(f" 品种: {syms} T={T} bars 年化因子={ppy} bar/年\n")
2026-07-03 12:01:30 +08:00
2026-07-10 18:14:59 +08:00
# ── 4. 为每品种计算因子 + 回测 ───────────────────────────────
vm = StackVM()
# 因果特征化:_robust_norm 现为滚动窗口实现,传入全量序列是安全的
# 每个时间步 t 的归一化参数只依赖 [t-w+1..t],无 look-ahead
feat = MT5FeatureEngineer.compute_features(raw_dict) # [N, F, T],因果安全
2026-07-03 01:11:00 +08:00
2026-07-10 18:14:59 +08:00
results_map = {}
backtest_results = []
2026-07-03 01:11:00 +08:00
2026-07-10 18:14:59 +08:00
for i, sym in enumerate(syms):
if sym not in symbol_formulas:
print(f" [跳过] {sym}(无策略)")
continue
2026-07-03 01:11:00 +08:00
2026-07-10 18:14:59 +08:00
formula = symbol_formulas[sym]
2026-07-11 14:04:56 +08:00
cost_rate = cost_rates.get(sym, cost_rate_all)
2026-07-10 18:14:59 +08:00
feat_i = feat[i:i+1]
raw_i = {k: v[i:i+1] for k, v in raw_dict.items()}
2026-07-03 01:11:00 +08:00
2026-07-17 16:08:18 +08:00
engine = BacktestEngine(formula=formula, cost_rate=cost_rate,
periods_per_year=ppy)
2026-07-10 18:14:59 +08:00
sym_res = engine.run(raw_i, feat_i, [sym])
backtest_results.extend(sym_res)
r = sym_res[0]
pnl_arr = r.pnl
cum_arr = r.cum_pnl
2026-07-17 16:08:18 +08:00
sharpe = calc_sharpe(pnl_arr, ppy)
sortino = calc_sortino(pnl_arr, ppy)
2026-07-11 14:04:56 +08:00
pl_ratio = r.profit_loss_ratio
2026-07-10 18:14:59 +08:00
results_map[sym] = {
"pnl": pnl_arr,
"cum_pnl": cum_arr,
"total_return": r.total_return,
"sharpe": sharpe,
"sortino": sortino,
"n_trades": r.n_trades,
"win_rate": r.win_rate,
"avg_hold": r.avg_hold_bars,
2026-07-11 14:04:56 +08:00
"profit_loss_ratio": pl_ratio,
2026-07-10 18:14:59 +08:00
"cost_rate": cost_rate,
}
2026-07-03 01:15:41 +08:00
2026-07-03 12:01:30 +08:00
# ── 5. 打印各品种统计 ─────────────────────────────────────────────
print(f"\n{'='*62}")
2026-07-11 14:04:56 +08:00
print(f" 多因子回测报告")
2026-07-03 12:01:30 +08:00
print(f"{'='*62}")
2026-07-11 14:04:56 +08:00
header = f"{'品种':12s} {'PnL':>8} {'Sharpe':>8} {'Sortino':>8} {'盈亏比':>8} {'Trades':>7} {'WinRate':>8} {'AvgH':>6}"
2026-07-03 12:01:30 +08:00
print(f" {header}")
2026-07-11 14:04:56 +08:00
print(f" {'─'*72}")
2026-07-03 12:01:30 +08:00
for sym, d in results_map.items():
2026-07-11 14:04:56 +08:00
pl = d["profit_loss_ratio"]
pl_s = f"{pl:8.3f}" if pl is not None else f"{'—':>8}"
2026-07-03 12:01:30 +08:00
print(f" {sym:12s} "
f"{d['total_return']:+8.3f} "
f"{d['sharpe']:+8.3f} "
f"{d['sortino']:+8.3f} "
2026-07-11 14:04:56 +08:00
f"{pl_s} "
2026-07-03 12:01:30 +08:00
f"{d['n_trades']:7d} "
f"{d['win_rate']:8.1%} "
f"{d['avg_hold']:6.1f}h")
# 等权组合
2026-07-11 14:04:56 +08:00
p_pl_ratio = None
2026-07-03 12:01:30 +08:00
if results_map:
all_pnls = np.stack([d["pnl"] for d in results_map.values()], axis=0)
port_pnl = all_pnls.mean(axis=0)
port_cum = np.cumsum(port_pnl)
2026-07-17 16:08:18 +08:00
p_sharpe = calc_sharpe(port_pnl, ppy)
p_sortino = calc_sortino(port_pnl, ppy)
2026-07-11 14:04:56 +08:00
pl_vals = [d["profit_loss_ratio"] for d in results_map.values()
if d["profit_loss_ratio"] is not None]
p_pl_ratio = float(sum(pl_vals) / len(pl_vals)) if pl_vals else None
pl_s = f"{p_pl_ratio:8.3f}" if p_pl_ratio is not None else f"{'—':>8}"
print(f" {'─'*72}")
2026-07-03 12:01:30 +08:00
print(f" {'Portfolio':12s} "
f"{port_cum[-1]:+8.3f} "
f"{p_sharpe:+8.3f} "
f"{p_sortino:+8.3f} "
2026-07-11 14:04:56 +08:00
f"{pl_s}")
2026-07-03 12:01:30 +08:00
print(f"\n 正收益品种: {sum(1 for d in results_map.values() if d['total_return']>0)}/{len(results_map)}")
print(f" Sharpe>1 品种: {sum(1 for d in results_map.values() if d['sharpe']>1)}/{len(results_map)}")
print(f"{'='*62}\n")
# ── 6. 资金曲线图 ─────────────────────────────────────────────────
2026-07-03 01:15:41 +08:00
Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True)
2026-07-03 12:01:30 +08:00
if results_map:
times_np = times_all[0].numpy() if times_all is not None else None
2026-07-17 16:08:18 +08:00
plot_equity_curves(results_map, OUTPUT_DIR, times_np, periods_per_year=ppy)
export_equity_json(results_map, OUTPUT_DIR, times_np, periods_per_year=ppy)
2026-07-03 01:15:41 +08:00
2026-07-11 14:04:56 +08:00
# ── 7. 资金曲线图已在步骤 6 生成;跳过 K 线/逐笔交易图以加快回测 ─────
2026-07-03 01:15:41 +08:00
2026-07-03 12:01:30 +08:00
# ── 8. 保存 JSON 报告 ─────────────────────────────────────────────
report = {
"mode": "single" if single_mode else "multi_factor",
"cost_rates": cost_rates,
"symbols": {},
"portfolio": {},
}
for sym, d in results_map.items():
formula = symbol_formulas.get(sym, [])
2026-07-11 14:04:56 +08:00
pl = d["profit_loss_ratio"]
2026-07-03 12:01:30 +08:00
report["symbols"][sym] = {
"formula": formula,
"readable": decode_formula(formula),
"cost_rate": d["cost_rate"],
"total_return": round(d["total_return"], 6),
"sharpe": round(d["sharpe"], 4),
"sortino": round(d["sortino"], 4),
"n_trades": d["n_trades"],
"win_rate": round(d["win_rate"], 4),
"avg_hold_bars":round(d["avg_hold"], 2),
2026-07-11 14:04:56 +08:00
"profit_loss_ratio": round(pl, 4) if pl is not None else None,
2026-07-03 12:01:30 +08:00
}
if results_map:
report["portfolio"] = {
"total_return": round(float(port_cum[-1]), 6),
"sharpe": round(p_sharpe, 4),
"sortino": round(p_sortino, 4),
2026-07-11 14:04:56 +08:00
"profit_loss_ratio": round(p_pl_ratio, 4) if p_pl_ratio is not None else None,
2026-07-03 12:01:30 +08:00
}
rp = f"{OUTPUT_DIR}/multi_factor_report.json"
with open(rp, "w") as f:
json.dump(report, f, indent=2, ensure_ascii=False)
print(f"\n JSON 报告已保存 → {rp}")
print("完成。\n")
2026-07-03 01:11:00 +08:00
if __name__ == "__main__":
main()