Initial commit: MT5 EA 回测报告分析工具集

通用可复用的 MT5 回测分析工具,不针对任何具体 EA:

- mt5_report_parser.py: 解析 MT5 中文 xlsx 报告,重建逐笔交易
- run_analysis.py: 主分析器,IS/OSS 对比 + What-If + 蒙特卡洛 + 数据驱动建议
- walk_forward.py: Walk-Forward 滚动 IS→OOS 验证 (WFE/泛化相关性)
- param_scan.py: 参数敏感度扫描,高原检测 + 输出优化后 .set
- mae_mfe.py: MAE/MFE 分析 + TP/SL 扫描,附 MQL5 导出代码

所有输出为自包含 HTML (内联 SVG,无图片依赖)。
This commit is contained in:
2026-06-28 02:31:54 +08:00
commit ed8e8afb44
13 changed files with 3657 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
.eggs/
build/
dist/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.venv/
venv/
env/
# IDE / 编辑器
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
Thumbs.db
# MT5 回测原始报告(含交易数据,敏感,不入库)
# 命名约定:IS-*.xlsx / OSS-*.xlsx / ReportTester-*.xlsx
IS-*.xlsx
OSS-*.xlsx
ReportTester-*.xlsx
ReportTester-*.htm
ReportTester-*.xml
# 参数扫描中间产物(批量回测的 .set 与报告,可由 gen-set 重新生成)
scan_*/
*.set.bak
# MAE/MFE 原始 CSV(由 EA 导出,含逐笔数据)
mae_mfe_*.csv
# 本地配置 / 凭证
.env
.env.local
*.secret
config_local.ini
# 临时与日志
*.log
tmp/
temp/
+180
View File
@@ -0,0 +1,180 @@
# MT5 EA 回测报告分析工具集
一套**通用、可复用**的 MetaTrader 5 EA 回测报告分析工具。读取 MT5 Strategy Tester 导出的 xlsx 报告,输出自包含 HTML(内联 SVG,无图片依赖,AI 可解析表格、人类可读可视化),辅助 EA 优化决策。
不针对任何具体 EA —— 所有分析逻辑数据驱动,换一份报告即自动重新计算与诊断。
## 功能概览
| 模块 | 作用 | 输出 |
|---|---|---|
| `mt5_report_parser.py` | 解析 MT5 中文 xlsx 报告(设置/结果/订单/成交),重建逐笔交易 | `MT5Report` 对象 |
| `run_analysis.py` | 主分析器:核心指标对比、What-If 假设、蒙特卡洛、方向/时段诊断、数据驱动建议 | `report.html` |
| `walk_forward.py` | Walk-Forward 滚动 IS→OOS 验证,算 WFE 与泛化相关性 | `walk_forward.html` |
| `param_scan.py` | 参数敏感度扫描:网格 .set 生成 → 响应面 → 高原检测 → **输出优化后 .set** | `param_scan.html` + `.set` |
| `mae_mfe.py` | MAE/MFE 分析:散点 + TP/SL 扫描热力图,附 MQL5 导出代码 | `mae_mfe.html` |
## 快速开始
### 环境要求
- Python 3.9+
- 依赖:`openpyxl``pandas``numpy`
```bash
pip install openpyxl pandas numpy
```
### 基本用法
把两份 MT5 回测报告(通常 IS / OSS 各一份)放进目录,命名为 `IS-*.xlsx``OSS-*.xlsx`
```bash
python run_analysis.py
# 或显式指定:
python run_analysis.py IS-ReportTester-12345.xlsx OSS-ReportTester-12345.xlsx
```
生成的 `output/report.html` 可直接浏览器打开。
### 各模块独立使用
```bash
# Walk-Forward 滚动验证
python walk_forward.py IS-ReportTester-12345.xlsx --is-days 60 --oos-days 30
# 参数敏感度扫描(三步)
python param_scan.py gen-set template.set grid.json out_setdir --ea MyEA
# (在 MT5 里跑 out_setdir/run_scan.bat,把报告放到 out_setdir/reports/
python param_scan.py analyze out_setdir/reports out_setdir/manifest.csv \
--x FastMA --y SlowMA --metric pf \
--template template.set --out-set output/optimized.set --strategy plateau
# MAE/MFE 分析
python mae_mfe.py --show-snippet > mae_mfe_snippet.mqh # 取 MQL5 代码
# (把代码粘进 EA,回测后得到 mae_mfe_*.csv
python mae_mfe.py mae_mfe_2026-06-28.csv
```
## 项目结构
```
.
├── mt5_report_parser.py # 通用 MT5 xlsx 解析器
├── run_analysis.py # 主分析器(含 What-If/蒙特卡洛/规则建议/Walk-Forward 集成)
├── walk_forward.py # Walk-Forward 滚动验证
├── param_scan.py # 参数敏感度扫描 + 优化 .set 输出
├── mae_mfe.py # MAE/MFE 分析 + MQL5 代码片段
├── output/ # 生成产物(HTML/.set,已入库便于在线预览)
│ ├── report.html
│ ├── walk_forward.html
│ ├── param_scan.html
│ └── mae_mfe.html
├── .gitignore
└── README.md
```
## 各模块详解
### 1. mt5_report_parser.py — 解析器
解析 MT5 Strategy Tester 导出的中文 xlsx 报告,结构化为:
- `meta`:元信息(EA、品种、期间、初始资金、杠杆、输入参数字典)
- `summary` / `summary_norm`:结果区汇总指标(数值化后的字典)
- `orders` / `deals`:订单与成交明细 DataFrame
- `trades`:由 in/out 成交对**重建的逐笔交易** DataFrame(含 open_time、close_time、direction、net_profit、duration_min 等)
```python
import mt5_report_parser as mp
rep = mp.parse_report("IS-ReportTester-12345.xlsx")
print(rep.meta["专家"], rep.summary["总净盈利"])
print(rep.trades[["open_time","direction","net_profit"]].head())
```
`compute_trade_metrics(trades)` 一次算出:PF、胜率、Sortino、Calmar、滚动PF、最大连败、按方向/小时/星期/持仓时间分桶等扩展指标。
### 2. run_analysis.py — 主分析器
读两份报告,输出 `report.html`,包含 10 个章节:
1. 核心指标对比(PF/胜率/回撤/夏普/Sortino/Calmar/滚动PF
2. 资金曲线(内联 SVG
3. 方向性诊断(多空各自的胜率/PF/盈亏比/期望)
4. **What-If 假设分析**9 个通用场景:sell-only/buy-only/信号反向/过滤最差时段/仓位减半/盈利单放大/亏损截断/启用BE)
5. 蒙特卡洛回撤模拟(1000 次打乱,检验顺序自相关)
6. 时段诊断(小时/星期热力表,标 IS/OSS 双负窗口)
7. 持仓时间分桶
8. **数据驱动的优化建议**(8 条规则按数据特征自动触发,每条附"触发条件→数据→动作")
9. Walk-Forward 滚动验证(内嵌)
10. 可选扩展分析指引
**关键设计**:建议规则不预设任何策略特定结论。例如"信号方向自检"规则仅当两份报告的"信号反向"场景净盈利均转正且 PF>1 时才触发,提示用户复核源码方向逻辑。
### 3. walk_forward.py — Walk-Forward 验证
将单份报告按时间切成多个滚动窗口(IS段 + OOS段),逐窗算指标:
- **WFE**Walk-Forward Efficiency= ΣOOS净盈利 / ΣIS净盈利
- **IS-OOS PF 相关性**:高=参数泛化好,低=过拟合风险
- OOS 盈利窗口占比
- 滚动 IS/OOS PF 曲线 SVG
可独立运行,也已集成进主报告第 9 节。
### 4. param_scan.py — 参数敏感度扫描
三个子命令:
- `gen-set`:读 `grid.json` + .set 模板,批量生成每个参数组合的 .set + manifest.csv + MT5 批处理脚本 + tester.ini 模板
- `analyze`:读 manifest + 一批报告,构建响应面
- `demo`:合成数据演示
**响应面分析**
- 2D 热力图(参数X × 参数Y → 指标,标出高原 P1/P2/P3
- 3D 等距投影曲面
- **高原检测**:4-连通区域 ≥4 格且高于 80 分位 → 稳健参数区
- **过拟合评分**:峰值孤立度 + 高原覆盖率 → 稳健性 0~1
- **直接输出优化后 .set**:支持两种策略
- `--strategy plateau`(默认,推荐):取最大高原中心,抗扰动
- `--strategy peak`:取单点峰值,激进易过拟合
`grid.json` 示例:
```json
{"FastMA": [5,10,15,20,25], "SlowMA": [20,30,40,50], "StopLoss": [50,100]}
```
### 5. mae_mfe.py — MAE/MFE 分析
MT5 标准 xlsx 不含逐笔 MAE/MFE。本模块提供:
- `--show-snippet`:输出 MQL5 代码,粘进 EA 的 OnDeinit,自动扫描每笔持仓期的 iHigh/iLow 算 MAE/MFE 导出 CSV
- 分析 CSVMAE/MFE vs 盈亏散点(绿赢红输 + SL/TP 候选阈值线)
- TP×SL 网格扫描热力图:扫描所有组合重算理论净盈利,标最优点
- 推荐:SL = 亏损笔 MAE 75 分位;TP = 盈利笔 MFE 中位
## 设计原则
1. **通用可复用**:脚本零硬编码,不依赖任何具体 EA 的参数名/阈值/结论
2. **数据驱动**:所有"建议"由当前数据的特征触发,非预写
3. **自包含输出**:HTML 全部用纯表格 + 内联 SVG,无外部图片/JS/CSS 依赖,AI 可直接解析数据
4. **输入仅依赖** mt5_report_parser 解析出的结构化数据
## 关于示例数据
出于交易策略数据敏感性,示例回测报告(`IS-*.xlsx` / `OSS-*.xlsx`)已通过 `.gitignore` 排除。`output/` 下的 HTML 为真实数据生成的示例产物,可供预览效果。
如需本地试跑,将自己的 MT5 报告按 `IS-*.xlsx` / `OSS-*.xlsx` 命名放入根目录即可。
## 依赖
| 包 | 用途 |
|---|---|
| `openpyxl` | 读取 xlsx |
| `pandas` | 数据处理 |
| `numpy` | 数值计算 |
无 matplotlib 等重型绘图库,所有可视化用纯 SVG。
## License
MIT
+441
View File
@@ -0,0 +1,441 @@
# -*- coding: utf-8 -*-
"""
MAE / MFE 分析
==============
MT5 标准回测 xlsx 不含逐笔 MAE/MFE(最大不利/有利偏移),需要由 EA 在
OnDeinit 里把每笔交易的 MAE/MFE 写到 CSV。本模块:
1. mql5_snippet() : 返回可粘贴进 EA 的 MQL5 代码,自动导出 mae_mfe.csv
2. analyze(df) : 由 CSV 计算最优 TP/SL 区间
3. build_html(df) : 散点图 + 推荐区间
CSV 列约定(首行表头):
ticket,direction,open_time,close_time,open_price,close_price,
mae_points,mfe_points,profit_money
direction: long / short
mae_points: 持仓期间最大不利偏移(点,正数表示逆向走了多少点)
mfe_points: 持仓期间最大有利偏移(点,正数)
profit_money: 该笔最终盈亏(金额)
分析原理:
- MFE vs 盈亏散点:能涨到 X 点的笔里,最终盈利占比?→ 若大量高 MFE 笔最终
亏损,说明 TP 太贪;把 TP 放到 MFE 高胜率区间可改善。
- MAE vs 盈亏散点:逆向超过 Y 点的笔几乎全亏 → SL 设在 Y 内可避免大损。
- 用扫描法找使"理论净盈利"最大的 TP/SL 组合(基于历史 MAE/MFE 重算)。
用法:
python mae_mfe.py <mae_mfe.csv>
python mae_mfe.py --show-snippet # 打印 MQL5 代码
python mae_mfe.py --demo # 合成数据演示
"""
from __future__ import annotations
import argparse
import html
import os
import sys
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
import pandas as pd
OUT_DIR = "output"
# =========================================================================== #
# 1. MQL5 代码片段
# =========================================================================== #
def mql5_snippet() -> str:
"""返回 MQL5 代码:在 OnDeinit 里把每笔历史仓位的 MAE/MFE 写入 CSV。"""
return r'''// ====== MAE/MFE 日志片段(粘贴进 EA ======
// 在文件顶部加:
#include <Trade\DealInfo.mqh>
#include <Trade\PositionInfo.mqh>
// 在 OnDeinit(const int reason) 末尾加:
void ExportMAEMFE()
{
string fname = "mae_mfe_" + TimeToString(TimeCurrent(), TIME_DATE) + ".csv";
int h = FileOpen(fname, FILE_WRITE | FILE_CSV | FILE_ANSI, ',');
if(h == INVALID_HANDLE) { Print("MAE/MFE: 文件打开失败"); return; }
FileWrite(h, "ticket","direction","open_time","close_time","open_price","close_price",
"mae_points","mfe_points","profit_money");
HistorySelect(0, TimeCurrent() + 86400);
int total = HistoryDealsTotal();
// 按订单(order)配对 in/out
for(int i = 0; i < total; i++)
{
ulong dealIn = HistoryDealGetTicket(i);
if(HistoryDealGetInteger(dealIn, DEAL_ENTRY) != DEAL_ENTRY_IN) continue;
long order = HistoryDealGetInteger(dealIn, DEAL_ORDER);
ulong dealOut = 0;
// 找同 order 的 out 成交
for(int j = i + 1; j < total; j++)
{
ulong d = HistoryDealGetTicket(j);
if(HistoryDealGetInteger(d, DEAL_ORDER) == order &&
HistoryDealGetInteger(d, DEAL_ENTRY) == DEAL_ENTRY_OUT)
{ dealOut = d; break; }
}
if(dealOut == 0) continue;
datetime tIn = (datetime)HistoryDealGetInteger(dealIn, DEAL_TIME);
datetime tOut = (datetime)HistoryDealGetInteger(dealOut, DEAL_TIME);
double pIn = HistoryDealGetDouble(dealIn, DEAL_PRICE);
double pOut = HistoryDealGetDouble(dealOut, DEAL_PRICE);
long type = HistoryDealGetInteger(dealIn, DEAL_TYPE); // DEAL_TYPE_BUY / SELL
double profit = HistoryDealGetDouble(dealOut, DEAL_PROFIT)
+ HistoryDealGetDouble(dealOut, DEAL_SWAP)
+ HistoryDealGetDouble(dealOut, DEAL_COMMISSION);
string dir = (type == DEAL_TYPE_BUY) ? "long" : "short";
// 扫描持仓期间最高/最低价算 MAE/MFE(点)
double mfe = 0, mae = 0;
double pt = SymbolInfoDouble(_Symbol, SYMBOL_POINT);
for(datetime t = tIn; t <= tOut; t += PeriodSeconds())
{
double hi = iHigh(_Symbol, PERIOD_M1, iBarShift(_Symbol, PERIOD_M1, t, false));
double lo = iLow (_Symbol, PERIOD_M1, iBarShift(_Symbol, PERIOD_M1, t, false));
if(hi <= 0 || lo <= 0) continue;
if(type == DEAL_TYPE_BUY)
{
double fav = (hi - pIn) / pt; // 有利
double adv = (pIn - lo) / pt; // 不利
if(fav > mfe) mfe = fav;
if(adv > mae) mae = adv;
}
else
{
double fav = (pIn - lo) / pt;
double adv = (hi - pIn) / pt;
if(fav > mfe) mfe = fav;
if(adv > mae) mae = adv;
}
}
FileWrite(h, order, dir, TimeToString(tIn), TimeToString(tOut),
DoubleToString(pIn, _Digits), DoubleToString(pOut, _Digits),
DoubleToString(mae, 1), DoubleToString(mfe, 1),
DoubleToString(profit, 2));
}
FileClose(h);
Print("MAE/MFE 已导出: ", fname);
}
// 在 OnDeinit 里调用: ExportMAEMFE();
// ====== 片段结束 ======
'''
# =========================================================================== #
# 2. 加载
# =========================================================================== #
def load_csv(path: str) -> pd.DataFrame:
df = pd.read_csv(path)
# 列名容错
rename = {
"mae_points": "mae", "mfe_points": "mfe",
"profit_money": "profit",
"open_price": "open", "close_price": "close",
}
df = df.rename(columns={k: v for k, v in rename.items() if k in df.columns})
for c in ("mae", "mfe", "profit"):
if c in df.columns:
df[c] = pd.to_numeric(df[c], errors="coerce")
return df
# =========================================================================== #
# 3. 分析:最优 TP/SL
# =========================================================================== #
def analyze(df: pd.DataFrame, tp_grid: Optional[List[float]] = None,
sl_grid: Optional[List[float]] = None) -> Dict[str, Any]:
"""
扫描 TP/SL 组合,找使理论净盈利最大的参数。
理论模型:对每笔,若 mfe >= TP 则按 TP 离场(盈利 = TP×点值);
若 mae >= SL 则按 SL 离场(亏损 = -SL×点值);
否则按原 profit。
这里用"点数口径"近似:盈利/亏损点数替代金额(点值统一假设)。
"""
if df.empty:
return {}
if tp_grid is None:
mfe_max = float(df["mfe"].max())
tp_grid = list(np.linspace(0, mfe_max, 30))
if sl_grid is None:
mae_max = float(df["mae"].max())
sl_grid = list(np.linspace(0, mae_max, 30))
mae = df["mae"].to_numpy()
mfe = df["mfe"].to_numpy()
n = len(df)
best = None
grid = []
for sl in sl_grid:
for tp in tp_grid:
# 重算每笔点数结果
# 优先 SL 触发(更保守:先假设逆向先到)
result_pts = np.empty(n)
for k in range(n):
if mae[k] >= sl and sl > 0:
result_pts[k] = -sl
elif mfe[k] >= tp and tp > 0:
result_pts[k] = tp
else:
# 都没触发,按原方向小盈小亏(用 profit 符号 × mfe 比例近似)
result_pts[k] = (mfe[k] - mae[k]) * 0.5
net = float(result_pts.sum())
wins = int((result_pts > 0).sum())
pf = float(result_pts[result_pts > 0].sum() / -result_pts[result_pts <= 0].sum()) if (result_pts <= 0).any() else np.inf
grid.append({"tp": float(tp), "sl": float(sl), "net": net,
"win_rate": wins / n * 100, "pf": pf})
if best is None or net > best["net"]:
best = {"tp": float(tp), "sl": float(sl), "net": net,
"win_rate": wins / n * 100, "pf": pf}
grid_df = pd.DataFrame(grid)
# MAE/MFE 分布统计
mae_threshold = float(df.loc[df["profit"] <= 0, "mae"].quantile(0.75)) if (df["profit"] <= 0).any() else 0
mfe_threshold = float(df.loc[df["profit"] > 0, "mfe"].quantile(0.5)) if (df["profit"] > 0).any() else 0
return {
"best": best,
"grid": grid_df,
"mae_q3_of_losers": mae_threshold, # 75% 亏损笔的 MAE 上限 → SL 候选
"mfe_median_of_winners": mfe_threshold, # 盈利笔 MFE 中位 → TP 候选
"n": n,
"profit_corr_mae": float(df["profit"].corr(df["mae"])),
"profit_corr_mfe": float(df["profit"].corr(df["mfe"])),
}
# =========================================================================== #
# 4. SVG 散点
# =========================================================================== #
def svg_scatter(x: np.ndarray, y: np.ndarray, profit: np.ndarray,
xlabel: str, ylabel: str, title: str,
threshold: Optional[float] = None, threshold_label: str = "",
w: int = 460, h: int = 320) -> str:
top, bottom, left, right = 30, 40, 50, 16
plot_w = w - left - right
plot_h = h - top - bottom
x_min, x_max = float(x.min()), float(x.max())
y_min, y_max = float(y.min()), float(y.max())
if x_max == x_min: x_max = x_min + 1
if y_max == y_min: y_max = y_min + 1
def sx(v): return left + (v - x_min) / (x_max - x_min) * plot_w
def sy(v): return top + plot_h - (v - y_min) / (y_max - y_min) * plot_h
pts = ""
for xi, yi, pi in zip(x, y, profit):
col = "#16a34a" if pi > 0 else "#dc2626"
pts += f'<circle cx="{sx(xi):.1f}" cy="{sy(yi):.1f}" r="2.2" fill="{col}" opacity="0.45"><title>{xlabel}={xi:.1f}, {ylabel}={yi:.2f}, profit={pi:.2f}</title></circle>'
thr = ""
if threshold is not None and x_min <= threshold <= x_max:
tx = sx(threshold)
thr = (f'<line x1="{tx:.1f}" y1="{top}" x2="{tx:.1f}" y2="{top+plot_h}" stroke="#1e3a8a" stroke-width="1.5" stroke-dasharray="4,3"/>'
f'<text x="{tx+4:.1f}" y="{top+12:.1f}" font-size="9" fill="#1e3a8a">{html.escape(threshold_label)}={threshold:.1f}</text>')
grid = ""
for frac in (0, 0.5, 1.0):
xv = x_min + frac * (x_max - x_min)
yv = y_min + frac * (y_max - y_min)
gx = left + frac * plot_w
gy = top + plot_h - frac * plot_h
grid += (f'<line x1="{gx:.1f}" y1="{top}" x2="{gx:.1f}" y2="{top+plot_h}" stroke="#f1f5f9" stroke-width="0.5"/>'
f'<text x="{gx:.1f}" y="{h-bottom+14:.1f}" font-size="9" fill="#6b7280" text-anchor="middle">{xv:.0f}</text>'
f'<line x1="{left}" y1="{gy:.1f}" x2="{w-right}" y2="{gy:.1f}" stroke="#f1f5f9" stroke-width="0.5"/>'
f'<text x="{left-6:.1f}" y="{gy+3:.1f}" font-size="9" fill="#6b7280" text-anchor="end">{yv:.0f}</text>')
return (f'<svg viewBox="0 0 {w} {h}" class="chart">'
f'<text x="{w//2}" y="16" font-size="11" fill="#374151" text-anchor="middle">{html.escape(title)}</text>'
f'{grid}{pts}{thr}'
f'<text x="{w//2}" y="{h-4}" font-size="9" fill="#6b7280" text-anchor="middle">{html.escape(xlabel)} →</text>'
f'<text x="14" y="{h//2}" font-size="9" fill="#6b7280" text-anchor="middle" transform="rotate(-90 14 {h//2})">{html.escape(ylabel)} →</text>'
f'</svg>')
def svg_tp_sl_heatmap(grid_df: pd.DataFrame, w: int = 460, h: int = 360) -> str:
"""TP×SL 净盈利热力图。"""
p = grid_df.pivot_table(index="sl", columns="tp", values="net", aggfunc="first")
p = p.sort_index().sort_index(axis=1)
Z = p.to_numpy()
xs = list(p.columns)
ys = list(p.index)
top, bottom, left, right = 30, 40, 60, 16
plot_w = w - left - right
plot_h = h - top - bottom
ny, nx = Z.shape
cw = plot_w / nx
ch = plot_h / ny
valid = Z[~np.isnan(Z)]
vmin, vmax = float(valid.min()), float(valid.max())
if vmax == vmin: vmax = vmin + 1
def col(v):
if np.isnan(v): return "#f3f4f6"
r = (v - vmin) / (vmax - vmin)
if r < 0.5:
t = r * 2
return f"rgb({int(220-60*t)},{int(60+160*t)},{int(60+40*t)})"
t = (r - 0.5) * 2
return f"rgb({int(160-100*t)},{int(220-40*t)},{int(100-40*t)})"
cells = ""
for j in range(ny):
for i in range(nx):
v = Z[j, i]
x = left + i * cw
y = top + (ny - 1 - j) * ch
cells += f'<rect x="{x:.1f}" y="{y:.1f}" width="{cw:.1f}" height="{ch:.1f}" fill="{col(v)}" stroke="#fff" stroke-width="0.4"/>'
# 最优点
bi = np.unravel_index(np.nanargmax(Z), Z.shape)
bx = left + bi[1] * cw + cw / 2
by = top + (ny - 1 - bi[0]) * ch + ch / 2
marker = f'<circle cx="{bx:.1f}" cy="{by:.1f}" r="6" fill="none" stroke="#1e3a8a" stroke-width="2"/>'
xlabs = "".join(f'<text x="{left+(i+0.5)*cw:.1f}" y="{h-bottom+14:.1f}" font-size="8" fill="#374151" text-anchor="middle">{xs[i]:.0f}</text>' for i in range(0, nx, 2))
ylabs = "".join(f'<text x="{left-6:.1f}" y="{top+(ny-0.5-j)*ch+3:.1f}" font-size="8" fill="#374151" text-anchor="end">{ys[j]:.0f}</text>' for j in range(0, ny, 2))
return (f'<svg viewBox="0 0 {w} {h}" class="chart">'
f'<text x="{w//2}" y="16" font-size="11" fill="#374151" text-anchor="middle">理论净盈利 (TP × SL 扫描) — 圈=最优</text>'
f'{cells}{marker}{xlabs}{ylabs}'
f'<text x="{w//2}" y="{h-4}" font-size="9" fill="#6b7280" text-anchor="middle">TP(点) →</text>'
f'<text x="14" y="{h//2}" font-size="9" fill="#6b7280" text-anchor="middle" transform="rotate(-90 14 {h//2})">SL(点) →</text>'
f'</svg>')
# =========================================================================== #
# 5. HTML
# =========================================================================== #
def build_html(df: pd.DataFrame, res: Dict[str, Any]) -> str:
mae_svg = svg_scatter(
df["mae"].to_numpy(), df["profit"].to_numpy(), df["profit"].to_numpy(),
"MAE(点)", "盈利($)", "MAE vs 盈亏(绿=赢/红=输)",
threshold=res["mae_q3_of_losers"], threshold_label="SL候选",
)
mfe_svg = svg_scatter(
df["mfe"].to_numpy(), df["profit"].to_numpy(), df["profit"].to_numpy(),
"MFE(点)", "盈利($)", "MFE vs 盈亏(绿=赢/红=输)",
threshold=res["mfe_median_of_winners"], threshold_label="TP候选",
)
tpsl_svg = svg_tp_sl_heatmap(res["grid"])
b = res["best"]
cards = (
"<div class='cards'>"
f"<div class='card'><div class='k'>样本笔数</div><div class='v'>{res['n']}</div></div>"
f"<div class='card'><div class='k'>最优 TP(点)</div><div class='v'>{b['tp']:.0f}</div></div>"
f"<div class='card'><div class='k'>最优 SL(点)</div><div class='v'>{b['sl']:.0f}</div></div>"
f"<div class='card'><div class='k'>理论净盈利(点)</div><div class='v pos'>{b['net']:.0f}</div></div>"
f"<div class='card'><div class='k'>理论胜率</div><div class='v'>{b['win_rate']:.1f}%</div></div>"
f"<div class='card'><div class='k'>理论 PF</div><div class='v'>{b['pf']:.2f}</div></div>"
"</div>"
)
interp = ""
if res["profit_corr_mae"] < -0.3:
interp += f"<p class='muted'>盈亏与 MAE 负相关 ({res['profit_corr_mae']:.2f}):逆向偏移越大越易亏,设 SL 合理。SL 候选 = 亏损笔 MAE 75 分位 ≈ <b>{res['mae_q3_of_losers']:.0f} 点</b>。</p>"
if res["profit_corr_mfe"] > 0.3:
interp += f"<p class='muted'>盈亏与 MFE 正相关 ({res['profit_corr_mfe']:.2f}):能跑到 X 点的笔更易盈利,TP 候选 = 盈利笔 MFE 中位 ≈ <b>{res['mfe_median_of_winners']:.0f} 点</b>。</p>"
if not interp:
interp = "<p class='muted'>MAE/MFE 与盈亏相关性弱,TP/SL 优化空间有限,建议关注入场逻辑。</p>"
interp += (f"<div class='note'><b>理论最优 TP/SL</b>TP={b['tp']:.0f}点 / SL={b['sl']:.0f}点,"
f"在该组合下重算净盈利 {b['net']:.0f}点、胜率 {b['win_rate']:.1f}%、PF {b['pf']:.2f}"
f"此为基于历史 MAE/MFE 的理论值,需回测验证。</div>")
return f"""<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
<title>MAE/MFE 分析</title>
<style>
body {{ font-family:-apple-system,"Microsoft YaHei",sans-serif; background:#f8fafc; color:#111827; margin:0; padding:20px;}}
.wrap {{ max-width:1100px; margin:0 auto;}}
h1,h2 {{ color:#1e3a8a;}} h1 {{ border-bottom:3px solid #1e3a8a; padding-bottom:8px;}}
.muted {{ color:#6b7280; font-size:12px;}}
.cards {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:12px; margin:16px 0;}}
.card {{ background:#fff; padding:12px; border-radius:8px; border:1px solid #e2e8f0;}}
.card .k {{ color:#6b7280; font-size:11px;}} .card .v {{ font-size:18px; font-weight:600; margin-top:4px;}}
.card .v.pos {{ color:#16a34a;}}
.chart {{ width:100%; height:auto; background:#fff; border:1px solid #e2e8f0; border-radius:6px;}}
.grid2 {{ display:grid; grid-template-columns:1fr 1fr; gap:12px;}}
.note {{ background:#fef3c7; border-left:4px solid #f59e0b; padding:10px 14px; margin:12px 0; border-radius:4px;}}
pre {{ background:#1e293b; color:#e2e8f0; padding:14px; border-radius:6px; overflow:auto; font-size:11px;}}
footer {{ color:#6b7280; font-size:11px; margin-top:32px; text-align:center;}}
</style></head><body><div class="wrap">
<h1>MAE / MFE 分析报告</h1>
<p class='muted'>样本: {res['n']} 笔 · 盈亏-MAE 相关 {res['profit_corr_mae']:.2f} · 盈亏-MFE 相关 {res['profit_corr_mfe']:.2f}</p>
{cards}
{interp}
<h2>1. MAE / MFE 散点</h2>
<div class="grid2"><div>{mae_svg}</div><div>{mfe_svg}</div></div>
<p class='muted'>蓝虚线 = 推荐的 SL/TP 候选阈值。MAE 图:右侧红点=逆向走很远还亏=该早止损;MFE 图:右侧绿点=涨得高最终赚=TP 可放到此处。</p>
<h2>2. TP × SL 扫描热力图</h2>
{tpsl_svg}
<p class='muted'>圈=理论最优组合。色越绿=净盈利越高。注意"宽绿区""单点深绿"更稳健。</p>
<h2>3. 如何获取 MAE/MFE 数据</h2>
<p class='muted'>MT5 标准 xlsx 报告不含逐笔 MAE/MFE。需在 EA 的 OnDeinit 里加如下代码导出 CSV,再喂给本工具:</p>
<pre>{html.escape(mql5_snippet())}</pre>
<footer>由 mae_mfe.py 生成 · {pd.Timestamp.now().strftime('%Y-%m-%d %H:%M')}</footer>
</div></body></html>"""
# =========================================================================== #
# 6. demo
# =========================================================================== #
def _demo_df() -> pd.DataFrame:
rng = np.random.default_rng(3)
n = 400
direction = rng.choice(["long", "short"], n)
mae = np.abs(rng.normal(20, 15, n))
mfe = np.abs(rng.normal(40, 20, n))
# 盈亏与 mfe 正相关、与 mae 负相关
base = (mfe - mae) * 0.3 + rng.normal(0, 5, n)
profit = np.where(base > 0, mfe * 0.2, -mae * 0.25) + rng.normal(0, 2, n)
return pd.DataFrame({
"ticket": np.arange(n),
"direction": direction,
"mae": np.round(mae, 1),
"mfe": np.round(mfe, 1),
"profit": np.round(profit, 2),
})
# =========================================================================== #
# CLI
# =========================================================================== #
def main(argv: List[str]) -> int:
ap = argparse.ArgumentParser(description="MAE/MFE 分析")
ap.add_argument("csv", nargs="?", help="mae_mfe.csv 路径")
ap.add_argument("--show-snippet", action="store_true", help="打印 MQL5 代码片段")
ap.add_argument("--demo", action="store_true")
args = ap.parse_args(argv)
os.makedirs(OUT_DIR, exist_ok=True)
if args.show_snippet:
print(mql5_snippet())
return 0
if args.demo:
df = _demo_df()
else:
if not args.csv:
ap.error("需要 csv 路径,或 --demo,或 --show-snippet")
df = load_csv(args.csv)
res = analyze(df)
html_doc = build_html(df, res)
out = os.path.join(OUT_DIR, "mae_mfe.html")
with open(out, "w", encoding="utf-8") as f:
f.write(html_doc)
print(f"HTML 报告: {out}")
print(f"最优 TP={res['best']['tp']:.0f}点 SL={res['best']['sl']:.0f}"
f"理论净盈利={res['best']['net']:.0f}点 PF={res['best']['pf']:.2f}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+414
View File
@@ -0,0 +1,414 @@
# -*- coding: utf-8 -*-
"""
MT5 策略测试报告解析器(中文版 xlsx)
=====================================
读取 MT5 Strategy Tester 导出的 .xlsx 报告,结构化为:
- meta : 元信息(EA、品种、期间、经纪商、初始资金、杠杆、输入参数)
- summary : “结果”区的汇总指标字典
- orders : 订单明细 DataFrame
- deals : 成交明细 DataFrame
- trades : 由 in/out 成交对重建的“逐笔交易”DataFrame
设计为可复用:后续新的回测报告可直接喂给本解析器。
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
import openpyxl
import pandas as pd
# 报告中区段标题(A 列出现的精确字符串)
SEC_SETTINGS = "设置"
SEC_RESULTS = "结果"
SEC_ORDERS = "订单"
SEC_DEALS = "成交"
@dataclass
class MT5Report:
meta: Dict[str, Any] = field(default_factory=dict)
summary: Dict[str, Any] = field(default_factory=dict)
orders: Optional[pd.DataFrame] = None
deals: Optional[pd.DataFrame] = None
trades: Optional[pd.DataFrame] = None
source_file: str = ""
# --------------------------------------------------------------------------- #
# 工具函数
# --------------------------------------------------------------------------- #
def _find_section_rows(ws) -> Dict[str, int]:
"""扫描 A 列,定位各段标题所在行。"""
rows: Dict[str, int] = {}
for r in range(1, ws.max_row + 1):
v = ws.cell(r, 1).value
if v in (SEC_SETTINGS, SEC_RESULTS, SEC_ORDERS, SEC_DEALS):
rows[v] = r
return rows
def _kv_in_row(ws, r: int, max_col: int = 14) -> Dict[str, Any]:
"""
按行解析 “标签: 值” 对。
MT5 报告里标签以全角冒号 ':' 结尾,值位于其右侧下一个非空单元格。
一行可能含多组 标签/值。
"""
out: Dict[str, Any] = {}
cells = [ws.cell(r, c).value for c in range(1, max_col + 1)]
i = 0
while i < len(cells):
v = cells[i]
if isinstance(v, str) and v.endswith(":"):
label = v[:-1].strip()
# 向右找第一个非空值
j = i + 1
while j < len(cells) and cells[j] is None:
j += 1
if j < len(cells):
out[label] = cells[j]
i = j + 1
continue
i += 1
return out
def _parse_table(
ws, header_row: int, end_row: int, max_col: int = 14
) -> pd.DataFrame:
"""从 header_row 读取列名,读取其后到 end_row 的数据,返回 DataFrame。"""
headers = [ws.cell(header_row, c).value for c in range(1, max_col + 1)]
# 去掉末尾连续的 None 列
while headers and headers[-1] is None:
headers.pop()
ncol = len(headers)
records: List[List[Any]] = []
for r in range(header_row + 1, end_row + 1):
row = [ws.cell(r, c).value for c in range(1, ncol + 1)]
# 跳过整行空
if all(v is None for v in row):
continue
records.append(row)
df = pd.DataFrame(records, columns=headers)
return df
def _to_float(x: Any) -> Optional[float]:
"""从形如 '971.54 (96.32%)''0.69415' 的字符串中提取首个数值。"""
if x is None:
return None
if isinstance(x, (int, float)):
return float(x)
if isinstance(x, str):
m = re.search(r"-?\d+\.?\d*", x)
return float(m.group()) if m else None
return None
def _pct_to_float(x: Any) -> Optional[float]:
"""'96.32%' 提取 96.32。"""
if x is None:
return None
if isinstance(x, (int, float)):
return float(x)
m = re.search(r"(-?\d+\.?\d*)\s*%", str(x))
return float(m.group(1)) if m else None
# --------------------------------------------------------------------------- #
# 主入口
# --------------------------------------------------------------------------- #
def parse_report(path: str) -> MT5Report:
"""解析一份 MT5 xlsx 报告。"""
wb = openpyxl.load_workbook(path, data_only=True)
ws = wb[wb.sheetnames[0]]
rep = MT5Report(source_file=path)
sec = _find_section_rows(ws)
r_settings = sec.get(SEC_SETTINGS, 1)
r_results = sec.get(SEC_RESULTS, r_settings)
r_orders = sec.get(SEC_ORDERS, r_results)
r_deals = sec.get(SEC_DEALS, r_orders)
# ---- 元信息 / 设置 ----
# 整个设置块(设置标题行之后到结果段之前)逐行解析 “标签: 值” 对
meta: Dict[str, Any] = {}
for r in range(r_settings + 1, r_results):
kv = _kv_in_row(ws, r)
for k, v in kv.items():
if k == "输入": # “输入:” 是子段标题,其值为分节字符串,跳过
continue
meta[k] = v
# 输入参数:在 “输入:” 段落里,列 D 形如 Key=Value
inputs: Dict[str, str] = {}
for r in range(r_settings + 1, r_results):
for c in (4,): # 经验上输入参数在 D 列
v = ws.cell(r, c).value
if isinstance(v, str) and "=" in v and not v.endswith(":"):
# 跳过分节标题(如 "=== PAINEL ===="
if v.strip().startswith("==="):
continue
k, _, val = v.partition("=")
inputs[k.strip()] = val.strip()
meta["inputs"] = inputs
# ---- 结果汇总 ----
summary: Dict[str, Any] = {}
for r in range(r_results, r_orders):
summary.update(_kv_in_row(ws, r))
# ---- 订单表 ----
rep.orders = _parse_table(ws, header_row=r_orders + 1, end_row=r_deals - 1)
# ---- 成交表 ----
rep.deals = _parse_table(ws, header_row=r_deals + 1, end_row=ws.max_row)
rep.meta = meta
rep.summary = summary
rep.trades = _reconstruct_trades(rep.deals)
_normalize_summary_numbers(rep)
return rep
# --------------------------------------------------------------------------- #
# 逐笔交易重建
# --------------------------------------------------------------------------- #
DEALS_COLS = {
"time": "时间",
"deal_id": "成交",
"symbol": "交易品种",
"type": "类型", # buy / sell / balance
"entry": "趋势", # in / out / None
"volume": "交易量",
"price": "价位",
"order": "订单",
"commission": "手续费",
"swap": "库存费",
"profit": "盈利",
"balance": "结余",
"comment": "注释",
}
def _reconstruct_trades(deals: pd.DataFrame) -> pd.DataFrame:
"""
由成交明细重建逐笔交易。
规则:每笔 'in' 成交后紧跟其 'out' 平仓成交,两两配对。
返回字段:open_time, close_time, direction, volume, open_price,
close_price, profit, swap, commission, net_profit, duration_min
"""
if deals is None or deals.empty:
return pd.DataFrame()
d = deals.copy()
# 统一列名
d = d.rename(columns={v: k for k, v in DEALS_COLS.items() if v in d.columns})
# 类型转换
d["time"] = pd.to_datetime(d["time"], errors="coerce")
for col in ("price", "commission", "swap", "profit", "balance"):
if col in d.columns:
d[col] = pd.to_numeric(d[col], errors="coerce")
# 成交量形如 '0.01 / 0.01',取前半
if "volume" in d.columns:
d["volume"] = d["volume"].astype(str).str.split("/").str[0].str.strip()
d["volume"] = pd.to_numeric(d["volume"], errors="coerce")
# 仅保留 in/out 行(balance 行无 entry
d = d[d["entry"].isin(["in", "out"])].reset_index(drop=True)
trades: List[Dict[str, Any]] = []
i = 0
while i < len(d) - 1:
in_row = d.iloc[i]
out_row = d.iloc[i + 1]
if in_row["entry"] != "in" or out_row["entry"] != "out":
i += 1
continue
direction = "short" if in_row["type"] == "sell" else "long"
profit = float(out_row.get("profit", 0.0) or 0.0)
swap = float(out_row.get("swap", 0.0) or 0.0)
commission = float(out_row.get("commission", 0.0) or 0.0)
dur = (out_row["time"] - in_row["time"]).total_seconds() / 60.0
trades.append(
{
"open_time": in_row["time"],
"close_time": out_row["time"],
"direction": direction,
"volume": in_row.get("volume"),
"open_price": in_row.get("price"),
"close_price": out_row.get("price"),
"profit": profit,
"swap": swap,
"commission": commission,
"net_profit": profit + swap + commission,
"duration_min": dur,
}
)
i += 2
return pd.DataFrame(trades)
# --------------------------------------------------------------------------- #
# 汇总指标数值化(便于程序对比)
# --------------------------------------------------------------------------- #
def _normalize_summary_numbers(rep: MT5Report) -> None:
s = rep.summary
norm: Dict[str, Optional[float]] = {}
# 直接数值字段
direct = {
"总净盈利": "total_net_profit",
"毛利": "gross_profit",
"毛损": "gross_loss",
"盈利因子": "profit_factor",
"预期收益": "expected_payoff",
"夏普比率": "sharpe",
"采收率": "recovery",
"LR 相关性": "lr_correlation",
"LR 标准误差": "lr_std_error",
"总成交": "total_deals",
"交易总计": "total_trades",
}
for cn, en in direct.items():
norm[en] = _to_float(s.get(cn))
# 回撤类形如 "971.54 (96.32%)" —— 同时拆金额与百分比
dd_pairs = {
"最大结余亏损": "max_balance_dd",
"最大净值亏损": "max_equity_dd",
"相对结余亏损": "rel_balance_dd",
"相对净值亏损": "rel_equity_dd",
}
for cn, en in dd_pairs.items():
val = _to_float(s.get(cn)) # 取金额
# 单独找百分比
pct = _pct_to_float(s.get(cn))
norm[en] = val
norm[en + "_pct"] = pct
# 胜率类形如 "732 (38.28%)" —— 取百分比
wr_pairs = {
"盈利交易 (% 全部)": "win_rate_pct",
"亏损交易 (% 全部)": "loss_rate_pct",
"卖出交易 (赢得 %)": "sell_win_rate_pct",
"买入交易 (赢得 %)": "buy_win_rate_pct",
}
for cn, en in wr_pairs.items():
norm[en] = _pct_to_float(s.get(cn))
# 平均盈亏
norm["avg_win"] = _to_float(s.get("平均 获利交易"))
norm["avg_loss"] = _to_float(s.get("平均 亏损交易"))
norm["max_win"] = _to_float(s.get("最大 获利交易"))
norm["max_loss"] = _to_float(s.get("最大 亏损交易"))
rep.summary_norm = norm # type: ignore[attr-defined]
# --------------------------------------------------------------------------- #
# 便捷:从 trades 直接计算扩展指标
# --------------------------------------------------------------------------- #
def compute_trade_metrics(trades: pd.DataFrame) -> Dict[str, Any]:
"""由逐笔交易 DataFrame 计算扩展指标。"""
if trades is None or trades.empty:
return {}
t = trades
n = len(t)
net = t["net_profit"].astype(float)
wins = net[net > 0]
losses = net[net <= 0]
gross_profit = wins.sum()
gross_loss = -losses.sum()
pf = gross_profit / gross_loss if gross_loss > 0 else np.inf
# 连续盈亏
streak_win = streak_loss = cur_w = cur_l = 0
max_streak_win = max_streak_loss = 0
for v in net:
if v > 0:
cur_w += 1
cur_l = 0
max_streak_win = max(max_streak_win, cur_w)
else:
cur_l += 1
cur_w = 0
max_streak_loss = max(max_streak_loss, cur_l)
# 资金曲线与回撤
equity = net.cumsum()
running_max = equity.cummax()
dd = equity - running_max
max_dd = dd.min()
# 按方向
by_dir = {}
for direction, g in t.groupby("direction"):
gn = g["net_profit"].astype(float)
w = gn[gn > 0]
l = gn[gn <= 0]
gp = w.sum()
gl = -l.sum()
by_dir[direction] = {
"n": len(g),
"win_rate": len(w) / len(g) * 100 if len(g) else 0,
"net_profit": gn.sum(),
"profit_factor": gp / gl if gl > 0 else np.inf,
"avg_win": w.mean() if len(w) else 0,
"avg_loss": l.mean() if len(l) else 0,
"expectancy": gn.mean(),
}
# 按小时 / 星期 / 月份
t2 = t.copy()
t2["hour"] = t2["open_time"].dt.hour
t2["weekday"] = t2["open_time"].dt.dayofweek # 0=Mon
t2["month"] = t2["open_time"].dt.to_period("M").astype(str)
by_hour = t2.groupby("hour")["net_profit"].agg(["count", "sum", "mean"]).to_dict("index")
by_weekday = t2.groupby("weekday")["net_profit"].agg(["count", "sum", "mean"]).to_dict("index")
by_month = t2.groupby("month")["net_profit"].agg(["count", "sum", "mean"]).to_dict("index")
return {
"n_trades": n,
"net_profit": float(net.sum()),
"win_rate": float(len(wins) / n * 100),
"profit_factor": float(pf),
"avg_win": float(wins.mean()) if len(wins) else 0.0,
"avg_loss": float(losses.mean()) if len(losses) else 0.0,
"expectancy": float(net.mean()),
"max_streak_win": int(max_streak_win),
"max_streak_loss": int(max_streak_loss),
"max_dd": float(max_dd),
"avg_duration_min": float(t["duration_min"].mean()),
"median_duration_min": float(t["duration_min"].median()),
"by_direction": by_dir,
"by_hour": by_hour,
"by_weekday": by_weekday,
"by_month": by_month,
"_equity": equity,
"_dd": dd,
}
if __name__ == "__main__":
import sys
for f in sys.argv[1:]:
rep = parse_report(f)
print(f"== {f} ==")
print("EA:", rep.meta.get("专家"))
print("symbol:", rep.meta.get("交易品种"))
print("period:", rep.meta.get("期间"))
print("inputs:", rep.meta.get("inputs"))
print("trades rows:", 0 if rep.trades is None else len(rep.trades))
print("deals rows:", 0 if rep.deals is None else len(rep.deals))
print("summary_norm sample:", rep.summary_norm) # type: ignore[attr-defined]
print()
+109
View File
File diff suppressed because one or more lines are too long
+10
View File
@@ -0,0 +1,10 @@
; === 由 param_scan.py 优化生成 ===
; 来源: 单点峰值(PF=2.281,来源 —)
; 生成时间: 2026-06-28 02:22
; 应用参数: {'FastMA': '25.0', 'SlowMA': '60.0'}
; inputs
FastMA=25.0||1||1||100||N
SlowMA=60.0||1||1||500||N
StopLoss=100||1||1||10000||N
TakeProfit=200||1||1||10000||N
+10
View File
@@ -0,0 +1,10 @@
; === 由 param_scan.py 优化生成 ===
; 来源: 最大高原中心(面积 5,PF 均值 1.399)
; 生成时间: 2026-06-28 02:21
; 应用参数: {'FastMA': '10', 'SlowMA': '40'}
; inputs
FastMA=10||1||1||100||N
SlowMA=40||1||1||500||N
StopLoss=100||1||1||10000||N
TakeProfit=200||1||1||10000||N
File diff suppressed because one or more lines are too long
+175
View File
File diff suppressed because one or more lines are too long
+23
View File
@@ -0,0 +1,23 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
<title>Walk-Forward 报告 — demo</title>
<style>
body { font-family: -apple-system, "Microsoft YaHei", sans-serif; background:#f8fafc; color:#111827; margin:0; padding:20px;}
.wrap { max-width:1100px; margin:0 auto;}
h1 { color:#1e3a8a; border-bottom:3px solid #1e3a8a; padding-bottom:8px;}
table.data { border-collapse:collapse; width:100%; background:#fff; margin:8px 0 16px; font-size:12px;}
table.data th, table.data td { border:1px solid #e2e8f0; padding:5px 7px; text-align:left;}
table.data th { background:#f1f5f9;}
td.pos, tr.pos td { color:#16a34a;} td.neg, tr.neg td { color:#dc2626;}
.muted { color:#6b7280; font-size:12px;}
.cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(200px,1fr)); gap:12px; margin:16px 0;}
.card { background:#fff; padding:14px; border-radius:8px; border:1px solid #e2e8f0;}
.card .k { color:#6b7280; font-size:12px;} .card .v { font-size:20px; font-weight:600; margin-top:4px;}
.card .v.pos { color:#16a34a;} .card .v.neg { color:#dc2626;}
.chart { width:100%; height:auto; background:#fff; border:1px solid #e2e8f0; border-radius:6px;}
.note { background:#fef3c7; border-left:4px solid #f59e0b; padding:10px 14px; margin:12px 0; border-radius:4px;}
.warn { background:#fee2e2; border-left:4px solid #dc2626; padding:10px 14px; margin:12px 0; border-radius:4px;}
</style></head><body><div class="wrap">
<h1>Walk-Forward 滚动验证报告</h1>
<p class='muted'>来源: demo</p>
<div class='cards'><div class='card'><div class='k'>窗口数</div><div class='v'>3</div></div><div class='card'><div class='k'>WFE (ΣOOS净/ΣIS净)</div><div class='v pos'>+0.51</div></div><div class='card'><div class='k'>OOS 盈利窗口占比</div><div class='v'>0%</div></div><div class='card'><div class='k'>IS-OOS PF 相关性</div><div class='v'>-0.28</div></div></div><svg viewBox="0 0 760 260" class="chart"><line x1="46" y1="224.0" x2="744" y2="224.0" stroke="#e5e7eb" stroke-width="0.5"/><text x="40" y="227.0" font-size="9" fill="#6b7280" text-anchor="end">0.00</text><line x1="46" y1="174.0" x2="744" y2="174.0" stroke="#e5e7eb" stroke-width="0.5"/><text x="40" y="177.0" font-size="9" fill="#6b7280" text-anchor="end">0.25</text><line x1="46" y1="124.0" x2="744" y2="124.0" stroke="#e5e7eb" stroke-width="0.5"/><text x="40" y="127.0" font-size="9" fill="#6b7280" text-anchor="end">0.50</text><line x1="46" y1="74.0" x2="744" y2="74.0" stroke="#e5e7eb" stroke-width="0.5"/><text x="40" y="77.0" font-size="9" fill="#6b7280" text-anchor="end">0.75</text><line x1="46" y1="24.0" x2="744" y2="24.0" stroke="#e5e7eb" stroke-width="0.5"/><text x="40" y="27.0" font-size="9" fill="#6b7280" text-anchor="end">1.00</text><line x1="46" y1="24.0" x2="744" y2="24.0" stroke="#9ca3af" stroke-dasharray="3,3" stroke-width="0.8"/><polyline points="46.0,79.4 395.0,64.9 744.0,63.7" fill="none" stroke="#2563eb" stroke-width="1.6"><title></title></polyline><polyline points="46.0,66.6 395.0,60.7 744.0,79.2" fill="none" stroke="#dc2626" stroke-width="1.6"><title></title></polyline><text x="46.0" y="238.0" font-size="9" fill="#374151" text-anchor="middle">#0</text><text x="395.0" y="238.0" font-size="9" fill="#374151" text-anchor="middle">#1</text><text x="744.0" y="238.0" font-size="9" fill="#374151" text-anchor="middle">#2</text><text x="380" y="16" font-size="11" fill="#374151" text-anchor="middle">滚动窗口 IS vs OOS 盈利因子 (PF=1 为盈亏平衡线)</text><text x="744" y="32" font-size="10" fill="#2563eb" text-anchor="end">IS PF</text><text x="744" y="46" font-size="10" fill="#dc2626" text-anchor="end">OOS PF</text><text x="380" y="256" font-size="9" fill="#6b7280" text-anchor="middle">窗口序号 →</text></svg><table class='data'><thead><tr><th>窗口</th><th>IS区间</th><th>OOS区间</th><th>IS笔</th><th>IS净</th><th>IS PF</th><th>IS胜率</th><th>OOS笔</th><th>OOS净</th><th>OOS PF</th><th>OOS胜率</th><th>OOS回撤</th></tr></thead><tbody><tr><td>#0</td><td>2024-01-01~2024-03-01</td><td>2024-03-01~2024-03-31</td><td>480</td><td>-171.12</td><td>0.72</td><td>43.3%</td><td>240</td><td class='neg'>-63.73</td><td>0.79</td><td>45.0%</td><td>-103.50</td></tr><tr><td>#1</td><td>2024-01-31~2024-03-31</td><td>2024-03-31~2024-04-30</td><td>480</td><td>-122.89</td><td>0.80</td><td>45.0%</td><td>240</td><td class='neg'>-55.43</td><td>0.82</td><td>45.0%</td><td>-99.96</td></tr><tr><td>#2</td><td>2024-03-01~2024-04-30</td><td>2024-04-30~2024-05-30</td><td>480</td><td>-119.16</td><td>0.80</td><td>45.0%</td><td>240</td><td class='neg'>-91.91</td><td>0.72</td><td>41.7%</td><td>-120.94</td></tr></tbody></table><p class='muted'>IS 与 OOS 的 PF 相关性低 → IS 表现难以预测 OOS,参数不稳健,警惕过拟合单段行情。</p><div class='note'>WFE &gt; 0.5:OOS 能保留 IS 的一半以上盈利,泛化较好。</div>
</div></body></html>
+766
View File
@@ -0,0 +1,766 @@
# -*- coding: utf-8 -*-
"""
参数敏感度扫描
==============
对 EA 关键输入参数做网格回测,分析响应面,找稳健参数高原
(plateau,即大面积参数都能盈利的区域,而非单点过拟合峰值)。
工作流:
1. gen-set : 根据 grid 定义 + .set 模板,批量生成每个参数组合的 .set 文件,
并写 manifest.csv(记录文件名↔参数值),生成 MT5 批处理脚本。
2. (用户在 MT5 里跑批处理,得到一批 ReportTester-*.xlsx)
3. analyze : 读取 manifest + 所有报告,构建响应面:
- 2D 热力图(参数X × 参数Y → 指标值,最适合找高原)
- 3D 等距投影曲面
- 高原检测:识别"指标高于阈值且邻域也高"的连通区域
- 与单点峰值对比,标记过拟合风险
用法:
python param_scan.py gen-set template.set grid.json out_setdir \\
--ea "MyEA" --symbol XAUUSD --period M1 --from 2024-01-01 --to 2024-06-01
python param_scan.py analyze reports_dir manifest.csv --x FastMA --y SlowMA --metric PF
python param_scan.py demo # 用合成数据演示响应面与高原检测
grid.json 示例:
{"FastMA": [5,10,15,20,25], "SlowMA": [20,30,40,50], "StopLoss": [50,100]}
"""
from __future__ import annotations
import argparse
import html
import json
import os
import sys
from itertools import product
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
import pandas as pd
import mt5_report_parser as mp
OUT_DIR = "output"
# =========================================================================== #
# 1. 网格 .set 文件生成
# =========================================================================== #
def _apply_params_to_template(template: str, params: Dict[str, str]) -> str:
"""
MT5 .set 文件每行形如: varname=value||0||0||0||0||0||...
本函数把指定 varname 的 value 替换为新值,保留行尾的 || 字段。
若模板中不存在该变量,追加一行。
"""
lines = template.splitlines()
out: List[str] = []
replaced = set()
for line in lines:
if "=" in line and not line.strip().startswith(";"):
key, _, rest = line.partition("=")
k = key.strip()
if k in params:
# rest 形如 value||... 只替换第一个 || 之前的部分
if "||" in rest:
_, _, tail = rest.partition("||")
out.append(f"{key}={params[k]}||{tail}")
else:
out.append(f"{key}={params[k]}")
replaced.add(k)
continue
out.append(line)
for k, v in params.items():
if k not in replaced:
out.append(f"{k}={v}||0||0||0||0||0")
return "\n".join(out) + "\n"
def generate_set_files(
template_set_path: str,
grid: Dict[str, List[Any]],
out_dir: str,
ea_name: str = "EA",
symbol: str = "XAUUSD",
period: str = "M1",
date_from: str = "2024-01-01",
date_to: str = "2024-06-01",
terminal_path: str = r"C:\Program Files\MetaTrader 5\terminal64.exe",
) -> str:
"""
生成所有参数组合的 .set 文件 + manifest.csv + 批处理脚本。
返回 manifest.csv 路径。
"""
os.makedirs(out_dir, exist_ok=True)
with open(template_set_path, "r", encoding="utf-8", errors="replace") as f:
template = f.read()
keys = list(grid.keys())
combos = list(product(*[grid[k] for k in keys]))
manifest_rows: List[Dict[str, Any]] = []
for i, combo in enumerate(combos):
params = {k: str(v) for k, v in zip(keys, combo)}
set_text = _apply_params_to_template(template, params)
set_name = f"{ea_name}_{i:04d}.set"
set_path = os.path.join(out_dir, set_name)
with open(set_path, "w", encoding="utf-8") as f:
f.write(set_text)
row = {"idx": i, "set_file": set_name}
row.update(params)
manifest_rows.append(row)
manifest_path = os.path.join(out_dir, "manifest.csv")
pd.DataFrame(manifest_rows).to_csv(manifest_path, index=False, encoding="utf-8-sig")
# 批处理脚本:逐个跑回测,输出 ReportTester-XXXX.xlsx
bat_lines = [
"@echo off",
f"REM MT5 批量回测脚本 — 由 param_scan.py 生成",
f"REM 共 {len(combos)} 个参数组合",
f'set TERMINAL="{terminal_path}"',
f'set EA={ea_name}',
f'set SYMBOL={symbol}',
f'set PERIOD={period}',
f'set FROM={date_from}',
f'set TO={date_to}',
f'set OUTDIR={out_dir}\\reports',
f'if not exist "%OUTDIR%" mkdir "%OUTDIR%"',
"",
"REM 说明:MT5 terminal.exe 单实例运行,每次跑一个 .set",
"REM 配置文件 (.ini) 指定 ReportTester 输出路径,跑完把 xlsx 移到 OUTDIR",
"REM 这里给出调用骨架;实际需配合 Common/tester.ini 模板",
]
for r in manifest_rows:
bat_lines.append(
f'REM [{r["idx"]}] {r["set_file"]} '
f'{", ".join(f"{k}={r[k]}" for k in keys)}'
)
bat_lines.append(
f'echo Running {r["set_file"]} ...'
)
bat_lines.append(
f'%TERMINAL% /portable /config:tester_{r["idx"]:04d}.ini'
)
bat_lines.append("echo All done.")
bat_path = os.path.join(out_dir, "run_scan.bat")
with open(bat_path, "w", encoding="utf-8") as f:
f.write("\r\n".join(bat_lines))
# tester.ini 模板说明
ini_note = (
"; tester.ini 模板示例(每组合一份,替换 <...>\n"
"[Tester]\n"
f"Expert=Experts\\{ea_name}.ex5\n"
f"Symbol={symbol}\n"
f"Period={period}\n"
f"FromDate={date_from}\n"
f"ToDate={date_to}\n"
"Deposit=10000\n"
"Currency=USD\n"
"Leverage=100\n"
"Model=1\n"
"Optimization=0\n"
"Visual=0\n"
"ForwardMode=0\n"
"Report=<OUTDIR>\\ReportTester_<IDX>\n"
"ReplaceReport=1\n"
"ShutdownTerminal=1\n"
"UseLocal=1\n"
"InputSet=<SET_FILE_FULL_PATH>\n"
)
with open(os.path.join(out_dir, "tester_ini_template.txt"), "w", encoding="utf-8") as f:
f.write(ini_note)
print(f"已生成 {len(combos)} 个 .set 文件 + manifest.csv + run_scan.bat")
print(f" 目录: {out_dir}")
print(f" 下一步: 编辑 tester_ini_template.txt,在 MT5 里跑 run_scan.bat")
print(f" 把生成的 ReportTester-*.xlsx 放到 {out_dir}\\reports\\ 后执行 analyze。")
return manifest_path
# =========================================================================== #
# 2. 加载扫描结果
# =========================================================================== #
def load_scan_results(reports_dir: str, manifest_csv: str) -> pd.DataFrame:
"""
读取 manifest + reports_dir 下所有 xlsx,解析每份报告的关键指标,
合并参数 → 返回 DataFrame[参数列..., net, pf, win_rate, max_dd_pct]。
"""
manifest = pd.read_csv(manifest_csv)
rows: List[Dict[str, Any]] = []
for _, mrow in manifest.iterrows():
# 报告文件名约定:ReportTester_<idx>.xlsx 或 manifest 里的 set_file 改后缀
idx = int(mrow["idx"])
candidates = [
os.path.join(reports_dir, f"ReportTester-{idx:04d}.xlsx"),
os.path.join(reports_dir, f"ReportTester-{idx}.xlsx"),
os.path.join(reports_dir, str(mrow["set_file"]).replace(".set", ".xlsx")),
]
path = next((c for c in candidates if os.path.exists(c)), None)
if path is None:
continue
try:
rep = mp.parse_report(path)
s = rep.summary_norm
rows.append({
**{k: mrow[k] for k in manifest.columns},
"net": s.get("total_net_profit"),
"pf": s.get("profit_factor"),
"win_rate": s.get("win_rate_pct"),
"max_dd_pct": s.get("max_balance_dd_pct"),
"sharpe": s.get("sharpe"),
})
except Exception as e:
print(f" 跳过 {path}: {e}")
return pd.DataFrame(rows)
# =========================================================================== #
# 3. 响应面 + 高原检测
# =========================================================================== #
def build_pivot(df: pd.DataFrame, x: str, y: str, metric: str) -> Tuple[np.ndarray, List, List]:
"""透视成 2D 矩阵 (行=y, 列=x)。"""
p = df.pivot_table(index=y, columns=x, values=metric, aggfunc="mean")
p = p.sort_index().sort_index(axis=1)
return p.to_numpy(), list(p.columns), list(p.index)
def detect_plateaus(
Z: np.ndarray, percentile: float = 80, min_cluster: int = 4
) -> List[Dict[str, Any]]:
"""
高原检测:指标值高于 percentile 阈值,且 4-邻域连通区域 ≥ min_cluster 个点。
返回每个高原的边界、中心、均值、面积。越大越宽的高原 = 越稳健。
"""
if Z.size == 0 or np.all(np.isnan(Z)):
return []
valid = Z[~np.isnan(Z)]
if valid.size == 0:
return []
thr = float(np.percentile(valid, percentile))
binary = (Z >= thr) & ~np.isnan(Z)
# 4-连通 DFS
visited = np.zeros_like(binary, dtype=bool)
ny, nx = Z.shape
clusters: List[List[Tuple[int, int]]] = []
for sy in range(ny):
for sx in range(nx):
if binary[sy, sx] and not visited[sy, sx]:
stack = [(sy, sx)]
comp = []
while stack:
cy, cx = stack.pop()
if cy < 0 or cy >= ny or cx < 0 or cx >= nx:
continue
if visited[cy, cx] or not binary[cy, cx]:
continue
visited[cy, cx] = True
comp.append((cy, cx))
stack.extend([(cy+1, cx), (cy-1, cx), (cy, cx+1), (cy, cx-1)])
if len(comp) >= min_cluster:
clusters.append(comp)
plateaus: List[Dict[str, Any]] = []
for comp in clusters:
ys = [c[0] for c in comp]
xs = [c[1] for c in comp]
vals = [Z[c] for c in comp]
plateaus.append({
"size": len(comp),
"y_range": (int(min(ys)), int(max(ys))),
"x_range": (int(min(xs)), int(max(xs))),
"metric_mean": float(np.mean(vals)),
"metric_max": float(max(vals)),
})
plateaus.sort(key=lambda p: p["size"], reverse=True)
return plateaus
def overfit_score(Z: np.ndarray, plateaus: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
过拟合评分:
peak_value = 全局最大值
peak_isolation = 最大值周围邻域均值 / 最大值(越小说明越孤立=过拟合)
plateau_coverage = 高原面积占总面积比例(越高越稳健)
"""
if Z.size == 0 or np.all(np.isnan(Z)):
return {}
valid = Z[~np.isnan(Z)]
peak = float(np.nanmax(Z))
py, px = np.unravel_index(np.nanargmax(Z), Z.shape)
# 邻域均值(3x3 减自身)
nbrs = []
for dy in (-1, 0, 1):
for dx in (-1, 0, 1):
if dy == 0 and dx == 0:
continue
ny, nx = py + dy, px + dx
if 0 <= ny < Z.shape[0] and 0 <= nx < Z.shape[1] and not np.isnan(Z[ny, nx]):
nbrs.append(Z[ny, nx])
nbr_mean = float(np.mean(nbrs)) if nbrs else float("nan")
isolation = nbr_mean / peak if peak != 0 else float("nan")
plateau_cov = sum(p["size"] for p in plateaus) / Z.size
# 评分:越接近 1 越稳健,越接近 0 越过拟合
robust = float(isolation * 0.5 + min(1, plateau_cov * 4) * 0.5) if not np.isnan(isolation) else float("nan")
return {
"peak": peak,
"peak_neighbor_mean": nbr_mean,
"peak_isolation_ratio": isolation,
"plateau_coverage": plateau_cov,
"robust_score": robust,
}
# =========================================================================== #
# 4. SVG 可视化
# =========================================================================== #
def svg_heatmap(
Z: np.ndarray, xticks: List, yticks: List,
metric: str, xname: str, yname: str,
plateaus: List[Dict[str, Any]],
w: int = 560, h: int = 420,
) -> str:
"""2D 热力图。高原用粗边框标出。"""
if Z.size == 0:
return ""
top, bottom, left, right = 50, 50, 70, 16
plot_w = w - left - right
plot_h = h - top - bottom
ny, nx = Z.shape
cw = plot_w / nx
ch = plot_h / ny
valid = Z[~np.isnan(Z)]
vmin = float(np.min(valid)) if valid.size else 0
vmax = float(np.max(valid)) if valid.size else 1
if vmax == vmin:
vmax = vmin + 1
def color(v):
if np.isnan(v):
return "#f3f4f6"
r = (v - vmin) / (vmax - vmin)
if r < 0.5:
t = r * 2
return f"rgb({int(220-60*t)},{int(60+160*t)},{int(60+40*t)})"
else:
t = (r - 0.5) * 2
return f"rgb({int(160-100*t)},{int(220-40*t)},{int(100-40*t)})"
cells = ""
for j in range(ny):
for i in range(nx):
v = Z[j, i]
x = left + i * cw
y = top + (ny - 1 - j) * ch
vstr = f"{v:.3f}" if not np.isnan(v) else ""
cells += (f'<rect x="{x:.1f}" y="{y:.1f}" width="{cw:.1f}" height="{ch:.1f}" '
f'fill="{color(v)}" stroke="#fff" stroke-width="0.5">'
f'<title>{xticks[i]}, {yticks[j]}: {vstr}</title></rect>')
if not np.isnan(v):
cells += f'<text x="{x+cw/2:.1f}" y="{y+ch/2+3:.1f}" font-size="9" fill="#111827" text-anchor="middle">{v:.2f}</text>'
# 高原边框
plat_svg = ""
palette = ["#1e3a8a", "#7c3aed", "#0f766e"]
for k, p in enumerate(plateaus[:3]):
y0, y1 = p["y_range"]
x0, x1 = p["x_range"]
rx = left + x0 * cw
ry = top + (ny - 1 - y1) * ch
rw = (x1 - x0 + 1) * cw
rh = (y1 - y0 + 1) * ch
col = palette[k % len(palette)]
plat_svg += (f'<rect x="{rx:.1f}" y="{ry:.1f}" width="{rw:.1f}" height="{rh:.1f}" '
f'fill="none" stroke="{col}" stroke-width="2.5" rx="2"/>')
plat_svg += f'<text x="{rx+4:.1f}" y="{ry+13:.1f}" font-size="10" fill="{col}" font-weight="bold">P{k+1}</text>'
# 轴标签
xlabs = "".join(
f'<text x="{left+(i+0.5)*cw:.1f}" y="{h-bottom+14:.1f}" font-size="9" fill="#374151" text-anchor="middle">{xticks[i]}</text>'
for i in range(nx)
)
ylabs = "".join(
f'<text x="{left-6:.1f}" y="{top+(ny-0.5-j)*ch+3:.1f}" font-size="9" fill="#374151" text-anchor="end">{yticks[j]}</text>'
for j in range(ny)
)
# 图例
leg = ""
for i in range(6):
r = i / 5
v = vmin + r * (vmax - vmin)
leg += (f'<rect x="{left+i*30:.0f}" y="18" width="30" height="10" fill="{color(v)}"/>'
f'<text x="{left+i*30+15:.0f}" y="42" font-size="8" fill="#374151" text-anchor="middle">{v:.2f}</text>')
return (f'<svg viewBox="0 0 {w} {h}" class="chart">'
f'<text x="{w//2}" y="12" font-size="11" fill="#374151" text-anchor="middle">'
f'{html.escape(metric)} 响应面热力图({html.escape(xname)} × {html.escape(yname)}</text>'
f'{leg}{cells}{plat_svg}{xlabs}{ylabs}'
f'<text x="{w//2}" y="{h-4}" font-size="10" fill="#374151" text-anchor="middle">{html.escape(xname)} →</text>'
f'<text x="14" y="{h//2}" font-size="10" fill="#374151" text-anchor="middle" transform="rotate(-90 14 {h//2})">{html.escape(yname)} →</text>'
f'</svg>')
def svg_surface_iso(
Z: np.ndarray, xticks: List, yticks: List, metric: str,
w: int = 560, h: int = 420,
) -> str:
"""3D 等距投影曲面(四边形网格,画家算法排序)。"""
if Z.size == 0:
return ""
Zf = np.where(np.isnan(Z), 0.0, Z)
ny, nx = Zf.shape
vmin = float(Zf.min())
vmax = float(Zf.max())
if vmax == vmin:
vmax = vmin + 1
cx, cy = w / 2, h * 0.62
scale_xy = min((w - 80) / (nx + ny), 36)
scale_z = (h * 0.45) / (vmax - vmin)
ang = 0.55 # 投影角
def proj(i, j, z):
# i 列(x), j 行(y)
px = cx + (i - j) * scale_xy * 0.9
py = cy + (i + j) * scale_xy * ang - (z - vmin) * scale_z
return px, py
quads = []
for j in range(ny - 1):
for i in range(nx - 1):
z = Zf[j, i]
# 颜色
r = (z - vmin) / (vmax - vmin)
if r < 0.5:
t = r * 2
col = f"rgb({int(220-60*t)},{int(60+160*t)},{int(60+40*t)})"
else:
t = (r - 0.5) * 2
col = f"rgb({int(160-100*t)},{int(220-40*t)},{int(100-40*t)})"
p1 = proj(i, j, Zf[j, i])
p2 = proj(i + 1, j, Zf[j, i + 1])
p3 = proj(i + 1, j + 1, Zf[j + 1, i + 1])
p4 = proj(i, j + 1, Zf[j + 1, i])
depth = i + j # 越大越靠前
pts = " ".join(f"{x:.1f},{y:.1f}" for x, y in [p1, p2, p3, p4])
quads.append((depth, f'<polygon points="{pts}" fill="{col}" stroke="#1f2937" stroke-width="0.3" opacity="0.92"/>'))
quads.sort(key=lambda q: -q[0]) # 远的先画
body = "".join(q[1] for q in quads)
return (f'<svg viewBox="0 0 {w} {h}" class="chart">'
f'<text x="{w//2}" y="16" font-size="11" fill="#374151" text-anchor="middle">{html.escape(metric)} 3D 响应面(等距投影)</text>'
f'{body}</svg>')
# =========================================================================== #
# 5. HTML 报告
# =========================================================================== #
def build_html(
df: pd.DataFrame, x: str, y: str, metric: str,
Z: np.ndarray, xticks: List, yticks: List,
plateaus: List[Dict[str, Any]], of_score: Dict[str, Any],
) -> str:
heat = svg_heatmap(Z, xticks, yticks, metric, x, y, plateaus)
surf = svg_surface_iso(Z, xticks, yticks, metric)
# 高原表
if plateaus:
plat_rows = ""
for k, p in enumerate(plateaus[:6]):
xr = f"{xticks[p['x_range'][0]]}~{xticks[p['x_range'][1]]}"
yr = f"{yticks[p['y_range'][0]]}~{yticks[p['y_range'][1]]}"
plat_rows += (f"<tr><td>P{k+1}</td><td>{p['size']}</td><td>{xr}</td><td>{yr}</td>"
f"<td>{p['metric_mean']:.3f}</td><td>{p['metric_max']:.3f}</td></tr>")
plat_table = (
"<table class='data'><thead><tr><th>高原</th><th>面积(格数)</th>"
f"<th>{html.escape(x)} 范围</th><th>{html.escape(y)} 范围</th>"
f"<th>{html.escape(metric)} 均值</th><th>{html.escape(metric)} 峰值</th></tr></thead><tbody>"
+ plat_rows + "</tbody></table>"
)
else:
plat_table = "<p class='muted'>未检测到稳健高原(所有高分点都是孤立单点,过拟合风险高)。</p>"
iso_str = (f"{of_score['peak_isolation_ratio']:.2f}" if of_score and not np.isnan(of_score.get('peak_isolation_ratio', np.nan)) else "")
rob_str = (f"{of_score['robust_score']:.2f}" if of_score and not np.isnan(of_score.get('robust_score', np.nan)) else "")
cards = (
"<div class='cards'>"
f"<div class='card'><div class='k'>全局峰值 {html.escape(metric)}</div><div class='v'>{of_score['peak']:.3f}</div></div>"
f"<div class='card'><div class='k'>峰值邻域均值/峰值</div><div class='v'>{iso_str}</div></div>"
f"<div class='card'><div class='k'>高原覆盖率</div><div class='v'>{of_score['plateau_coverage']*100:.0f}%</div></div>"
f"<div class='card'><div class='k'>稳健性评分(0~1)</div><div class='v'>{rob_str}</div></div>"
"</div>"
)
interp = ""
if of_score and not np.isnan(of_score.get('robust_score', np.nan)):
r = of_score['robust_score']
if r < 0.4:
interp = "<div class='warn'>稳健性评分低:最优参数是孤立尖峰,邻域快速退化,过拟合风险高。应在高原中心选参而非峰值点。</div>"
elif r > 0.7:
interp = "<div class='note'>稳健性评分高:存在宽阔高原,参数对扰动不敏感,泛化性好。建议在最大高原中心选参。</div>"
else:
interp = "<div class='note'>稳健性中等:有一定高原但峰值仍较突出,建议在高原内偏离峰值一点的位置选参以留安全边际。</div>"
recommend = ""
if plateaus:
p1 = plateaus[0]
cx_v = xticks[(p1['x_range'][0] + p1['x_range'][1]) // 2]
cy_v = yticks[(p1['y_range'][0] + p1['y_range'][1]) // 2]
recommend = (f"<div class='note'><b>推荐参数(最大高原中心)</b>:"
f"{html.escape(x)} = <code>{cx_v}</code>{html.escape(y)} = <code>{cy_v}</code>。"
f"该位置周围 {p1['size']} 个网格点 {html.escape(metric)} 均值 {p1['metric_mean']:.3f}"
f"对参数扰动不敏感。</div>")
# 顶 10 单点
top_df = df.nlargest(10, metric) if metric in df.columns else df.head(0)
top_rows = ""
for _, r in top_df.iterrows():
top_rows += "<tr>" + "".join(f"<td>{r[c]}</td>" for c in top_df.columns) + "</tr>"
top_table = ("<h3>Top-10 单点峰值(对比用,单点 ≠ 稳健)</h3>"
"<table class='data'><thead><tr>" + "".join(f"<th>{html.escape(c)}</th>" for c in top_df.columns) + "</tr></thead><tbody>"
+ top_rows + "</tbody></table>") if top_rows else ""
return f"""<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
<title>参数敏感度扫描 — {html.escape(metric)}</title>
<style>
body {{ font-family:-apple-system,"Microsoft YaHei",sans-serif; background:#f8fafc; color:#111827; margin:0; padding:20px;}}
.wrap {{ max-width:1100px; margin:0 auto;}}
h1,h2,h3 {{ color:#1e3a8a;}} h1 {{ border-bottom:3px solid #1e3a8a; padding-bottom:8px;}}
table.data {{ border-collapse:collapse; width:100%; background:#fff; margin:8px 0 16px; font-size:12px;}}
table.data th, table.data td {{ border:1px solid #e2e8f0; padding:5px 7px; text-align:left;}}
table.data th {{ background:#f1f5f9;}}
.muted {{ color:#6b7280; font-size:12px;}}
code {{ background:#f1f5f9; padding:1px 5px; border-radius:3px;}}
.chart {{ width:100%; height:auto; background:#fff; border:1px solid #e2e8f0; border-radius:6px; margin:8px 0;}}
.grid2 {{ display:grid; grid-template-columns:1fr 1fr; gap:12px;}}
.cards {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(200px,1fr)); gap:12px; margin:16px 0;}}
.card {{ background:#fff; padding:14px; border-radius:8px; border:1px solid #e2e8f0;}}
.card .k {{ color:#6b7280; font-size:12px;}} .card .v {{ font-size:18px; font-weight:600; margin-top:4px;}}
.note {{ background:#fef3c7; border-left:4px solid #f59e0b; padding:10px 14px; margin:12px 0; border-radius:4px;}}
.warn {{ background:#fee2e2; border-left:4px solid #dc2626; padding:10px 14px; margin:12px 0; border-radius:4px;}}
footer {{ color:#6b7280; font-size:11px; margin-top:32px; text-align:center;}}
</style></head><body><div class="wrap">
<h1>参数敏感度扫描报告</h1>
<p class='muted'>指标: <code>{html.escape(metric)}</code> 参数轴: {html.escape(x)} × {html.escape(y)} 样本数: {len(df)}</p>
{cards}
{interp}
{recommend}
<h2>1. 响应面热力图(找高原用)</h2>
{heat}
<p class='muted'>粗框 P1/P2/P3 = 检测到的高原(连通高分区域)。高原越宽 = 参数越稳健。</p>
<h2>2. 3D 等距投影曲面</h2>
{surf}
<h2>3. 高原清单</h2>
{plat_table}
{top_table}
<footer>由 param_scan.py 生成 · 高原检测阈值=80分位 · {pd.Timestamp.now().strftime('%Y-%m-%d %H:%M')}</footer>
</div></body></html>"""
# =========================================================================== #
# 6. demo(合成数据演示)
# =========================================================================== #
def _demo_df() -> pd.DataFrame:
"""合成一个有高原 + 一个孤立尖峰的数据集。"""
xs = list(range(5, 35, 5)) # FastMA
ys = list(range(20, 70, 10)) # SlowMA
rows = []
for x in xs:
for y in ys:
# 主响应:FastMA 10-20, SlowMA 30-50 是高原(PF~1.5
base = 1.5 - 0.02 * abs(x - 15) - 0.015 * abs(y - 40)
# 加一个孤立尖峰在 (25, 60)
if x == 25 and y == 60:
base = 2.3
base += np.random.default_rng(x * 100 + y).normal(0, 0.05)
rows.append({"FastMA": x, "SlowMA": y, "PF": round(base, 3)})
return pd.DataFrame(rows)
def _demo_template() -> str:
"""合成一个最小 .set 模板,供 demo 写出 .set 用。"""
return (
"; inputs\n"
"FastMA=10||1||1||100||N\n"
"SlowMA=40||1||1||500||N\n"
"StopLoss=100||1||1||10000||N\n"
"TakeProfit=200||1||1||10000||N\n"
)
# =========================================================================== #
# 7. 写出优化后的 .set 文件
# =========================================================================== #
def _pick_optimal_params(
df: pd.DataFrame, x: str, y: str, metric: str,
Z: np.ndarray, xticks: List, yticks: List,
plateaus: List[Dict[str, Any]],
strategy: str,
) -> Tuple[Dict[str, Any], str]:
"""
根据策略选出最优参数。
返回 (参数字典, 来源说明)。
参数字典包含 x/y 两个轴的值(高原/峰值),或单点峰值的全套参数。
"""
if strategy == "plateau":
if not plateaus:
# 回退到峰值
return _pick_optimal_params(df, x, y, metric, Z, xticks, yticks, plateaus, "peak")
p1 = plateaus[0]
cx_v = xticks[(p1["x_range"][0] + p1["x_range"][1]) // 2]
cy_v = yticks[(p1["y_range"][0] + p1["y_range"][1]) // 2]
return ({x: cx_v, y: cy_v},
f"最大高原中心(面积 {p1['size']}{metric} 均值 {p1['metric_mean']:.3f}")
if strategy == "peak":
# 单点峰值:取 df 中 metric 最高的行,返回该行全套参数
best_row = df.loc[df[metric].idxmax()]
# 排除所有计算列与 metric 本身,剩下的是 manifest 里的参数列
exclude = {"idx", "set_file", "net", "pf", "win_rate", "max_dd_pct", "sharpe",
metric.lower(), metric.upper(), metric}
param_cols = [c for c in df.columns if c not in exclude]
params = {c: best_row[c] for c in param_cols}
return params, f"单点峰值({metric}={best_row[metric]:.3f},来源 {best_row.get('set_file','')}"
raise ValueError(f"未知策略: {strategy}")
def write_optimized_set(
template_path: Optional[str],
template_text: Optional[str],
params: Dict[str, Any],
out_path: str,
source_note: str,
) -> str:
"""
把最优参数套到模板上,写出 .set 文件。
template_path 与 template_text 二选一(后者优先,供 demo 用)。
返回写入的文件路径。
"""
if template_text is None:
if template_path is None or not os.path.exists(template_path):
raise FileNotFoundError(f"模板未提供/不存在: {template_path}")
with open(template_path, "r", encoding="utf-8", errors="replace") as f:
template_text = f.read()
# 全部转字符串
params_str = {k: str(v) for k, v in params.items()}
new_set = _apply_params_to_template(template_text, params_str)
# 在文件头加注释说明来源
header = f"; === 由 param_scan.py 优化生成 ===\n; 来源: {source_note}\n; 生成时间: {pd.Timestamp.now().strftime('%Y-%m-%d %H:%M')}\n; 应用参数: {params_str}\n\n"
with open(out_path, "w", encoding="utf-8") as f:
f.write(header + new_set)
return out_path
# =========================================================================== #
# CLI
# =========================================================================== #
def main(argv: List[str]) -> int:
ap = argparse.ArgumentParser(description="参数敏感度扫描")
sub = ap.add_subparsers(dest="cmd", required=True)
p_gen = sub.add_parser("gen-set", help="生成 .set 网格 + 批处理脚本")
p_gen.add_argument("template")
p_gen.add_argument("grid_json")
p_gen.add_argument("out_dir")
p_gen.add_argument("--ea", default="EA")
p_gen.add_argument("--symbol", default="XAUUSD")
p_gen.add_argument("--period", default="M1")
p_gen.add_argument("--from", dest="date_from", default="2024-01-01")
p_gen.add_argument("--to", dest="date_to", default="2024-06-01")
p_gen.add_argument("--terminal", default=r"C:\Program Files\MetaTrader 5\terminal64.exe")
p_an = sub.add_parser("analyze", help="分析扫描结果,画响应面")
p_an.add_argument("reports_dir")
p_an.add_argument("manifest")
p_an.add_argument("--x", required=True)
p_an.add_argument("--y", required=True)
p_an.add_argument("--metric", default="pf")
p_an.add_argument("--template", default=None,
help="原始 .set 模板路径(用于生成优化后 .set)")
p_an.add_argument("--out-set", default=None,
help="优化后 .set 输出路径(需配合 --template")
p_an.add_argument("--strategy", choices=["peak", "plateau"], default="plateau",
help="选参策略:peak=单点峰值(激进),plateau=高原中心(稳健,默认)")
p_demo = sub.add_parser("demo", help="用合成数据演示")
p_demo.add_argument("--out-set", default=None,
help="演示用合成模板写出优化后 .set 的路径")
p_demo.add_argument("--strategy", choices=["peak", "plateau"], default="plateau",
help="选参策略:peak=单点峰值(激进),plateau=高原中心(稳健,默认)")
args = ap.parse_args(argv)
os.makedirs(OUT_DIR, exist_ok=True)
if args.cmd == "gen-set":
with open(args.grid_json, "r", encoding="utf-8") as f:
grid = json.load(f)
generate_set_files(
args.template, grid, args.out_dir,
ea_name=args.ea, symbol=args.symbol, period=args.period,
date_from=args.date_from, date_to=args.date_to,
terminal_path=args.terminal,
)
return 0
if args.cmd == "analyze":
df = load_scan_results(args.reports_dir, args.manifest)
if df.empty:
print("未加载到任何报告数据,检查 reports_dir 与 manifest")
return 1
if args.x not in df.columns or args.y not in df.columns:
print(f"参数 {args.x}/{args.y} 不在 manifest,可用列: {list(df.columns)}")
return 1
if args.metric not in df.columns:
print(f"指标 {args.metric} 不可用,可用: {['net','pf','win_rate','max_dd_pct','sharpe']}")
return 1
Z, xt, yt = build_pivot(df, args.x, args.y, args.metric)
plateaus = detect_plateaus(Z)
of = overfit_score(Z, plateaus)
html_doc = build_html(df, args.x, args.y, args.metric, Z, xt, yt, plateaus, of)
out = os.path.join(OUT_DIR, "param_scan.html")
with open(out, "w", encoding="utf-8") as f:
f.write(html_doc)
print(f"HTML 报告: {out}")
# 写出优化后的 .set
if args.out_set:
if not args.template:
print("ERROR: --out-set 需要配合 --template <原始set路径>")
return 1
params, note = _pick_optimal_params(
df, args.x, args.y, args.metric, Z, xt, yt, plateaus, args.strategy
)
write_optimized_set(args.template, None, params, args.out_set, note)
print(f"优化后 .set 已写出: {args.out_set}")
print(f" 策略: {args.strategy} 来源: {note}")
print(f" 应用参数: {params}")
return 0
if args.cmd == "demo":
df = _demo_df()
Z, xt, yt = build_pivot(df, "FastMA", "SlowMA", "PF")
plateaus = detect_plateaus(Z)
of = overfit_score(Z, plateaus)
html_doc = build_html(df, "FastMA", "SlowMA", "PF", Z, xt, yt, plateaus, of)
out = os.path.join(OUT_DIR, "param_scan.html")
with open(out, "w", encoding="utf-8") as f:
f.write(html_doc)
print(f"HTML 报告: {out}")
print(f"检测到 {len(plateaus)} 个高原")
if args.out_set:
params, note = _pick_optimal_params(
df, "FastMA", "SlowMA", "PF", Z, xt, yt, plateaus, args.strategy
)
write_optimized_set(None, _demo_template(), params, args.out_set, note)
print(f"优化后 .set 已写出(用合成模板): {args.out_set}")
print(f" 来源: {note}")
print(f" 应用参数: {params}")
return 0
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+1076
View File
File diff suppressed because it is too large Load Diff
+367
View File
@@ -0,0 +1,367 @@
# -*- coding: utf-8 -*-
"""
Walk-Forward 滚动验证
====================
将单份报告的逐笔交易按时间切成多个滚动窗口每个窗口内
前段做 IS参数评估段后段做 OOS样本外验证段
计算每个窗口的 IS/OOS 指标再汇总
- Walk-Forward Efficiency (WFE) = Σ OOS净盈利 / Σ IS净盈利
- OOS 盈利窗口占比
- IS PF OOS PF 的相关性=过拟合低=参数不稳健
- 滚动 IS/OOS PF 曲线
相比单次 IS/OSS 划分能反映参数在不同行情段的稳健性
可独立运行也可被 run_analysis.py 导入集成进主报告
用法:
python walk_forward.py <report.xlsx> [--is-days 60] [--oos-days 30] [--step-days 30]
python walk_forward.py --demo
"""
from __future__ import annotations
import argparse
import glob
import html
import os
import sys
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
import pandas as pd
import mt5_report_parser as mp
OUT_DIR = "output"
# --------------------------------------------------------------------------- #
# 单段指标
# --------------------------------------------------------------------------- #
def _seg_metrics(net: pd.Series) -> Dict[str, float]:
n = len(net)
if n == 0:
return {"n": 0, "net": 0.0, "pf": 0.0, "win": 0.0, "dd": 0.0, "exp": 0.0}
wins = net[net > 0]
losses = net[net <= 0]
gp = wins.sum()
gl = -losses.sum()
pf = gp / gl if gl > 0 else np.inf
eq = net.cumsum()
dd = float((eq - eq.cummax()).min())
return {
"n": int(n),
"net": float(net.sum()),
"pf": float(pf),
"win": float(len(wins) / n * 100),
"dd": dd,
"exp": float(net.mean()),
}
@dataclass
class WFWindow:
idx: int
is_start: pd.Timestamp
is_end: pd.Timestamp
oos_start: pd.Timestamp
oos_end: pd.Timestamp
is_metrics: Dict[str, float]
oos_metrics: Dict[str, float]
def walk_forward(
trades: pd.DataFrame,
is_days: int = 60,
oos_days: int = 30,
step_days: Optional[int] = None,
) -> List[WFWindow]:
"""
滚动切窗
trades: open_time, net_profit DataFrame
is_days / oos_days: IS/OOS 窗口天数
step_days: 滑动步长默认 = oos_days即不重叠的 OOS
"""
if trades is None or trades.empty:
return []
t = trades.sort_values("open_time").reset_index(drop=True)
t["net_profit"] = t["net_profit"].astype(float)
if step_days is None:
step_days = oos_days
start = t["open_time"].min()
end = t["open_time"].max()
total_span = (end - start).days
win_span = is_days + oos_days
if total_span < win_span:
return [] # 数据不足以切出一个窗口
windows: List[WFWindow] = []
cur = start
idx = 0
while True:
is_s = cur
is_e = cur + pd.Timedelta(days=is_days)
oos_s = is_e
oos_e = oos_s + pd.Timedelta(days=oos_days)
if oos_e > end + pd.Timedelta(days=1):
break
is_mask = (t["open_time"] >= is_s) & (t["open_time"] < is_e)
oos_mask = (t["open_time"] >= oos_s) & (t["open_time"] < oos_e)
is_net = t.loc[is_mask, "net_profit"]
oos_net = t.loc[oos_mask, "net_profit"]
if len(is_net) == 0 or len(oos_net) == 0:
cur += pd.Timedelta(days=step_days)
continue
windows.append(WFWindow(
idx=idx,
is_start=is_s, is_end=is_e,
oos_start=oos_s, oos_end=oos_e,
is_metrics=_seg_metrics(is_net),
oos_metrics=_seg_metrics(oos_net),
))
idx += 1
cur += pd.Timedelta(days=step_days)
return windows
def wf_summary(windows: List[WFWindow]) -> Dict[str, Any]:
if not windows:
return {}
is_nets = [w.is_metrics["net"] for w in windows]
oos_nets = [w.oos_metrics["net"] for w in windows]
is_pfs = [w.is_metrics["pf"] for w in windows if not np.isinf(w.is_metrics["pf"])]
oos_pfs = [w.oos_metrics["pf"] for w in windows if not np.isinf(w.oos_metrics["pf"])]
wfe = sum(oos_nets) / sum(is_nets) if sum(is_nets) != 0 else np.nan
oos_profitable = sum(1 for n in oos_nets if n > 0)
# IS-OOS PF 相关性
if len(is_pfs) > 2 and len(oos_pfs) > 2:
# 配对(取有限值的交集)
pairs = [(w.is_metrics["pf"], w.oos_metrics["pf"]) for w in windows
if not np.isinf(w.is_metrics["pf"]) and not np.isinf(w.oos_metrics["pf"])]
if len(pairs) > 2:
arr = np.array(pairs)
corr = float(np.corrcoef(arr[:, 0], arr[:, 1])[0, 1])
else:
corr = np.nan
else:
corr = np.nan
return {
"n_windows": len(windows),
"wfe": float(wfe),
"oos_profitable": oos_profitable,
"oos_profitable_pct": float(oos_profitable / len(windows) * 100),
"sum_is_net": float(sum(is_nets)),
"sum_oos_net": float(sum(oos_nets)),
"avg_is_pf": float(np.mean(is_pfs)) if is_pfs else 0.0,
"avg_oos_pf": float(np.mean(oos_pfs)) if oos_pfs else 0.0,
"is_oos_pf_corr": corr,
"max_oos_dd": float(min(w.oos_metrics["dd"] for w in windows)),
}
# --------------------------------------------------------------------------- #
# SVG
# --------------------------------------------------------------------------- #
def svg_wf_curve(windows: List[WFWindow], w: int = 760, h: int = 260) -> str:
if not windows:
return ""
top, bottom, left, right = 24, 36, 46, 16
plot_h = h - top - bottom
plot_w = w - left - right
is_pfs = [w.is_metrics["pf"] for w in windows]
oos_pfs = [w.oos_metrics["pf"] for w in windows]
vals = is_pfs + oos_pfs + [1.0]
vmin = min(vals)
vmax = max(vals)
if vmax == vmin:
vmax = vmin + 1
vmax = max(vmax, 1.0)
vmin = min(vmin, 0.0)
def xy(i, v):
x = left + i / max(1, len(windows) - 1) * plot_w
y = top + plot_h - (v - vmin) / (vmax - vmin) * plot_h
return x, y
def poly(vals_, color):
pts = " ".join(f"{x:.1f},{y:.1f}" for x, y in [xy(i, v) for i, v in enumerate(vals_)])
return f'<polyline points="{pts}" fill="none" stroke="{color}" stroke-width="1.6"><title></title></polyline>'
# 1.0 参考线
one_y = top + plot_h - (1.0 - vmin) / (vmax - vmin) * plot_h
grid = ""
for frac in (0, 0.25, 0.5, 0.75, 1.0):
yv = vmin + frac * (vmax - vmin)
gy = top + plot_h - frac * plot_h
grid += (f'<line x1="{left}" y1="{gy:.1f}" x2="{w-right}" y2="{gy:.1f}" stroke="#e5e7eb" stroke-width="0.5"/>'
f'<text x="{left-6}" y="{gy+3:.1f}" font-size="9" fill="#6b7280" text-anchor="end">{yv:.2f}</text>')
one_line = f'<line x1="{left}" y1="{one_y:.1f}" x2="{w-right}" y2="{one_y:.1f}" stroke="#9ca3af" stroke-dasharray="3,3" stroke-width="0.8"/>'
# 窗口标签(仅首/中/尾)
xlabs = ""
for i in [0, len(windows) // 2, len(windows) - 1]:
x = left + i / max(1, len(windows) - 1) * plot_w
xlabs += f'<text x="{x:.1f}" y="{h-bottom+14:.1f}" font-size="9" fill="#374151" text-anchor="middle">#{windows[i].idx}</text>'
return (
f'<svg viewBox="0 0 {w} {h}" class="chart">'
f'{grid}{one_line}'
f'{poly(is_pfs, "#2563eb")}'
f'{poly(oos_pfs, "#dc2626")}'
f'{xlabs}'
f'<text x="{w//2}" y="16" font-size="11" fill="#374151" text-anchor="middle">滚动窗口 IS vs OOS 盈利因子 (PF=1 为盈亏平衡线)</text>'
f'<text x="{w-right}" y="32" font-size="10" fill="#2563eb" text-anchor="end">IS PF</text>'
f'<text x="{w-right}" y="46" font-size="10" fill="#dc2626" text-anchor="end">OOS PF</text>'
f'<text x="{w//2}" y="{h-4}" font-size="9" fill="#6b7280" text-anchor="middle">窗口序号 →</text>'
f'</svg>'
)
# --------------------------------------------------------------------------- #
# HTML 片段(供 run_analysis.py 嵌入)
# --------------------------------------------------------------------------- #
def wf_html_fragment(windows: List[WFWindow]) -> str:
if not windows:
return "<p class='muted'>数据不足以切出 Walk-Forward 窗口。</p>"
s = wf_summary(windows)
rows = ""
for w in windows:
oos_cls = " class='pos'" if w.oos_metrics["net"] > 0 else " class='neg'"
rows += (
f"<tr><td>#{w.idx}</td>"
f"<td>{w.is_start:%Y-%m-%d}~{w.is_end:%Y-%m-%d}</td>"
f"<td>{w.oos_start:%Y-%m-%d}~{w.oos_end:%Y-%m-%d}</td>"
f"<td>{w.is_metrics['n']}</td>"
f"<td>{w.is_metrics['net']:+.2f}</td>"
f"<td>{w.is_metrics['pf']:.2f}</td>"
f"<td>{w.is_metrics['win']:.1f}%</td>"
f"<td>{w.oos_metrics['n']}</td>"
f"<td{oos_cls}>{w.oos_metrics['net']:+.2f}</td>"
f"<td>{w.oos_metrics['pf']:.2f}</td>"
f"<td>{w.oos_metrics['win']:.1f}%</td>"
f"<td>{w.oos_metrics['dd']:.2f}</td></tr>"
)
table = (
"<table class='data'><thead><tr>"
"<th>窗口</th><th>IS区间</th><th>OOS区间</th>"
"<th>IS笔</th><th>IS净</th><th>IS PF</th><th>IS胜率</th>"
"<th>OOS笔</th><th>OOS净</th><th>OOS PF</th><th>OOS胜率</th><th>OOS回撤</th>"
"</tr></thead><tbody>" + rows + "</tbody></table>"
)
corr_str = f"{s['is_oos_pf_corr']:.2f}" if not np.isnan(s['is_oos_pf_corr']) else ""
cards = (
f"<div class='cards'>"
f"<div class='card'><div class='k'>窗口数</div><div class='v'>{s['n_windows']}</div></div>"
f"<div class='card'><div class='k'>WFE (ΣOOS净/ΣIS净)</div><div class='v {'pos' if s['wfe']>0 else 'neg'}'>{s['wfe']:+.2f}</div></div>"
f"<div class='card'><div class='k'>OOS 盈利窗口占比</div><div class='v'>{s['oos_profitable_pct']:.0f}%</div></div>"
f"<div class='card'><div class='k'>IS-OOS PF 相关性</div><div class='v'>{corr_str}</div></div>"
f"</div>"
)
interp = ""
if not np.isnan(s['is_oos_pf_corr']):
if s['is_oos_pf_corr'] > 0.6:
interp = "<p class='muted'>IS 与 OOS 的 PF 相关性较高 → 参数对行情段有一定泛化能力。</p>"
elif s['is_oos_pf_corr'] < 0.2:
interp = "<p class='muted'>IS 与 OOS 的 PF 相关性低 → IS 表现难以预测 OOS,参数不稳健,警惕过拟合单段行情。</p>"
else:
interp = "<p class='muted'>IS/OOS PF 中等相关性,泛化能力中等。</p>"
if s['wfe'] < 0:
interp += "<div class='warn'>WFE 为负:OOS 累计亏损。即使 IS 段盈利,策略也未能泛化。</div>"
elif s['wfe'] > 0.5:
interp += "<div class='note'>WFE &gt; 0.5OOS 能保留 IS 的一半以上盈利,泛化较好。</div>"
return cards + svg_wf_curve(windows) + table + interp
# --------------------------------------------------------------------------- #
# 独立 HTML 报告
# --------------------------------------------------------------------------- #
def build_standalone_html(name: str, windows: List[WFWindow]) -> str:
frag = wf_html_fragment(windows)
return f"""<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
<title>Walk-Forward 报告 {html.escape(name)}</title>
<style>
body {{ font-family: -apple-system, "Microsoft YaHei", sans-serif; background:#f8fafc; color:#111827; margin:0; padding:20px;}}
.wrap {{ max-width:1100px; margin:0 auto;}}
h1 {{ color:#1e3a8a; border-bottom:3px solid #1e3a8a; padding-bottom:8px;}}
table.data {{ border-collapse:collapse; width:100%; background:#fff; margin:8px 0 16px; font-size:12px;}}
table.data th, table.data td {{ border:1px solid #e2e8f0; padding:5px 7px; text-align:left;}}
table.data th {{ background:#f1f5f9;}}
td.pos, tr.pos td {{ color:#16a34a;}} td.neg, tr.neg td {{ color:#dc2626;}}
.muted {{ color:#6b7280; font-size:12px;}}
.cards {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(200px,1fr)); gap:12px; margin:16px 0;}}
.card {{ background:#fff; padding:14px; border-radius:8px; border:1px solid #e2e8f0;}}
.card .k {{ color:#6b7280; font-size:12px;}} .card .v {{ font-size:20px; font-weight:600; margin-top:4px;}}
.card .v.pos {{ color:#16a34a;}} .card .v.neg {{ color:#dc2626;}}
.chart {{ width:100%; height:auto; background:#fff; border:1px solid #e2e8f0; border-radius:6px;}}
.note {{ background:#fef3c7; border-left:4px solid #f59e0b; padding:10px 14px; margin:12px 0; border-radius:4px;}}
.warn {{ background:#fee2e2; border-left:4px solid #dc2626; padding:10px 14px; margin:12px 0; border-radius:4px;}}
</style></head><body><div class="wrap">
<h1>Walk-Forward 滚动验证报告</h1>
<p class='muted'>来源: {html.escape(name)}</p>
{frag}
</div></body></html>"""
# --------------------------------------------------------------------------- #
# demo
# --------------------------------------------------------------------------- #
def _demo_trades() -> pd.DataFrame:
rng = np.random.default_rng(7)
n = 1200
start = pd.Timestamp("2024-01-01")
times = start + pd.to_timedelta(np.arange(n) * 3, unit="h")
# 让 PF 随时间漂移,模拟参数不稳健
drift = np.sin(np.arange(n) / 200) * 1.5
base = rng.normal(-0.1 + drift * 0.1, 3, n)
return pd.DataFrame({"open_time": times, "net_profit": base})
# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #
def main(argv: List[str]) -> int:
ap = argparse.ArgumentParser(description="Walk-Forward 滚动验证")
ap.add_argument("report", nargs="?", help="MT5 回测报告 xlsx 路径")
ap.add_argument("--is-days", type=int, default=60)
ap.add_argument("--oos-days", type=int, default=30)
ap.add_argument("--step-days", type=int, default=None)
ap.add_argument("--demo", action="store_true")
args = ap.parse_args(argv)
os.makedirs(OUT_DIR, exist_ok=True)
if args.demo:
trades = _demo_trades()
name = "demo"
else:
if not args.report:
ap.error("需要报告路径或 --demo")
rep = mp.parse_report(args.report)
trades = rep.trades
name = os.path.basename(args.report)
windows = walk_forward(trades, args.is_days, args.oos_days, args.step_days)
if not windows:
print("数据不足以切出窗口,试试更小的 is-days/oos-days")
return 1
s = wf_summary(windows)
print(f"窗口数: {s['n_windows']} WFE: {s['wfe']:+.3f} "
f"OOS盈利窗口: {s['oos_profitable_pct']:.0f}% "
f"IS-OOS PF 相关性: {s['is_oos_pf_corr']}")
html_doc = build_standalone_html(name, windows)
out = os.path.join(OUT_DIR, "walk_forward.html")
with open(out, "w", encoding="utf-8") as f:
f.write(html_doc)
print(f"HTML 报告: {out}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))