commit ed8e8afb4494d6fe0730b50e349b8fa64000b925 Author: gavindiaz Date: Sun Jun 28 02:31:54 2026 +0800 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,无图片依赖)。 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..501659b --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..77d4b0b --- /dev/null +++ b/README.md @@ -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 +- 分析 CSV:MAE/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 diff --git a/mae_mfe.py b/mae_mfe.py new file mode 100644 index 0000000..6d68ed3 --- /dev/null +++ b/mae_mfe.py @@ -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 + 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 +#include + +// 在 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'{xlabel}={xi:.1f}, {ylabel}={yi:.2f}, profit={pi:.2f}' + + thr = "" + if threshold is not None and x_min <= threshold <= x_max: + tx = sx(threshold) + thr = (f'' + f'{html.escape(threshold_label)}={threshold:.1f}') + + 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'' + f'{xv:.0f}' + f'' + f'{yv:.0f}') + + return (f'' + f'{html.escape(title)}' + f'{grid}{pts}{thr}' + f'{html.escape(xlabel)} →' + f'{html.escape(ylabel)} →' + f'') + + +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'' + # 最优点 + 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'' + + xlabs = "".join(f'{xs[i]:.0f}' for i in range(0, nx, 2)) + ylabs = "".join(f'{ys[j]:.0f}' for j in range(0, ny, 2)) + return (f'' + f'理论净盈利 (TP × SL 扫描) — 圈=最优' + f'{cells}{marker}{xlabs}{ylabs}' + f'TP(点) →' + f'SL(点) →' + f'') + + +# =========================================================================== # +# 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 = ( + "
" + f"
样本笔数
{res['n']}
" + f"
最优 TP(点)
{b['tp']:.0f}
" + f"
最优 SL(点)
{b['sl']:.0f}
" + f"
理论净盈利(点)
{b['net']:.0f}
" + f"
理论胜率
{b['win_rate']:.1f}%
" + f"
理论 PF
{b['pf']:.2f}
" + "
" + ) + + interp = "" + if res["profit_corr_mae"] < -0.3: + interp += f"

盈亏与 MAE 负相关 ({res['profit_corr_mae']:.2f}):逆向偏移越大越易亏,设 SL 合理。SL 候选 = 亏损笔 MAE 75 分位 ≈ {res['mae_q3_of_losers']:.0f} 点

" + if res["profit_corr_mfe"] > 0.3: + interp += f"

盈亏与 MFE 正相关 ({res['profit_corr_mfe']:.2f}):能跑到 X 点的笔更易盈利,TP 候选 = 盈利笔 MFE 中位 ≈ {res['mfe_median_of_winners']:.0f} 点

" + if not interp: + interp = "

MAE/MFE 与盈亏相关性弱,TP/SL 优化空间有限,建议关注入场逻辑。

" + + interp += (f"
理论最优 TP/SL:TP={b['tp']:.0f}点 / SL={b['sl']:.0f}点," + f"在该组合下重算净盈利 {b['net']:.0f}点、胜率 {b['win_rate']:.1f}%、PF {b['pf']:.2f}。" + f"此为基于历史 MAE/MFE 的理论值,需回测验证。
") + + return f""" +MAE/MFE 分析 +
+

MAE / MFE 分析报告

+

样本: {res['n']} 笔 · 盈亏-MAE 相关 {res['profit_corr_mae']:.2f} · 盈亏-MFE 相关 {res['profit_corr_mfe']:.2f}

+{cards} +{interp} +

1. MAE / MFE 散点

+
{mae_svg}
{mfe_svg}
+

蓝虚线 = 推荐的 SL/TP 候选阈值。MAE 图:右侧红点=逆向走很远还亏=该早止损;MFE 图:右侧绿点=涨得高最终赚=TP 可放到此处。

+

2. TP × SL 扫描热力图

+{tpsl_svg} +

圈=理论最优组合。色越绿=净盈利越高。注意"宽绿区"比"单点深绿"更稳健。

+

3. 如何获取 MAE/MFE 数据

+

MT5 标准 xlsx 报告不含逐笔 MAE/MFE。需在 EA 的 OnDeinit 里加如下代码导出 CSV,再喂给本工具:

+
{html.escape(mql5_snippet())}
+
由 mae_mfe.py 生成 · {pd.Timestamp.now().strftime('%Y-%m-%d %H:%M')}
+
""" + + +# =========================================================================== # +# 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:])) diff --git a/mt5_report_parser.py b/mt5_report_parser.py new file mode 100644 index 0000000..e9d4a95 --- /dev/null +++ b/mt5_report_parser.py @@ -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() diff --git a/output/mae_mfe.html b/output/mae_mfe.html new file mode 100644 index 0000000..d3d2034 --- /dev/null +++ b/output/mae_mfe.html @@ -0,0 +1,109 @@ + +MAE/MFE 分析 +
+

MAE / MFE 分析报告

+

样本: 400 笔 · 盈亏-MAE 相关 -0.37 · 盈亏-MFE 相关 0.72

+
样本笔数
400
最优 TP(点)
36
最优 SL(点)
0
理论净盈利(点)
8385
理论胜率
79.8%
理论 PF
14.47
+

盈亏与 MAE 负相关 (-0.37):逆向偏移越大越易亏,设 SL 合理。SL 候选 = 亏损笔 MAE 75 分位 ≈ 38 点

盈亏与 MFE 正相关 (0.72):能跑到 X 点的笔更易盈利,TP 候选 = 盈利笔 MFE 中位 ≈ 45 点

理论最优 TP/SL:TP=36点 / SL=0点,在该组合下重算净盈利 8385点、胜率 79.8%、PF 14.47。此为基于历史 MAE/MFE 的理论值,需回测验证。
+

1. MAE / MFE 散点

+
MAE vs 盈亏(绿=赢/红=输)0-163236421MAE(点)=22.2, 盈利($)=9.91, profit=9.91MAE(点)=35.5, 盈利($)=3.24, profit=3.24MAE(点)=22.5, 盈利($)=12.02, profit=12.02MAE(点)=29.4, 盈利($)=-9.51, profit=-9.51MAE(点)=44.5, 盈利($)=-13.31, profit=-13.31MAE(点)=24.1, 盈利($)=6.19, profit=6.19MAE(点)=22.9, 盈利($)=-5.71, profit=-5.71MAE(点)=15.9, 盈利($)=4.22, profit=4.22MAE(点)=4.1, 盈利($)=11.40, profit=11.40MAE(点)=31.4, 盈利($)=2.43, profit=2.43MAE(点)=6.3, 盈利($)=11.21, profit=11.21MAE(点)=29.8, 盈利($)=6.12, profit=6.12MAE(点)=19.8, 盈利($)=5.41, profit=5.41MAE(点)=36.9, 盈利($)=-8.47, profit=-8.47MAE(点)=19.0, 盈利($)=9.19, profit=9.19MAE(点)=7.7, 盈利($)=11.35, profit=11.35MAE(点)=25.4, 盈利($)=-4.54, profit=-4.54MAE(点)=11.6, 盈利($)=12.65, profit=12.65MAE(点)=17.3, 盈利($)=13.24, profit=13.24MAE(点)=20.6, 盈利($)=8.76, profit=8.76MAE(点)=18.0, 盈利($)=13.22, profit=13.22MAE(点)=17.2, 盈利($)=17.69, profit=17.69MAE(点)=7.5, 盈利($)=13.53, profit=13.53MAE(点)=17.2, 盈利($)=-4.52, profit=-4.52MAE(点)=12.1, 盈利($)=8.39, profit=8.39MAE(点)=17.6, 盈利($)=8.20, profit=8.20MAE(点)=2.0, 盈利($)=7.34, profit=7.34MAE(点)=36.8, 盈利($)=-9.55, profit=-9.55MAE(点)=39.0, 盈利($)=-11.36, profit=-11.36MAE(点)=9.3, 盈利($)=3.73, profit=3.73MAE(点)=22.2, 盈利($)=9.43, profit=9.43MAE(点)=18.1, 盈利($)=8.91, profit=8.91MAE(点)=4.3, 盈利($)=13.29, profit=13.29MAE(点)=28.0, 盈利($)=-6.92, profit=-6.92MAE(点)=13.1, 盈利($)=-4.95, profit=-4.95MAE(点)=6.5, 盈利($)=11.35, profit=11.35MAE(点)=16.0, 盈利($)=14.81, profit=14.81MAE(点)=17.8, 盈利($)=3.69, profit=3.69MAE(点)=21.6, 盈利($)=15.05, profit=15.05MAE(点)=1.5, 盈利($)=10.17, profit=10.17MAE(点)=29.2, 盈利($)=9.16, profit=9.16MAE(点)=31.0, 盈利($)=11.82, profit=11.82MAE(点)=2.8, 盈利($)=2.14, profit=2.14MAE(点)=10.1, 盈利($)=13.59, profit=13.59MAE(点)=18.8, 盈利($)=2.39, profit=2.39MAE(点)=11.5, 盈利($)=8.31, profit=8.31MAE(点)=46.2, 盈利($)=-13.75, profit=-13.75MAE(点)=23.1, 盈利($)=2.60, profit=2.60MAE(点)=4.8, 盈利($)=8.42, profit=8.42MAE(点)=8.2, 盈利($)=8.25, profit=8.25MAE(点)=19.1, 盈利($)=10.07, profit=10.07MAE(点)=54.4, 盈利($)=-12.76, profit=-12.76MAE(点)=17.3, 盈利($)=10.65, profit=10.65MAE(点)=21.9, 盈利($)=-6.22, profit=-6.22MAE(点)=27.7, 盈利($)=6.38, profit=6.38MAE(点)=19.4, 盈利($)=8.39, profit=8.39MAE(点)=54.2, 盈利($)=-11.48, profit=-11.48MAE(点)=12.0, 盈利($)=-6.12, profit=-6.12MAE(点)=31.2, 盈利($)=8.38, profit=8.38MAE(点)=22.4, 盈利($)=5.15, profit=5.15MAE(点)=29.3, 盈利($)=-6.65, profit=-6.65MAE(点)=2.4, 盈利($)=15.04, profit=15.04MAE(点)=46.5, 盈利($)=9.78, profit=9.78MAE(点)=18.2, 盈利($)=13.26, profit=13.26MAE(点)=20.5, 盈利($)=10.19, profit=10.19MAE(点)=6.0, 盈利($)=6.97, profit=6.97MAE(点)=9.1, 盈利($)=3.35, profit=3.35MAE(点)=28.5, 盈利($)=-8.84, profit=-8.84MAE(点)=34.8, 盈利($)=-10.06, profit=-10.06MAE(点)=31.3, 盈利($)=-10.05, profit=-10.05MAE(点)=38.1, 盈利($)=11.31, profit=11.31MAE(点)=30.7, 盈利($)=16.85, profit=16.85MAE(点)=20.4, 盈利($)=12.73, profit=12.73MAE(点)=32.5, 盈利($)=4.55, profit=4.55MAE(点)=28.9, 盈利($)=7.74, profit=7.74MAE(点)=18.5, 盈利($)=5.73, profit=5.73MAE(点)=30.9, 盈利($)=10.82, profit=10.82MAE(点)=39.3, 盈利($)=-13.62, profit=-13.62MAE(点)=23.5, 盈利($)=7.99, profit=7.99MAE(点)=14.7, 盈利($)=17.62, profit=17.62MAE(点)=30.8, 盈利($)=9.91, profit=9.91MAE(点)=48.5, 盈利($)=20.62, profit=20.62MAE(点)=16.8, 盈利($)=8.78, profit=8.78MAE(点)=18.6, 盈利($)=4.62, profit=4.62MAE(点)=18.0, 盈利($)=12.37, profit=12.37MAE(点)=38.3, 盈利($)=18.07, profit=18.07MAE(点)=7.6, 盈利($)=3.90, profit=3.90MAE(点)=25.5, 盈利($)=9.85, profit=9.85MAE(点)=37.9, 盈利($)=-8.59, profit=-8.59MAE(点)=7.8, 盈利($)=10.13, profit=10.13MAE(点)=42.3, 盈利($)=12.62, profit=12.62MAE(点)=28.0, 盈利($)=6.86, profit=6.86MAE(点)=11.8, 盈利($)=6.78, profit=6.78MAE(点)=23.1, 盈利($)=2.51, profit=2.51MAE(点)=3.0, 盈利($)=11.42, profit=11.42MAE(点)=11.5, 盈利($)=11.08, profit=11.08MAE(点)=47.4, 盈利($)=14.34, profit=14.34MAE(点)=6.6, 盈利($)=8.83, profit=8.83MAE(点)=47.7, 盈利($)=-11.03, profit=-11.03MAE(点)=18.8, 盈利($)=13.76, profit=13.76MAE(点)=34.9, 盈利($)=8.04, profit=8.04MAE(点)=20.7, 盈利($)=7.49, profit=7.49MAE(点)=13.6, 盈利($)=11.29, profit=11.29MAE(点)=11.4, 盈利($)=2.47, profit=2.47MAE(点)=22.8, 盈利($)=-6.76, profit=-6.76MAE(点)=2.7, 盈利($)=5.79, profit=5.79MAE(点)=0.2, 盈利($)=11.72, profit=11.72MAE(点)=25.5, 盈利($)=12.84, profit=12.84MAE(点)=10.3, 盈利($)=8.58, profit=8.58MAE(点)=5.4, 盈利($)=0.96, profit=0.96MAE(点)=9.7, 盈利($)=8.22, profit=8.22MAE(点)=32.9, 盈利($)=-13.46, profit=-13.46MAE(点)=12.9, 盈利($)=7.86, profit=7.86MAE(点)=34.0, 盈利($)=-6.91, profit=-6.91MAE(点)=42.9, 盈利($)=-9.04, profit=-9.04MAE(点)=20.5, 盈利($)=9.21, profit=9.21MAE(点)=3.1, 盈利($)=12.52, profit=12.52MAE(点)=0.5, 盈利($)=6.37, profit=6.37MAE(点)=19.6, 盈利($)=-5.33, profit=-5.33MAE(点)=33.2, 盈利($)=15.39, profit=15.39MAE(点)=24.0, 盈利($)=3.63, profit=3.63MAE(点)=28.2, 盈利($)=8.19, profit=8.19MAE(点)=13.3, 盈利($)=18.44, profit=18.44MAE(点)=24.5, 盈利($)=-6.17, profit=-6.17MAE(点)=12.9, 盈利($)=11.75, profit=11.75MAE(点)=15.1, 盈利($)=1.96, profit=1.96MAE(点)=0.6, 盈利($)=13.06, profit=13.06MAE(点)=3.6, 盈利($)=6.30, profit=6.30MAE(点)=12.8, 盈利($)=9.26, profit=9.26MAE(点)=3.4, 盈利($)=10.71, profit=10.71MAE(点)=5.6, 盈利($)=2.99, profit=2.99MAE(点)=30.1, 盈利($)=11.07, profit=11.07MAE(点)=18.4, 盈利($)=-4.75, profit=-4.75MAE(点)=63.7, 盈利($)=-15.53, profit=-15.53MAE(点)=33.9, 盈利($)=7.27, profit=7.27MAE(点)=10.5, 盈利($)=-2.53, profit=-2.53MAE(点)=31.7, 盈利($)=11.26, profit=11.26MAE(点)=25.4, 盈利($)=14.16, profit=14.16MAE(点)=9.7, 盈利($)=7.10, profit=7.10MAE(点)=36.5, 盈利($)=-7.78, profit=-7.78MAE(点)=8.4, 盈利($)=7.81, profit=7.81MAE(点)=25.9, 盈利($)=4.46, profit=4.46MAE(点)=31.9, 盈利($)=8.17, profit=8.17MAE(点)=10.7, 盈利($)=10.41, profit=10.41MAE(点)=42.3, 盈利($)=-7.88, profit=-7.88MAE(点)=10.3, 盈利($)=-2.38, profit=-2.38MAE(点)=2.3, 盈利($)=5.39, profit=5.39MAE(点)=43.2, 盈利($)=9.65, profit=9.65MAE(点)=3.9, 盈利($)=1.16, profit=1.16MAE(点)=23.1, 盈利($)=8.72, profit=8.72MAE(点)=41.7, 盈利($)=11.06, profit=11.06MAE(点)=21.7, 盈利($)=-3.55, profit=-3.55MAE(点)=18.8, 盈利($)=-5.99, profit=-5.99MAE(点)=21.5, 盈利($)=-4.74, profit=-4.74MAE(点)=32.6, 盈利($)=9.88, profit=9.88MAE(点)=7.9, 盈利($)=1.85, profit=1.85MAE(点)=29.2, 盈利($)=6.08, profit=6.08MAE(点)=28.7, 盈利($)=-4.98, profit=-4.98MAE(点)=55.0, 盈利($)=-15.74, profit=-15.74MAE(点)=15.0, 盈利($)=13.23, profit=13.23MAE(点)=6.3, 盈利($)=9.93, profit=9.93MAE(点)=31.0, 盈利($)=13.45, profit=13.45MAE(点)=14.0, 盈利($)=6.71, profit=6.71MAE(点)=24.3, 盈利($)=-4.95, profit=-4.95MAE(点)=22.0, 盈利($)=4.93, profit=4.93MAE(点)=37.5, 盈利($)=-11.84, profit=-11.84MAE(点)=10.1, 盈利($)=8.06, profit=8.06MAE(点)=18.6, 盈利($)=17.56, profit=17.56MAE(点)=18.3, 盈利($)=9.78, profit=9.78MAE(点)=6.6, 盈利($)=6.92, profit=6.92MAE(点)=43.0, 盈利($)=16.30, profit=16.30MAE(点)=9.8, 盈利($)=4.86, profit=4.86MAE(点)=9.5, 盈利($)=8.03, profit=8.03MAE(点)=1.7, 盈利($)=6.20, profit=6.20MAE(点)=28.7, 盈利($)=-3.94, profit=-3.94MAE(点)=22.7, 盈利($)=9.68, profit=9.68MAE(点)=2.2, 盈利($)=15.12, profit=15.12MAE(点)=30.0, 盈利($)=-8.62, profit=-8.62MAE(点)=23.9, 盈利($)=-7.49, profit=-7.49MAE(点)=31.3, 盈利($)=3.82, profit=3.82MAE(点)=10.4, 盈利($)=5.57, profit=5.57MAE(点)=46.1, 盈利($)=-9.49, profit=-9.49MAE(点)=26.1, 盈利($)=11.47, profit=11.47MAE(点)=18.2, 盈利($)=9.57, profit=9.57MAE(点)=12.2, 盈利($)=10.94, profit=10.94MAE(点)=16.3, 盈利($)=7.92, profit=7.92MAE(点)=32.9, 盈利($)=-8.83, profit=-8.83MAE(点)=16.0, 盈利($)=-5.94, profit=-5.94MAE(点)=36.2, 盈利($)=9.94, profit=9.94MAE(点)=28.7, 盈利($)=12.52, profit=12.52MAE(点)=40.2, 盈利($)=-9.86, profit=-9.86MAE(点)=32.2, 盈利($)=8.05, profit=8.05MAE(点)=19.1, 盈利($)=7.09, profit=7.09MAE(点)=8.6, 盈利($)=8.25, profit=8.25MAE(点)=24.5, 盈利($)=8.60, profit=8.60MAE(点)=31.8, 盈利($)=-9.13, profit=-9.13MAE(点)=22.4, 盈利($)=-5.10, profit=-5.10MAE(点)=28.3, 盈利($)=-9.47, profit=-9.47MAE(点)=12.9, 盈利($)=8.28, profit=8.28MAE(点)=14.3, 盈利($)=-2.53, profit=-2.53MAE(点)=5.9, 盈利($)=6.49, profit=6.49MAE(点)=40.4, 盈利($)=-7.99, profit=-7.99MAE(点)=22.4, 盈利($)=-5.19, profit=-5.19MAE(点)=12.6, 盈利($)=7.67, profit=7.67MAE(点)=0.0, 盈利($)=7.23, profit=7.23MAE(点)=17.7, 盈利($)=9.06, profit=9.06MAE(点)=41.6, 盈利($)=9.36, profit=9.36MAE(点)=26.9, 盈利($)=11.61, profit=11.61MAE(点)=9.1, 盈利($)=7.61, profit=7.61MAE(点)=41.4, 盈利($)=-11.86, profit=-11.86MAE(点)=41.4, 盈利($)=11.01, profit=11.01MAE(点)=34.7, 盈利($)=8.77, profit=8.77MAE(点)=31.8, 盈利($)=9.43, profit=9.43MAE(点)=37.3, 盈利($)=13.90, profit=13.90MAE(点)=28.1, 盈利($)=4.11, profit=4.11MAE(点)=31.6, 盈利($)=11.71, profit=11.71MAE(点)=8.3, 盈利($)=1.12, profit=1.12MAE(点)=42.5, 盈利($)=-12.55, profit=-12.55MAE(点)=23.5, 盈利($)=1.98, profit=1.98MAE(点)=50.4, 盈利($)=-10.79, profit=-10.79MAE(点)=10.6, 盈利($)=1.00, profit=1.00MAE(点)=30.6, 盈利($)=7.80, profit=7.80MAE(点)=34.7, 盈利($)=-7.11, profit=-7.11MAE(点)=14.9, 盈利($)=12.17, profit=12.17MAE(点)=26.8, 盈利($)=16.98, profit=16.98MAE(点)=31.2, 盈利($)=-9.10, profit=-9.10MAE(点)=28.7, 盈利($)=15.46, profit=15.46MAE(点)=11.9, 盈利($)=12.06, profit=12.06MAE(点)=17.9, 盈利($)=21.15, profit=21.15MAE(点)=35.3, 盈利($)=9.87, profit=9.87MAE(点)=39.2, 盈利($)=-6.92, profit=-6.92MAE(点)=21.5, 盈利($)=14.00, profit=14.00MAE(点)=21.2, 盈利($)=-5.07, profit=-5.07MAE(点)=23.0, 盈利($)=-2.60, profit=-2.60MAE(点)=35.6, 盈利($)=-7.71, profit=-7.71MAE(点)=4.2, 盈利($)=4.35, profit=4.35MAE(点)=0.1, 盈利($)=8.77, profit=8.77MAE(点)=21.9, 盈利($)=-1.20, profit=-1.20MAE(点)=3.4, 盈利($)=18.73, profit=18.73MAE(点)=11.2, 盈利($)=9.01, profit=9.01MAE(点)=21.3, 盈利($)=-1.58, profit=-1.58MAE(点)=27.2, 盈利($)=-5.91, profit=-5.91MAE(点)=3.9, 盈利($)=6.72, profit=6.72MAE(点)=32.0, 盈利($)=-8.20, profit=-8.20MAE(点)=6.6, 盈利($)=9.04, profit=9.04MAE(点)=28.2, 盈利($)=-9.23, profit=-9.23MAE(点)=3.7, 盈利($)=3.50, profit=3.50MAE(点)=0.9, 盈利($)=4.33, profit=4.33MAE(点)=29.6, 盈利($)=10.62, profit=10.62MAE(点)=13.9, 盈利($)=15.58, profit=15.58MAE(点)=8.1, 盈利($)=15.50, profit=15.50MAE(点)=13.3, 盈利($)=8.92, profit=8.92MAE(点)=27.0, 盈利($)=10.29, profit=10.29MAE(点)=19.2, 盈利($)=1.58, profit=1.58MAE(点)=19.6, 盈利($)=10.51, profit=10.51MAE(点)=35.8, 盈利($)=-8.61, profit=-8.61MAE(点)=9.3, 盈利($)=11.69, profit=11.69MAE(点)=12.9, 盈利($)=5.96, profit=5.96MAE(点)=12.2, 盈利($)=-3.35, profit=-3.35MAE(点)=37.5, 盈利($)=7.62, profit=7.62MAE(点)=34.9, 盈利($)=5.65, profit=5.65MAE(点)=39.3, 盈利($)=-8.65, profit=-8.65MAE(点)=19.5, 盈利($)=5.81, profit=5.81MAE(点)=17.6, 盈利($)=-5.83, profit=-5.83MAE(点)=32.8, 盈利($)=-7.37, profit=-7.37MAE(点)=14.1, 盈利($)=2.62, profit=2.62MAE(点)=32.5, 盈利($)=7.36, profit=7.36MAE(点)=30.9, 盈利($)=10.91, profit=10.91MAE(点)=9.5, 盈利($)=15.18, profit=15.18MAE(点)=18.8, 盈利($)=12.14, profit=12.14MAE(点)=18.2, 盈利($)=-3.00, profit=-3.00MAE(点)=46.0, 盈利($)=-10.16, profit=-10.16MAE(点)=58.8, 盈利($)=-14.26, profit=-14.26MAE(点)=18.5, 盈利($)=17.35, profit=17.35MAE(点)=20.2, 盈利($)=8.60, profit=8.60MAE(点)=14.2, 盈利($)=13.05, profit=13.05MAE(点)=23.2, 盈利($)=5.68, profit=5.68MAE(点)=19.9, 盈利($)=1.89, profit=1.89MAE(点)=3.9, 盈利($)=6.17, profit=6.17MAE(点)=31.8, 盈利($)=4.60, profit=4.60MAE(点)=3.5, 盈利($)=13.88, profit=13.88MAE(点)=16.8, 盈利($)=-1.17, profit=-1.17MAE(点)=22.1, 盈利($)=7.90, profit=7.90MAE(点)=1.3, 盈利($)=5.48, profit=5.48MAE(点)=32.9, 盈利($)=12.49, profit=12.49MAE(点)=35.8, 盈利($)=3.73, profit=3.73MAE(点)=0.8, 盈利($)=9.15, profit=9.15MAE(点)=1.1, 盈利($)=3.62, profit=3.62MAE(点)=29.0, 盈利($)=11.65, profit=11.65MAE(点)=19.5, 盈利($)=-10.38, profit=-10.38MAE(点)=1.8, 盈利($)=8.02, profit=8.02MAE(点)=15.6, 盈利($)=-3.74, profit=-3.74MAE(点)=18.4, 盈利($)=-1.32, profit=-1.32MAE(点)=17.9, 盈利($)=11.88, profit=11.88MAE(点)=32.4, 盈利($)=-7.40, profit=-7.40MAE(点)=25.8, 盈利($)=-6.26, profit=-6.26MAE(点)=46.5, 盈利($)=10.98, profit=10.98MAE(点)=40.6, 盈利($)=16.89, profit=16.89MAE(点)=30.2, 盈利($)=-2.98, profit=-2.98MAE(点)=36.2, 盈利($)=13.93, profit=13.93MAE(点)=39.9, 盈利($)=10.26, profit=10.26MAE(点)=30.6, 盈利($)=13.76, profit=13.76MAE(点)=1.6, 盈利($)=10.90, profit=10.90MAE(点)=32.9, 盈利($)=15.76, profit=15.76MAE(点)=21.1, 盈利($)=10.03, profit=10.03MAE(点)=44.3, 盈利($)=15.18, profit=15.18MAE(点)=21.4, 盈利($)=1.89, profit=1.89MAE(点)=27.9, 盈利($)=12.36, profit=12.36MAE(点)=24.9, 盈利($)=6.29, profit=6.29MAE(点)=45.2, 盈利($)=-13.27, profit=-13.27MAE(点)=22.7, 盈利($)=10.29, profit=10.29MAE(点)=16.0, 盈利($)=6.49, profit=6.49MAE(点)=24.6, 盈利($)=7.20, profit=7.20MAE(点)=13.4, 盈利($)=3.59, profit=3.59MAE(点)=22.4, 盈利($)=11.19, profit=11.19MAE(点)=19.9, 盈利($)=6.99, profit=6.99MAE(点)=24.1, 盈利($)=0.83, profit=0.83MAE(点)=48.8, 盈利($)=7.09, profit=7.09MAE(点)=13.9, 盈利($)=10.73, profit=10.73MAE(点)=20.6, 盈利($)=-5.68, profit=-5.68MAE(点)=17.0, 盈利($)=9.85, profit=9.85MAE(点)=30.1, 盈利($)=11.57, profit=11.57MAE(点)=2.3, 盈利($)=14.85, profit=14.85MAE(点)=9.6, 盈利($)=7.61, profit=7.61MAE(点)=19.4, 盈利($)=3.74, profit=3.74MAE(点)=28.2, 盈利($)=9.26, profit=9.26MAE(点)=19.3, 盈利($)=-4.20, profit=-4.20MAE(点)=12.5, 盈利($)=1.94, profit=1.94MAE(点)=7.0, 盈利($)=4.72, profit=4.72MAE(点)=27.7, 盈利($)=-7.86, profit=-7.86MAE(点)=41.6, 盈利($)=16.28, profit=16.28MAE(点)=24.8, 盈利($)=7.29, profit=7.29MAE(点)=22.8, 盈利($)=-4.80, profit=-4.80MAE(点)=26.7, 盈利($)=4.28, profit=4.28MAE(点)=5.9, 盈利($)=9.94, profit=9.94MAE(点)=15.3, 盈利($)=-1.35, profit=-1.35MAE(点)=16.0, 盈利($)=-3.95, profit=-3.95MAE(点)=24.4, 盈利($)=5.52, profit=5.52MAE(点)=19.5, 盈利($)=9.20, profit=9.20MAE(点)=1.8, 盈利($)=8.89, profit=8.89MAE(点)=36.9, 盈利($)=8.64, profit=8.64MAE(点)=45.4, 盈利($)=13.94, profit=13.94MAE(点)=16.1, 盈利($)=19.77, profit=19.77MAE(点)=26.0, 盈利($)=-6.03, profit=-6.03MAE(点)=24.0, 盈利($)=19.50, profit=19.50MAE(点)=31.8, 盈利($)=8.30, profit=8.30MAE(点)=2.2, 盈利($)=15.88, profit=15.88MAE(点)=44.2, 盈利($)=-11.16, profit=-11.16MAE(点)=13.0, 盈利($)=7.99, profit=7.99MAE(点)=39.0, 盈利($)=6.70, profit=6.70MAE(点)=21.2, 盈利($)=9.80, profit=9.80MAE(点)=14.2, 盈利($)=13.19, profit=13.19MAE(点)=17.9, 盈利($)=7.65, profit=7.65MAE(点)=33.5, 盈利($)=-9.81, profit=-9.81MAE(点)=7.1, 盈利($)=9.11, profit=9.11MAE(点)=14.4, 盈利($)=13.27, profit=13.27MAE(点)=12.8, 盈利($)=8.41, profit=8.41MAE(点)=5.7, 盈利($)=6.14, profit=6.14MAE(点)=39.5, 盈利($)=8.84, profit=8.84MAE(点)=28.7, 盈利($)=-8.24, profit=-8.24MAE(点)=19.7, 盈利($)=-5.82, profit=-5.82MAE(点)=37.5, 盈利($)=-10.45, profit=-10.45MAE(点)=5.7, 盈利($)=11.96, profit=11.96MAE(点)=18.9, 盈利($)=6.81, profit=6.81MAE(点)=45.9, 盈利($)=9.29, profit=9.29MAE(点)=34.7, 盈利($)=-11.48, profit=-11.48MAE(点)=13.4, 盈利($)=13.69, profit=13.69MAE(点)=30.0, 盈利($)=17.52, profit=17.52MAE(点)=4.4, 盈利($)=7.83, profit=7.83MAE(点)=35.9, 盈利($)=-7.09, profit=-7.09MAE(点)=12.9, 盈利($)=4.18, profit=4.18MAE(点)=16.7, 盈利($)=18.10, profit=18.10MAE(点)=29.1, 盈利($)=10.43, profit=10.43MAE(点)=6.7, 盈利($)=8.71, profit=8.71MAE(点)=23.2, 盈利($)=12.39, profit=12.39MAE(点)=31.9, 盈利($)=8.30, profit=8.30MAE(点)=27.0, 盈利($)=8.16, profit=8.16MAE(点)=25.9, 盈利($)=8.90, profit=8.90MAE(点)=11.0, 盈利($)=4.63, profit=4.63MAE(点)=27.7, 盈利($)=-8.31, profit=-8.31MAE(点)=13.6, 盈利($)=10.12, profit=10.12MAE(点)=39.6, 盈利($)=-8.57, profit=-8.57MAE(点)=58.6, 盈利($)=-12.88, profit=-12.88MAE(点)=8.9, 盈利($)=15.73, profit=15.73MAE(点)=12.6, 盈利($)=9.91, profit=9.91MAE(点)=13.6, 盈利($)=4.05, profit=4.05MAE(点)=25.7, 盈利($)=3.43, profit=3.43MAE(点)=35.8, 盈利($)=8.77, profit=8.77MAE(点)=36.2, 盈利($)=13.38, profit=13.38MAE(点)=8.2, 盈利($)=6.15, profit=6.15MAE(点)=8.1, 盈利($)=8.95, profit=8.95MAE(点)=39.0, 盈利($)=-7.20, profit=-7.20MAE(点)=3.6, 盈利($)=10.21, profit=10.21MAE(点)=36.4, 盈利($)=-6.41, profit=-6.41MAE(点)=16.6, 盈利($)=5.30, profit=5.30MAE(点)=31.5, 盈利($)=10.23, profit=10.23MAE(点)=30.6, 盈利($)=-6.00, profit=-6.00MAE(点)=31.6, 盈利($)=20.95, profit=20.95MAE(点)=32.3, 盈利($)=-11.33, profit=-11.33MAE(点)=5.2, 盈利($)=8.94, profit=8.94SL候选=37.9MAE(点) →盈利($) →
MFE vs 盈亏(绿=赢/红=输)0-1652310421MFE(点)=46.0, 盈利($)=9.91, profit=9.91MFE(点)=40.3, 盈利($)=3.24, profit=3.24MFE(点)=46.2, 盈利($)=12.02, profit=12.02MFE(点)=64.7, 盈利($)=-9.51, profit=-9.51MFE(点)=34.6, 盈利($)=-13.31, profit=-13.31MFE(点)=25.8, 盈利($)=6.19, profit=6.19MFE(点)=25.4, 盈利($)=-5.71, profit=-5.71MFE(点)=33.8, 盈利($)=4.22, profit=4.22MFE(点)=55.1, 盈利($)=11.40, profit=11.40MFE(点)=23.7, 盈利($)=2.43, profit=2.43MFE(点)=63.2, 盈利($)=11.21, profit=11.21MFE(点)=45.8, 盈利($)=6.12, profit=6.12MFE(点)=35.5, 盈利($)=5.41, profit=5.41MFE(点)=13.4, 盈利($)=-8.47, profit=-8.47MFE(点)=44.9, 盈利($)=9.19, profit=9.19MFE(点)=53.8, 盈利($)=11.35, profit=11.35MFE(点)=4.7, 盈利($)=-4.54, profit=-4.54MFE(点)=63.8, 盈利($)=12.65, profit=12.65MFE(点)=73.0, 盈利($)=13.24, profit=13.24MFE(点)=46.8, 盈利($)=8.76, profit=8.76MFE(点)=49.8, 盈利($)=13.22, profit=13.22MFE(点)=88.3, 盈利($)=17.69, profit=17.69MFE(点)=64.6, 盈利($)=13.53, profit=13.53MFE(点)=22.0, 盈利($)=-4.52, profit=-4.52MFE(点)=39.7, 盈利($)=8.39, profit=8.39MFE(点)=36.9, 盈利($)=8.20, profit=8.20MFE(点)=37.0, 盈利($)=7.34, profit=7.34MFE(点)=7.3, 盈利($)=-9.55, profit=-9.55MFE(点)=55.1, 盈利($)=-11.36, profit=-11.36MFE(点)=27.3, 盈利($)=3.73, profit=3.73MFE(点)=43.3, 盈利($)=9.43, profit=9.43MFE(点)=37.3, 盈利($)=8.91, profit=8.91MFE(点)=57.4, 盈利($)=13.29, profit=13.29MFE(点)=34.1, 盈利($)=-6.92, profit=-6.92MFE(点)=24.1, 盈利($)=-4.95, profit=-4.95MFE(点)=50.6, 盈利($)=11.35, profit=11.35MFE(点)=68.1, 盈利($)=14.81, profit=14.81MFE(点)=33.6, 盈利($)=3.69, profit=3.69MFE(点)=67.4, 盈利($)=15.05, profit=15.05MFE(点)=45.2, 盈利($)=10.17, profit=10.17MFE(点)=29.3, 盈利($)=9.16, profit=9.16MFE(点)=70.5, 盈利($)=11.82, profit=11.82MFE(点)=23.6, 盈利($)=2.14, profit=2.14MFE(点)=60.3, 盈利($)=13.59, profit=13.59MFE(点)=30.0, 盈利($)=2.39, profit=2.39MFE(点)=49.0, 盈利($)=8.31, profit=8.31MFE(点)=15.3, 盈利($)=-13.75, profit=-13.75MFE(点)=23.2, 盈利($)=2.60, profit=2.60MFE(点)=61.3, 盈利($)=8.42, profit=8.42MFE(点)=43.8, 盈利($)=8.25, profit=8.25MFE(点)=40.8, 盈利($)=10.07, profit=10.07MFE(点)=31.2, 盈利($)=-12.76, profit=-12.76MFE(点)=48.9, 盈利($)=10.65, profit=10.65MFE(点)=12.1, 盈利($)=-6.22, profit=-6.22MFE(点)=17.6, 盈利($)=6.38, profit=6.38MFE(点)=36.8, 盈利($)=8.39, profit=8.39MFE(点)=23.3, 盈利($)=-11.48, profit=-11.48MFE(点)=26.0, 盈利($)=-6.12, profit=-6.12MFE(点)=38.6, 盈利($)=8.38, profit=8.38MFE(点)=37.2, 盈利($)=5.15, profit=5.15MFE(点)=35.5, 盈利($)=-6.65, profit=-6.65MFE(点)=73.3, 盈利($)=15.04, profit=15.04MFE(点)=60.2, 盈利($)=9.78, profit=9.78MFE(点)=64.6, 盈利($)=13.26, profit=13.26MFE(点)=39.4, 盈利($)=10.19, profit=10.19MFE(点)=29.9, 盈利($)=6.97, profit=6.97MFE(点)=10.9, 盈利($)=3.35, profit=3.35MFE(点)=33.0, 盈利($)=-8.84, profit=-8.84MFE(点)=1.7, 盈利($)=-10.06, profit=-10.06MFE(点)=28.3, 盈利($)=-10.05, profit=-10.05MFE(点)=55.3, 盈利($)=11.31, profit=11.31MFE(点)=72.4, 盈利($)=16.85, profit=16.85MFE(点)=59.3, 盈利($)=12.73, profit=12.73MFE(点)=22.8, 盈利($)=4.55, profit=4.55MFE(点)=49.9, 盈利($)=7.74, profit=7.74MFE(点)=24.2, 盈利($)=5.73, profit=5.73MFE(点)=56.5, 盈利($)=10.82, profit=10.82MFE(点)=37.8, 盈利($)=-13.62, profit=-13.62MFE(点)=57.2, 盈利($)=7.99, profit=7.99MFE(点)=91.5, 盈利($)=17.62, profit=17.62MFE(点)=58.5, 盈利($)=9.91, profit=9.91MFE(点)=86.6, 盈利($)=20.62, profit=20.62MFE(点)=43.5, 盈利($)=8.78, profit=8.78MFE(点)=33.4, 盈利($)=4.62, profit=4.62MFE(点)=66.7, 盈利($)=12.37, profit=12.37MFE(点)=74.5, 盈利($)=18.07, profit=18.07MFE(点)=28.9, 盈利($)=3.90, profit=3.90MFE(点)=41.5, 盈利($)=9.85, profit=9.85MFE(点)=11.3, 盈利($)=-8.59, profit=-8.59MFE(点)=36.0, 盈利($)=10.13, profit=10.13MFE(点)=57.8, 盈利($)=12.62, profit=12.62MFE(点)=38.3, 盈利($)=6.86, profit=6.86MFE(点)=28.6, 盈利($)=6.78, profit=6.78MFE(点)=11.9, 盈利($)=2.51, profit=2.51MFE(点)=47.4, 盈利($)=11.42, profit=11.42MFE(点)=56.0, 盈利($)=11.08, profit=11.08MFE(点)=51.9, 盈利($)=14.34, profit=14.34MFE(点)=45.1, 盈利($)=8.83, profit=8.83MFE(点)=14.5, 盈利($)=-11.03, profit=-11.03MFE(点)=75.2, 盈利($)=13.76, profit=13.76MFE(点)=38.9, 盈利($)=8.04, profit=8.04MFE(点)=37.1, 盈利($)=7.49, profit=7.49MFE(点)=54.7, 盈利($)=11.29, profit=11.29MFE(点)=3.3, 盈利($)=2.47, profit=2.47MFE(点)=38.7, 盈利($)=-6.76, profit=-6.76MFE(点)=37.3, 盈利($)=5.79, profit=5.79MFE(点)=47.4, 盈利($)=11.72, profit=11.72MFE(点)=66.9, 盈利($)=12.84, profit=12.84MFE(点)=49.4, 盈利($)=8.58, profit=8.58MFE(点)=3.4, 盈利($)=0.96, profit=0.96MFE(点)=33.2, 盈利($)=8.22, profit=8.22MFE(点)=44.1, 盈利($)=-13.46, profit=-13.46MFE(点)=42.9, 盈利($)=7.86, profit=7.86MFE(点)=14.4, 盈利($)=-6.91, profit=-6.91MFE(点)=6.7, 盈利($)=-9.04, profit=-9.04MFE(点)=51.0, 盈利($)=9.21, profit=9.21MFE(点)=46.9, 盈利($)=12.52, profit=12.52MFE(点)=39.6, 盈利($)=6.37, profit=6.37MFE(点)=5.5, 盈利($)=-5.33, profit=-5.33MFE(点)=75.5, 盈利($)=15.39, profit=15.39MFE(点)=18.0, 盈利($)=3.63, profit=3.63MFE(点)=32.7, 盈利($)=8.19, profit=8.19MFE(点)=81.8, 盈利($)=18.44, profit=18.44MFE(点)=36.9, 盈利($)=-6.17, profit=-6.17MFE(点)=44.6, 盈利($)=11.75, profit=11.75MFE(点)=16.4, 盈利($)=1.96, profit=1.96MFE(点)=67.2, 盈利($)=13.06, profit=13.06MFE(点)=14.1, 盈利($)=6.30, profit=6.30MFE(点)=45.1, 盈利($)=9.26, profit=9.26MFE(点)=42.7, 盈利($)=10.71, profit=10.71MFE(点)=19.0, 盈利($)=2.99, profit=2.99MFE(点)=57.7, 盈利($)=11.07, profit=11.07MFE(点)=11.7, 盈利($)=-4.75, profit=-4.75MFE(点)=26.3, 盈利($)=-15.53, profit=-15.53MFE(点)=39.4, 盈利($)=7.27, profit=7.27MFE(点)=31.8, 盈利($)=-2.53, profit=-2.53MFE(点)=49.4, 盈利($)=11.26, profit=11.26MFE(点)=58.5, 盈利($)=14.16, profit=14.16MFE(点)=34.1, 盈利($)=7.10, profit=7.10MFE(点)=29.2, 盈利($)=-7.78, profit=-7.78MFE(点)=29.7, 盈利($)=7.81, profit=7.81MFE(点)=32.7, 盈利($)=4.46, profit=4.46MFE(点)=56.2, 盈利($)=8.17, profit=8.17MFE(点)=35.6, 盈利($)=10.41, profit=10.41MFE(点)=48.2, 盈利($)=-7.88, profit=-7.88MFE(点)=38.2, 盈利($)=-2.38, profit=-2.38MFE(点)=23.1, 盈利($)=5.39, profit=5.39MFE(点)=57.3, 盈利($)=9.65, profit=9.65MFE(点)=19.2, 盈利($)=1.16, profit=1.16MFE(点)=40.1, 盈利($)=8.72, profit=8.72MFE(点)=69.7, 盈利($)=11.06, profit=11.06MFE(点)=18.1, 盈利($)=-3.55, profit=-3.55MFE(点)=38.9, 盈利($)=-5.99, profit=-5.99MFE(点)=8.3, 盈利($)=-4.74, profit=-4.74MFE(点)=29.8, 盈利($)=9.88, profit=9.88MFE(点)=10.1, 盈利($)=1.85, profit=1.85MFE(点)=32.8, 盈利($)=6.08, profit=6.08MFE(点)=29.2, 盈利($)=-4.98, profit=-4.98MFE(点)=42.6, 盈利($)=-15.74, profit=-15.74MFE(点)=71.7, 盈利($)=13.23, profit=13.23MFE(点)=45.7, 盈利($)=9.93, profit=9.93MFE(点)=53.2, 盈利($)=13.45, profit=13.45MFE(点)=39.0, 盈利($)=6.71, profit=6.71MFE(点)=35.1, 盈利($)=-4.95, profit=-4.95MFE(点)=23.3, 盈利($)=4.93, profit=4.93MFE(点)=4.0, 盈利($)=-11.84, profit=-11.84MFE(点)=41.4, 盈利($)=8.06, profit=8.06MFE(点)=72.6, 盈利($)=17.56, profit=17.56MFE(点)=49.5, 盈利($)=9.78, profit=9.78MFE(点)=29.3, 盈利($)=6.92, profit=6.92MFE(点)=73.0, 盈利($)=16.30, profit=16.30MFE(点)=31.3, 盈利($)=4.86, profit=4.86MFE(点)=27.6, 盈利($)=8.03, profit=8.03MFE(点)=15.7, 盈利($)=6.20, profit=6.20MFE(点)=20.8, 盈利($)=-3.94, profit=-3.94MFE(点)=35.1, 盈利($)=9.68, profit=9.68MFE(点)=80.0, 盈利($)=15.12, profit=15.12MFE(点)=35.9, 盈利($)=-8.62, profit=-8.62MFE(点)=19.4, 盈利($)=-7.49, profit=-7.49MFE(点)=27.9, 盈利($)=3.82, profit=3.82MFE(点)=35.5, 盈利($)=5.57, profit=5.57MFE(点)=5.8, 盈利($)=-9.49, profit=-9.49MFE(点)=42.5, 盈利($)=11.47, profit=11.47MFE(点)=54.0, 盈利($)=9.57, profit=9.57MFE(点)=46.4, 盈利($)=10.94, profit=10.94MFE(点)=52.0, 盈利($)=7.92, profit=7.92MFE(点)=14.2, 盈利($)=-8.83, profit=-8.83MFE(点)=6.0, 盈利($)=-5.94, profit=-5.94MFE(点)=65.7, 盈利($)=9.94, profit=9.94MFE(点)=61.9, 盈利($)=12.52, profit=12.52MFE(点)=9.2, 盈利($)=-9.86, profit=-9.86MFE(点)=44.1, 盈利($)=8.05, profit=8.05MFE(点)=40.5, 盈利($)=7.09, profit=7.09MFE(点)=50.9, 盈利($)=8.25, profit=8.25MFE(点)=44.2, 盈利($)=8.60, profit=8.60MFE(点)=30.0, 盈利($)=-9.13, profit=-9.13MFE(点)=43.9, 盈利($)=-5.10, profit=-5.10MFE(点)=43.1, 盈利($)=-9.47, profit=-9.47MFE(点)=39.0, 盈利($)=8.28, profit=8.28MFE(点)=3.0, 盈利($)=-2.53, profit=-2.53MFE(点)=19.9, 盈利($)=6.49, profit=6.49MFE(点)=24.2, 盈利($)=-7.99, profit=-7.99MFE(点)=7.3, 盈利($)=-5.19, profit=-5.19MFE(点)=45.1, 盈利($)=7.67, profit=7.67MFE(点)=54.3, 盈利($)=7.23, profit=7.23MFE(点)=45.3, 盈利($)=9.06, profit=9.06MFE(点)=32.6, 盈利($)=9.36, profit=9.36MFE(点)=41.4, 盈利($)=11.61, profit=11.61MFE(点)=63.3, 盈利($)=7.61, profit=7.61MFE(点)=25.4, 盈利($)=-11.86, profit=-11.86MFE(点)=58.4, 盈利($)=11.01, profit=11.01MFE(点)=54.2, 盈利($)=8.77, profit=8.77MFE(点)=45.2, 盈利($)=9.43, profit=9.43MFE(点)=69.0, 盈利($)=13.90, profit=13.90MFE(点)=25.6, 盈利($)=4.11, profit=4.11MFE(点)=58.9, 盈利($)=11.71, profit=11.71MFE(点)=21.4, 盈利($)=1.12, profit=1.12MFE(点)=24.7, 盈利($)=-12.55, profit=-12.55MFE(点)=18.8, 盈利($)=1.98, profit=1.98MFE(点)=48.9, 盈利($)=-10.79, profit=-10.79MFE(点)=21.2, 盈利($)=1.00, profit=1.00MFE(点)=41.0, 盈利($)=7.80, profit=7.80MFE(点)=17.3, 盈利($)=-7.11, profit=-7.11MFE(点)=61.3, 盈利($)=12.17, profit=12.17MFE(点)=76.7, 盈利($)=16.98, profit=16.98MFE(点)=44.5, 盈利($)=-9.10, profit=-9.10MFE(点)=72.9, 盈利($)=15.46, profit=15.46MFE(点)=51.2, 盈利($)=12.06, profit=12.06MFE(点)=83.4, 盈利($)=21.15, profit=21.15MFE(点)=42.0, 盈利($)=9.87, profit=9.87MFE(点)=8.6, 盈利($)=-6.92, profit=-6.92MFE(点)=69.8, 盈利($)=14.00, profit=14.00MFE(点)=37.5, 盈利($)=-5.07, profit=-5.07MFE(点)=19.3, 盈利($)=-2.60, profit=-2.60MFE(点)=26.9, 盈利($)=-7.71, profit=-7.71MFE(点)=7.5, 盈利($)=4.35, profit=4.35MFE(点)=36.6, 盈利($)=8.77, profit=8.77MFE(点)=18.6, 盈利($)=-1.20, profit=-1.20MFE(点)=69.7, 盈利($)=18.73, profit=18.73MFE(点)=53.3, 盈利($)=9.01, profit=9.01MFE(点)=6.8, 盈利($)=-1.58, profit=-1.58MFE(点)=9.8, 盈利($)=-5.91, profit=-5.91MFE(点)=28.7, 盈利($)=6.72, profit=6.72MFE(点)=1.5, 盈利($)=-8.20, profit=-8.20MFE(点)=37.8, 盈利($)=9.04, profit=9.04MFE(点)=39.1, 盈利($)=-9.23, profit=-9.23MFE(点)=24.5, 盈利($)=3.50, profit=3.50MFE(点)=29.1, 盈利($)=4.33, profit=4.33MFE(点)=50.0, 盈利($)=10.62, profit=10.62MFE(点)=68.4, 盈利($)=15.58, profit=15.58MFE(点)=69.9, 盈利($)=15.50, profit=15.50MFE(点)=40.7, 盈利($)=8.92, profit=8.92MFE(点)=57.1, 盈利($)=10.29, profit=10.29MFE(点)=25.2, 盈利($)=1.58, profit=1.58MFE(点)=48.2, 盈利($)=10.51, profit=10.51MFE(点)=20.8, 盈利($)=-8.61, profit=-8.61MFE(点)=54.6, 盈利($)=11.69, profit=11.69MFE(点)=15.2, 盈利($)=5.96, profit=5.96MFE(点)=20.7, 盈利($)=-3.35, profit=-3.35MFE(点)=39.8, 盈利($)=7.62, profit=7.62MFE(点)=51.1, 盈利($)=5.65, profit=5.65MFE(点)=11.2, 盈利($)=-8.65, profit=-8.65MFE(点)=29.3, 盈利($)=5.81, profit=5.81MFE(点)=42.7, 盈利($)=-5.83, profit=-5.83MFE(点)=44.6, 盈利($)=-7.37, profit=-7.37MFE(点)=23.2, 盈利($)=2.62, profit=2.62MFE(点)=41.9, 盈利($)=7.36, profit=7.36MFE(点)=34.7, 盈利($)=10.91, profit=10.91MFE(点)=68.1, 盈利($)=15.18, profit=15.18MFE(点)=51.1, 盈利($)=12.14, profit=12.14MFE(点)=13.5, 盈利($)=-3.00, profit=-3.00MFE(点)=4.0, 盈利($)=-10.16, profit=-10.16MFE(点)=42.0, 盈利($)=-14.26, profit=-14.26MFE(点)=63.6, 盈利($)=17.35, profit=17.35MFE(点)=43.5, 盈利($)=8.60, profit=8.60MFE(点)=57.6, 盈利($)=13.05, profit=13.05MFE(点)=30.3, 盈利($)=5.68, profit=5.68MFE(点)=12.7, 盈利($)=1.89, profit=1.89MFE(点)=55.0, 盈利($)=6.17, profit=6.17MFE(点)=42.0, 盈利($)=4.60, profit=4.60MFE(点)=53.7, 盈利($)=13.88, profit=13.88MFE(点)=12.4, 盈利($)=-1.17, profit=-1.17MFE(点)=43.6, 盈利($)=7.90, profit=7.90MFE(点)=27.5, 盈利($)=5.48, profit=5.48MFE(点)=56.1, 盈利($)=12.49, profit=12.49MFE(点)=28.4, 盈利($)=3.73, profit=3.73MFE(点)=27.9, 盈利($)=9.15, profit=9.15MFE(点)=28.3, 盈利($)=3.62, profit=3.62MFE(点)=65.3, 盈利($)=11.65, profit=11.65MFE(点)=40.6, 盈利($)=-10.38, profit=-10.38MFE(点)=24.8, 盈利($)=8.02, profit=8.02MFE(点)=19.5, 盈利($)=-3.74, profit=-3.74MFE(点)=19.1, 盈利($)=-1.32, profit=-1.32MFE(点)=69.3, 盈利($)=11.88, profit=11.88MFE(点)=3.7, 盈利($)=-7.40, profit=-7.40MFE(点)=23.0, 盈利($)=-6.26, profit=-6.26MFE(点)=52.8, 盈利($)=10.98, profit=10.98MFE(点)=63.6, 盈利($)=16.89, profit=16.89MFE(点)=37.0, 盈利($)=-2.98, profit=-2.98MFE(点)=55.7, 盈利($)=13.93, profit=13.93MFE(点)=55.2, 盈利($)=10.26, profit=10.26MFE(点)=59.7, 盈利($)=13.76, profit=13.76MFE(点)=40.9, 盈利($)=10.90, profit=10.90MFE(点)=73.1, 盈利($)=15.76, profit=15.76MFE(点)=44.3, 盈利($)=10.03, profit=10.03MFE(点)=71.0, 盈利($)=15.18, profit=15.18MFE(点)=18.3, 盈利($)=1.89, profit=1.89MFE(点)=59.2, 盈利($)=12.36, profit=12.36MFE(点)=15.3, 盈利($)=6.29, profit=6.29MFE(点)=10.5, 盈利($)=-13.27, profit=-13.27MFE(点)=50.5, 盈利($)=10.29, profit=10.29MFE(点)=24.0, 盈利($)=6.49, profit=6.49MFE(点)=49.5, 盈利($)=7.20, profit=7.20MFE(点)=19.2, 盈利($)=3.59, profit=3.59MFE(点)=72.8, 盈利($)=11.19, profit=11.19MFE(点)=40.9, 盈利($)=6.99, profit=6.99MFE(点)=18.1, 盈利($)=0.83, profit=0.83MFE(点)=44.3, 盈利($)=7.09, profit=7.09MFE(点)=58.5, 盈利($)=10.73, profit=10.73MFE(点)=25.5, 盈利($)=-5.68, profit=-5.68MFE(点)=51.2, 盈利($)=9.85, profit=9.85MFE(点)=81.9, 盈利($)=11.57, profit=11.57MFE(点)=58.3, 盈利($)=14.85, profit=14.85MFE(点)=40.8, 盈利($)=7.61, profit=7.61MFE(点)=29.6, 盈利($)=3.74, profit=3.74MFE(点)=52.6, 盈利($)=9.26, profit=9.26MFE(点)=10.7, 盈利($)=-4.20, profit=-4.20MFE(点)=19.7, 盈利($)=1.94, profit=1.94MFE(点)=27.7, 盈利($)=4.72, profit=4.72MFE(点)=19.4, 盈利($)=-7.86, profit=-7.86MFE(点)=76.0, 盈利($)=16.28, profit=16.28MFE(点)=43.9, 盈利($)=7.29, profit=7.29MFE(点)=21.4, 盈利($)=-4.80, profit=-4.80MFE(点)=17.9, 盈利($)=4.28, profit=4.28MFE(点)=51.2, 盈利($)=9.94, profit=9.94MFE(点)=24.6, 盈利($)=-1.35, profit=-1.35MFE(点)=29.6, 盈利($)=-3.95, profit=-3.95MFE(点)=39.4, 盈利($)=5.52, profit=5.52MFE(点)=43.0, 盈利($)=9.20, profit=9.20MFE(点)=43.2, 盈利($)=8.89, profit=8.89MFE(点)=43.6, 盈利($)=8.64, profit=8.64MFE(点)=67.4, 盈利($)=13.94, profit=13.94MFE(点)=75.4, 盈利($)=19.77, profit=19.77MFE(点)=8.3, 盈利($)=-6.03, profit=-6.03MFE(点)=92.9, 盈利($)=19.50, profit=19.50MFE(点)=44.0, 盈利($)=8.30, profit=8.30MFE(点)=66.3, 盈利($)=15.88, profit=15.88MFE(点)=26.6, 盈利($)=-11.16, profit=-11.16MFE(点)=47.9, 盈利($)=7.99, profit=7.99MFE(点)=45.8, 盈利($)=6.70, profit=6.70MFE(点)=43.7, 盈利($)=9.80, profit=9.80MFE(点)=58.5, 盈利($)=13.19, profit=13.19MFE(点)=54.1, 盈利($)=7.65, profit=7.65MFE(点)=21.4, 盈利($)=-9.81, profit=-9.81MFE(点)=39.1, 盈利($)=9.11, profit=9.11MFE(点)=56.1, 盈利($)=13.27, profit=13.27MFE(点)=55.2, 盈利($)=8.41, profit=8.41MFE(点)=34.8, 盈利($)=6.14, profit=6.14MFE(点)=35.9, 盈利($)=8.84, profit=8.84MFE(点)=38.4, 盈利($)=-8.24, profit=-8.24MFE(点)=19.4, 盈利($)=-5.82, profit=-5.82MFE(点)=15.8, 盈利($)=-10.45, profit=-10.45MFE(点)=72.9, 盈利($)=11.96, profit=11.96MFE(点)=33.6, 盈利($)=6.81, profit=6.81MFE(点)=43.4, 盈利($)=9.29, profit=9.29MFE(点)=26.5, 盈利($)=-11.48, profit=-11.48MFE(点)=67.5, 盈利($)=13.69, profit=13.69MFE(点)=76.6, 盈利($)=17.52, profit=17.52MFE(点)=37.1, 盈利($)=7.83, profit=7.83MFE(点)=51.8, 盈利($)=-7.09, profit=-7.09MFE(点)=26.4, 盈利($)=4.18, profit=4.18MFE(点)=76.8, 盈利($)=18.10, profit=18.10MFE(点)=60.0, 盈利($)=10.43, profit=10.43MFE(点)=24.7, 盈利($)=8.71, profit=8.71MFE(点)=66.5, 盈利($)=12.39, profit=12.39MFE(点)=48.7, 盈利($)=8.30, profit=8.30MFE(点)=49.8, 盈利($)=8.16, profit=8.16MFE(点)=48.6, 盈利($)=8.90, profit=8.90MFE(点)=28.1, 盈利($)=4.63, profit=4.63MFE(点)=30.7, 盈利($)=-8.31, profit=-8.31MFE(点)=53.9, 盈利($)=10.12, profit=10.12MFE(点)=40.5, 盈利($)=-8.57, profit=-8.57MFE(点)=22.5, 盈利($)=-12.88, profit=-12.88MFE(点)=82.1, 盈利($)=15.73, profit=15.73MFE(点)=37.2, 盈利($)=9.91, profit=9.91MFE(点)=30.2, 盈利($)=4.05, profit=4.05MFE(点)=27.5, 盈利($)=3.43, profit=3.43MFE(点)=55.2, 盈利($)=8.77, profit=8.77MFE(点)=60.9, 盈利($)=13.38, profit=13.38MFE(点)=46.3, 盈利($)=6.15, profit=6.15MFE(点)=58.8, 盈利($)=8.95, profit=8.95MFE(点)=11.4, 盈利($)=-7.20, profit=-7.20MFE(点)=33.9, 盈利($)=10.21, profit=10.21MFE(点)=32.0, 盈利($)=-6.41, profit=-6.41MFE(点)=41.5, 盈利($)=5.30, profit=5.30MFE(点)=51.1, 盈利($)=10.23, profit=10.23MFE(点)=0.5, 盈利($)=-6.00, profit=-6.00MFE(点)=103.8, 盈利($)=20.95, profit=20.95MFE(点)=23.9, 盈利($)=-11.33, profit=-11.33MFE(点)=48.2, 盈利($)=8.94, profit=8.94TP候选=45.1MFE(点) →盈利($) →
+

蓝虚线 = 推荐的 SL/TP 候选阈值。MAE 图:右侧红点=逆向走很远还亏=该早止损;MFE 图:右侧绿点=涨得高最终赚=TP 可放到此处。

+

2. TP × SL 扫描热力图

+理论净盈利 (TP × SL 扫描) — 圈=最优07142129364350576472798693100049131822263135404448535762TP(点) →SL(点) → +

圈=理论最优组合。色越绿=净盈利越高。注意"宽绿区"比"单点深绿"更稳健。

+

3. 如何获取 MAE/MFE 数据

+

MT5 标准 xlsx 报告不含逐笔 MAE/MFE。需在 EA 的 OnDeinit 里加如下代码导出 CSV,再喂给本工具:

+
// ====== 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();
+// ====== 片段结束 ======
+
+
由 mae_mfe.py 生成 · 2026-06-28 02:13
+
\ No newline at end of file diff --git a/output/optimized_peak.set b/output/optimized_peak.set new file mode 100644 index 0000000..9f93ef2 --- /dev/null +++ b/output/optimized_peak.set @@ -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 diff --git a/output/optimized_plateau.set b/output/optimized_plateau.set new file mode 100644 index 0000000..e9349cd --- /dev/null +++ b/output/optimized_plateau.set @@ -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 diff --git a/output/param_scan.html b/output/param_scan.html new file mode 100644 index 0000000..9692dc2 --- /dev/null +++ b/output/param_scan.html @@ -0,0 +1,35 @@ + +参数敏感度扫描 — PF +
+

参数敏感度扫描报告

+

指标: PF 参数轴: FastMA × SlowMA 样本数: 30

+
全局峰值 PF
2.281
峰值邻域均值/峰值
0.47
高原覆盖率
17%
稳健性评分(0~1)
0.57
+
稳健性中等:有一定高原但峰值仍较突出,建议在高原内偏离峰值一点的位置选参以留安全边际。
+
推荐参数(最大高原中心):FastMA = 10,SlowMA = 40。该位置周围 5 个网格点 PF 均值 1.399,对参数扰动不敏感。
+

1. 响应面热力图(找高原用)

+PF 响应面热力图(FastMA × SlowMA)0.831.121.411.701.992.285, 20: 1.0171.0210, 20: 1.1691.1715, 20: 1.1321.1320, 20: 1.1631.1625, 20: 0.9420.9430, 20: 0.8270.835, 30: 1.1521.1510, 30: 1.1481.1515, 30: 1.2911.2920, 30: 1.2401.2425, 30: 1.1871.1930, 30: 1.1671.175, 40: 1.3081.3110, 40: 1.4311.4315, 40: 1.5251.5220, 40: 1.3551.3525, 40: 1.2181.2230, 40: 1.2441.245, 50: 1.1131.1110, 50: 1.3741.3715, 50: 1.3061.3120, 50: 1.1331.1325, 50: 1.0411.0430, 50: 1.1001.105, 60: 1.0571.0610, 60: 1.1591.1615, 60: 1.2011.2020, 60: 1.1991.2025, 60: 2.2812.2830, 60: 0.9420.94P1510152025302030405060FastMA →SlowMA → +

粗框 P1/P2/P3 = 检测到的高原(连通高分区域)。高原越宽 = 参数越稳健。

+

2. 3D 等距投影曲面

+PF 3D 响应面(等距投影) +

3. 高原清单

+
高原面积(格数)FastMA 范围SlowMA 范围PF 均值PF 峰值
P155~2040~501.3991.525
+

Top-10 单点峰值(对比用,单点 ≠ 稳健)

FastMASlowMAPF
25.060.02.281
15.040.01.525
10.040.01.431
10.050.01.374
20.040.01.355
5.040.01.308
15.050.01.306
15.030.01.291
30.040.01.244
20.030.01.24
+
由 param_scan.py 生成 · 高原检测阈值=80分位 · 2026-06-28 02:22
+
\ No newline at end of file diff --git a/output/report.html b/output/report.html new file mode 100644 index 0000000..65c455b --- /dev/null +++ b/output/report.html @@ -0,0 +1,175 @@ + + +EA 回测分析报告 +
+

EA 回测分析报告

+

EA: M1 GOLD SCALPER PRO 品种: XAUUSD.c  +报告A = IS-ReportTester-52845377.xlsx 报告B = OSS-ReportTester-52845377.xlsx

+ +
+
报告A 净盈利
-962.92
+
报告B 净盈利
-403.50
+
PF (A / B)
0.69 / 0.94
+
回撤% (A / B)
96.32% / 53.1%
+
+ +
+实际交易区间:报告A = 2025-07-17 01:58:00 ~ 2025-10-27 22:39:00;报告B = 2026-02-16 01:02:00 ~ 2026-05-29 23:46:00。
+输入参数:完全一致。 +若两份报告参数相同而结果差异大,说明策略对行情敏感;若参数不同,需先确认对比是否公平。 +
+ +

输入参数

参数报告A报告B
ShowPaneltrue✓ 一致
Panel_X20✓ 一致
Panel_Y30✓ 一致
Panel_W543✓ 一致
Panel_H181✓ 一致
FilterTradingHoursfalse✓ 一致
Exittrue✓ 一致
USEMOVETOBREAKEVENfalse✓ 一致
WHENTOMOVETOBE10.0✓ 一致
PIPSTOMOVESL5.0✓ 一致
Lots0.01✓ 一致
MaximumRisk0.0✓ 一致
DecreaseFactor330.0✓ 一致
TrailingStop2564.0✓ 一致
Stop_Loss1240.0✓ 一致
MagicNumber1234✓ 一致
Take_Profit2000.0✓ 一致
FastMA261✓ 一致
SlowMA385✓ 一致
Mom_Sell595.8✓ 一致
Mom_Buy1513.6✓ 一致
FractalNum6595✓ 一致
+ +

1. 核心指标对比

+
指标IS-ReportTester-52845377.xlsxOSS-ReportTester-52845377.xlsx单位
交易笔数19121914
净盈利-962.92-403.50
盈利因子 PF0.690.94
胜率 (%)38.1344.93
平均盈利3.007.29
平均亏损-2.66-6.33
盈亏比 (avgW/|avgL|)1.131.15
期望收益/笔-0.50-0.21
最大回撤-971.54-642.14
最大回撤 (%)96.3253.10%
夏普比率-5.00-3.33
Sortino (逐笔)-0.16-0.04
Calmar (净利/|回撤|)-0.99-0.63
最大连败1616
平均持仓 (分钟)39.6634.04
滚动PF 区间0.26~1.250.50~1.90
滚动窗口盈利比例9.5936.99%
+

Sortino/Calmar 为逐笔口径估算;滚动 PF 以 100 笔窗口滑动,"滚动窗口盈利比例"=窗口 PF>1 的占比(越高越稳定)。

+ +

2. 资金曲线

+-963-670-377-84209IS-ReportTester-52845377.xlsxOSS-ReportTester-52845377.xlsx交易序号 →累计盈亏 + +

3. 方向性诊断

+
方向指标IS-ReportTester-52845377.xlsxOSS-ReportTester-52845377.xlsx
long笔数1066958
long胜率(%)22.9827.04
long净盈利-412.29-194.63
longPF0.770.94
long平均盈利5.4812.26
long平均亏损-2.14-4.82
long盈亏比2.562.54
long期望/笔-0.39-0.20
short笔数846956
short胜率(%)57.2162.87
short净盈利-550.63-208.87
shortPF0.600.94
short平均盈利1.745.15
short平均亏损-3.85-9.31
short盈亏比0.450.55
short期望/笔-0.65-0.22
+-570-25266各方向净盈亏(A=报告A, B=报告B)-412long-A-195long-B-551short-A-209short-B +

若多空胜率/盈亏比显著不对称,是结构性偏差信号,需结合下方 What-If 与建议规则分析。

+ +

4. What-If 假设分析

+

对每份报告模拟多种改造场景,重算指标。场景为通用逻辑,不依赖具体 EA 参数。 +重点看 PF 是否站上 1、回撤是否收敛、期望是否转正

+

IS-ReportTester-52845377.xlsx

场景笔数净盈利PF胜率最大回撤期望/笔
基线(现状)
不做任何修改
1912-962.920.6938.1%-971.54-0.504
仅做空 (sell-only)
禁用多头,验证空头方向是否有正期望
846-550.630.6057.2%-562.87-0.651
仅做多 (buy-only)
禁用空头,验证多头方向是否有正期望
1066-412.290.7723.0%-418.15-0.387
信号反向 (net × -1)
盈亏整体取反。若反向后净盈利为正且 PF>1,需警惕信号方向逻辑写反
1912+962.921.4461.7%-82.29+0.504
过滤最差星期(周四)
剔除净盈亏最差的星期 周四
1498-637.920.7437.9%-638.02-0.426
过滤最差 3 个小时
剔除净盈亏最差的 3 个小时窗口
1639-629.480.7639.3%-654.13-0.384
仓位减半 (net × 0.5)
缩小仓位,回撤与盈利同步减半
1912-481.460.6938.1%-485.77-0.252
盈利单放大 ×1.5
模拟改进入场/离场让盈利单兑现更多利润
1912+129.791.0438.1%-257.48+0.068
亏损截断 ≤ $1.52
模拟更紧止损,把单笔亏损封顶在中位亏损水平
1912+839.091.6238.1%-52.81+0.439
启用 BE (盈利单 ×0.5)
模拟盈亏平移触发后盈利单部分回吐,但被保护
1912-2055.630.3538.1%-2059.48-1.075

OSS-ReportTester-52845377.xlsx

场景笔数净盈利PF胜率最大回撤期望/笔
基线(现状)
不做任何修改
1914-403.500.9444.9%-642.14-0.211
仅做空 (sell-only)
禁用多头,验证空头方向是否有正期望
956-208.870.9462.9%-328.52-0.218
仅做多 (buy-only)
禁用空头,验证多头方向是否有正期望
958-194.630.9427.0%-449.30-0.203
信号反向 (net × -1)
盈亏整体取反。若反向后净盈利为正且 PF>1,需警惕信号方向逻辑写反
1914+403.501.0655.0%-452.49+0.211
过滤最差星期(周四)
剔除净盈亏最差的星期 周四
1514-9.861.0046.5%-347.70-0.007
过滤最差 3 个小时
剔除净盈亏最差的 3 个小时窗口
1653+84.261.0246.2%-391.04+0.051
仓位减半 (net × 0.5)
缩小仓位,回撤与盈利同步减半
1914-201.750.9444.9%-321.07-0.105
盈利单放大 ×1.5
模拟改进入场/离场让盈利单兑现更多利润
1914+2731.841.4144.9%-139.98+1.427
亏损截断 ≤ $4.66
模拟更紧止损,把单笔亏损封顶在中位亏损水平
1914+2653.141.7344.9%-79.25+1.386
启用 BE (盈利单 ×0.5)
模拟盈亏平移触发后盈利单部分回吐,但被保护
1914-3538.840.4744.9%-3544.54-1.849
+
+必看:"信号反向"场景若两份报告净盈利均转正且 PF>1,需警惕信号方向逻辑写反,应人工复核源码。 +
+ +

5. 蒙特卡洛回撤模拟

+

将逐笔盈亏随机打乱 1000 次,看顺序无关下的回撤分布。 +若"实际回撤"显著差于中位,说明存在连败聚集(顺序有自我相关),建议加连亏保护。

+
+

报告A 蒙特卡洛回撤

-1059-1005-952-1056p5(更糟)-996均值-990p50-963p95(较好)-972实际
+

报告B 蒙特卡洛回撤

-867-649-432-854p5(更糟)-649均值-640p50-477p95(较好)-642实际
+
+
分位IS-ReportTester-52845377.xlsxOSS-ReportTester-52845377.xlsx
实际最大回撤-971.54-642.14
蒙特卡洛 5% 分位(更糟)-1055.90-853.67
蒙特卡洛 中位-989.91-640.02
蒙特卡洛 95% 分位(较好)-962.86-476.90
蒙特卡洛 均值-996.36-648.63
+ +

6. 时段诊断

+

6.1 按小时

+
小时IS-ReportTester-52845377.xlsx净OSS-ReportTester-52845377.xlsx净双负
01-42.46104+114.5295
02-45.4295-89.41103⚠️
03-105.8289-68.7990⚠️
04-58.52101+88.6774
05-36.5984-143.0471⚠️
06+10.2086-84.9670
07-10.3668+59.0878
08-92.2382-85.2882⚠️
09-61.1593-77.2480⚠️
10-11.85103-130.5592⚠️
11-40.2878+16.73100
12-41.4465+20.3197
13-135.39102-214.1798⚠️
14-30.9569-2.8284⚠️
15-9.2989+30.99112
16-19.3798+61.6196
17-13.2782-25.7294⚠️
18-7.3963-10.4153⚠️
19-41.5851-111.1353⚠️
20-41.8766+86.7889
21-29.5986+20.7575
22-34.9772+22.2269
23-63.3386+118.3659
+

6.2 按星期

+
星期IS-ReportTester-52845377.xlsx净OSS-ReportTester-52845377.xlsx净双负
周一-104.81346+115.22363
周二-140.46378-241.25388⚠️
周三-111.67364-106.02393⚠️
周四-325.00414-393.64400⚠️
周五-280.98410+222.19370
+

⚠️ = 两份报告同时为负,属系统性劣势时段,应优先过滤。单元格颜色:红=负、绿=正。

+ +

7. 持仓时间分桶

+
持仓区间IS-ReportTester-52845377.xlsx笔均值OSS-ReportTester-52845377.xlsx笔均值
<5m646-439.10-0.680536-569.43-1.062
5-15m323-328.18-1.016434-168.47-0.388
15-30m323-225.92-0.699454-361.47-0.796
30-60m357-130.78-0.366342+276.76+0.809
1-2h217+145.43+0.670121+225.92+1.867
>2h46+15.63+0.34027+193.19+7.155
+

观察哪个持仓区间贡献正/负盈亏,可指导止盈止损时间维度调整。

+ +

8. 数据驱动的优化方向建议

+

以下建议由当前数据的特征触发(每条附触发条件、数据、动作),不预设任何策略特定结论。 +未触发的规则不显示,未列出的维度表示数据正常。

+
+ +

① 信号方向自检 ⚠️ 触发

+

触发条件:两份报告"信号反向"场景净盈利均为正且 PF>1。

+

数据:IS 反向后 净=$+962.92 / PF=1.44; + OSS 反向后 净=$+403.50 / PF=1.06。

+

动作:强烈建议人工复核 EA 源码里 buy/sell 信号的方向判定,怀疑方向逻辑写反。

+ +

② 多空方向不对称 ⚠️ 触发

+

触发条件:两份报告多空胜率差均 > 20 个百分点。

+

数据:IS 多空胜率差 34.2,OSS 多空胜率差 35.8。 + 较弱方向:long(IS 胜率 23.0%)。

+

动作:审查较弱方向的入场信号;可先单独跑该方向 What-If(sell-only/buy-only)确认其期望。

+ +

③ short 方向:高胜率低盈亏比

+

触发条件:胜率 > 55% 且盈亏比 < 0.8(赢的太小输的太大,离场管理问题)。

+

数据:IS short 胜率 57.2%,盈亏比 0.45。

+

动作:检查提前离场逻辑是否砍断盈利单;考虑启用盈亏平衡或让盈利单跑到目标价。

+ +

③ long 方向:高盈亏比低胜率

+

触发条件:胜率 < 40% 且盈亏比 > 1.5(入场时机差但单笔盈亏结构尚可)。

+

数据:IS long 胜率 23.0%,盈亏比 2.56。

+

动作:改进入场过滤条件以提升胜率;或确认止损/止盈设置是否合理。

+ +

④ 时段/星期过滤 触发

+

触发条件:某时段在两份报告同时为负(系统性劣势)。

+

数据:双负小时 11 个 ['02', '03', '05', '08', '09', '10', '13', '14', '17', '18', '19']; + 双负星期:周二、周三、周四。

+

动作:若 EA 有时段过滤参数,开启并剔除上述窗口;否则在信号逻辑中加入时间过滤条件。

+ +

⑤ 回撤控制 触发

+

触发条件:任一报告最大回撤 > 40%。

+

数据:IS 回撤 96.32%,OSS 回撤 53.1%,最大连败 IS 16 笔 / OSS 16 笔。

+

动作:缩小单笔风险(止损/仓位);考虑在连亏达 N 笔时暂停或减仓; + 参考 What-If 表"仓位减半"和"亏损截断"场景的回撤改善效果。

+ +

⑦ 离场优化潜力

+

触发条件:"盈利单放大×1.5"场景 PF 站上 1 且优于基线。

+

数据:IS 基线 PF 0.69 → 场景 PF 1.04; + OSS 基线 PF 0.94 → 场景 PF 1.41。

+

动作:离场逻辑有改进空间,考虑让盈利单兑现更多利润(放宽止盈/调整提前离场条件)。

+ +

⑧ 参数稳健性

+

触发条件:两份报告 PF 差异 > 0.2。

+

数据:IS PF 0.69,OSS PF 0.94,差 0.25。

+

动作:策略对行情敏感,建议做参数敏感度扫描找稳健高原(而非单点峰值),并用 Walk-Forward 验证。

+ +
+ +

9. Walk-Forward 滚动验证

+

将每份报告的逐笔交易按时间切成滚动窗口(IS 45天 + OOS 30天,步长 30天), + 计算每个窗口的 IS/OOS 指标。WFE = ΣOOS净 / ΣIS净,越高越稳健; + IS-OOS PF 相关性高=泛化好,低=过拟合风险。数据太短无法切窗会提示。

+

报告A: IS-ReportTester-52845377.xlsx

+
窗口数
1
WFE (ΣOOS净/ΣIS净)
+0.57
OOS 盈利窗口占比
0%
IS-OOS PF 相关性
0.000.250.500.751.00#0#0#0滚动窗口 IS vs OOS 盈利因子 (PF=1 为盈亏平衡线)IS PFOOS PF窗口序号 →
窗口IS区间OOS区间IS笔IS净IS PFIS胜率OOS笔OOS净OOS PFOOS胜率OOS回撤
#02025-07-17~2025-08-312025-08-31~2025-09-30956-497.130.5939.7%607-281.560.7237.4%-296.37
WFE > 0.5:OOS 能保留 IS 的一半以上盈利,泛化较好。
+

报告B: OSS-ReportTester-52845377.xlsx

+
窗口数
1
WFE (ΣOOS净/ΣIS净)
-1.64
OOS 盈利窗口占比
0%
IS-OOS PF 相关性
0.000.260.530.791.05#0#0#0滚动窗口 IS vs OOS 盈利因子 (PF=1 为盈亏平衡线)IS PFOOS PF窗口序号 →
窗口IS区间OOS区间IS笔IS净IS PFIS胜率OOS笔OOS净OOS PFOOS胜率OOS回撤
#02026-02-16~2026-04-022026-04-02~2026-05-02852+182.581.0545.1%530-299.730.8345.7%-338.75
WFE 为负:OOS 累计亏损。即使 IS 段盈利,策略也未能泛化。
+ +

10. 可选扩展分析

+

以下三项已实现为独立可复用工具,按需调用:

+
    +
  • 参数敏感度扫描python param_scan.py gen-set ... 生成网格 .set + 批处理脚本, + 在 MT5 跑完后 python param_scan.py analyze ... 出响应面热力图 + 3D 曲面 + 高原检测。 + python param_scan.py demo 可先看效果。
  • +
  • MAE/MFE 分析:MT5 标准 xlsx 不含逐笔 MAE/MFE,需先用 python mae_mfe.py --show-snippet + 取 MQL5 代码粘进 EA 导出 CSV,再 python mae_mfe.py mae_mfe.csv 出散点 + TP/SL 扫描热力图。
  • +
  • Walk-Forward 独立报告python walk_forward.py <report.xlsx> --is-days 60 --oos-days 30 + 出滚动窗口 IS/OOS 验证(本报告第 8 节已内嵌简化版)。
  • +
+

其它建议(未实现,需自行扩展):

+
    +
  • 多品种/多周期:同策略测多品种多周期,看泛化能力。
  • +
  • 交易成本敏感度:点差/手续费放大 1.5x、2x 看 PF 退化曲线,评估实盘可行性。
  • +
  • 信号因子分解:复合信号拆单因子分别回测,剔除拖累项。
  • +
+ +
由 run_analysis.py 自动生成 · 数据来自 MT5 Strategy Tester 导出的 xlsx · 2026-06-28 02:14
+
diff --git a/output/walk_forward.html b/output/walk_forward.html new file mode 100644 index 0000000..3a858bc --- /dev/null +++ b/output/walk_forward.html @@ -0,0 +1,23 @@ + +Walk-Forward 报告 — demo +
+

Walk-Forward 滚动验证报告

+

来源: demo

+
窗口数
3
WFE (ΣOOS净/ΣIS净)
+0.51
OOS 盈利窗口占比
0%
IS-OOS PF 相关性
-0.28
0.000.250.500.751.00#0#1#2滚动窗口 IS vs OOS 盈利因子 (PF=1 为盈亏平衡线)IS PFOOS PF窗口序号 →
窗口IS区间OOS区间IS笔IS净IS PFIS胜率OOS笔OOS净OOS PFOOS胜率OOS回撤
#02024-01-01~2024-03-012024-03-01~2024-03-31480-171.120.7243.3%240-63.730.7945.0%-103.50
#12024-01-31~2024-03-312024-03-31~2024-04-30480-122.890.8045.0%240-55.430.8245.0%-99.96
#22024-03-01~2024-04-302024-04-30~2024-05-30480-119.160.8045.0%240-91.910.7241.7%-120.94

IS 与 OOS 的 PF 相关性低 → IS 表现难以预测 OOS,参数不稳健,警惕过拟合单段行情。

WFE > 0.5:OOS 能保留 IS 的一半以上盈利,泛化较好。
+
\ No newline at end of file diff --git a/param_scan.py b/param_scan.py new file mode 100644 index 0000000..9dbd1c5 --- /dev/null +++ b/param_scan.py @@ -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=\\ReportTester_\n" + "ReplaceReport=1\n" + "ShutdownTerminal=1\n" + "UseLocal=1\n" + "InputSet=\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_.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'' + f'{xticks[i]}, {yticks[j]}: {vstr}') + if not np.isnan(v): + cells += f'{v:.2f}' + + # 高原边框 + 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'') + plat_svg += f'P{k+1}' + + # 轴标签 + xlabs = "".join( + f'{xticks[i]}' + for i in range(nx) + ) + ylabs = "".join( + f'{yticks[j]}' + for j in range(ny) + ) + # 图例 + leg = "" + for i in range(6): + r = i / 5 + v = vmin + r * (vmax - vmin) + leg += (f'' + f'{v:.2f}') + + return (f'' + f'' + f'{html.escape(metric)} 响应面热力图({html.escape(xname)} × {html.escape(yname)})' + f'{leg}{cells}{plat_svg}{xlabs}{ylabs}' + f'{html.escape(xname)} →' + f'{html.escape(yname)} →' + f'') + + +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'')) + quads.sort(key=lambda q: -q[0]) # 远的先画 + body = "".join(q[1] for q in quads) + return (f'' + f'{html.escape(metric)} 3D 响应面(等距投影)' + f'{body}') + + +# =========================================================================== # +# 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"P{k+1}{p['size']}{xr}{yr}" + f"{p['metric_mean']:.3f}{p['metric_max']:.3f}") + plat_table = ( + "" + f"" + f"" + + plat_rows + "
高原面积(格数){html.escape(x)} 范围{html.escape(y)} 范围{html.escape(metric)} 均值{html.escape(metric)} 峰值
" + ) + else: + plat_table = "

未检测到稳健高原(所有高分点都是孤立单点,过拟合风险高)。

" + + 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 = ( + "
" + f"
全局峰值 {html.escape(metric)}
{of_score['peak']:.3f}
" + f"
峰值邻域均值/峰值
{iso_str}
" + f"
高原覆盖率
{of_score['plateau_coverage']*100:.0f}%
" + f"
稳健性评分(0~1)
{rob_str}
" + "
" + ) + + 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 = "
稳健性评分低:最优参数是孤立尖峰,邻域快速退化,过拟合风险高。应在高原中心选参而非峰值点。
" + elif r > 0.7: + interp = "
稳健性评分高:存在宽阔高原,参数对扰动不敏感,泛化性好。建议在最大高原中心选参。
" + else: + interp = "
稳健性中等:有一定高原但峰值仍较突出,建议在高原内偏离峰值一点的位置选参以留安全边际。
" + + 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"
推荐参数(最大高原中心):" + f"{html.escape(x)} = {cx_v},{html.escape(y)} = {cy_v}。" + f"该位置周围 {p1['size']} 个网格点 {html.escape(metric)} 均值 {p1['metric_mean']:.3f}," + f"对参数扰动不敏感。
") + + # 顶 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 += "" + "".join(f"{r[c]}" for c in top_df.columns) + "" + top_table = ("

Top-10 单点峰值(对比用,单点 ≠ 稳健)

" + "" + "".join(f"" for c in top_df.columns) + "" + + top_rows + "
{html.escape(c)}
") if top_rows else "" + + return f""" +参数敏感度扫描 — {html.escape(metric)} +
+

参数敏感度扫描报告

+

指标: {html.escape(metric)} 参数轴: {html.escape(x)} × {html.escape(y)} 样本数: {len(df)}

+{cards} +{interp} +{recommend} +

1. 响应面热力图(找高原用)

+{heat} +

粗框 P1/P2/P3 = 检测到的高原(连通高分区域)。高原越宽 = 参数越稳健。

+

2. 3D 等距投影曲面

+{surf} +

3. 高原清单

+{plat_table} +{top_table} +
由 param_scan.py 生成 · 高原检测阈值=80分位 · {pd.Timestamp.now().strftime('%Y-%m-%d %H:%M')}
+
""" + + +# =========================================================================== # +# 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:])) diff --git a/run_analysis.py b/run_analysis.py new file mode 100644 index 0000000..ee4bb9d --- /dev/null +++ b/run_analysis.py @@ -0,0 +1,1076 @@ +# -*- coding: utf-8 -*- +""" +MT5 EA 回测报告通用分析器 +======================== +读取任意两份 MT5 Strategy Tester 导出的 xlsx 报告(通常一份 IS / 一份 OSS, +但也支持任意两份对比),输出自包含 HTML 报告: + + - 元信息与实际交易区间 + - 核心指标对比(PF / 胜率 / 回撤 / 夏普 / Sortino / Calmar / 滚动PF 等) + - 资金曲线、回撤曲线 + - 方向性诊断(多/空各自的胜率、PF、盈亏比、期望) + - What-If 假设分析(sell-only / buy-only / 信号反向 / 过滤双负时段 / 仓位缩放 / + 盈利单放大 / 亏损截断 等通用场景,结论由数据推导) + - 蒙特卡洛回撤模拟(顺序无关性检验) + - 时段 / 星期 / 持仓时间分桶热力表 + - 数据驱动的优化方向建议(不预写任何策略特定结论) + +设计原则: + 1. 不依赖任何具体 EA 的参数名、阈值或结论 + 2. 所有"建议"由当前两份报告的数据特征触发,而非硬编码 + 3. 输入仅依赖 mt5_report_parser 解析出的结构化数据 + +用法: + python run_analysis.py + python run_analysis.py # 自动找当前目录 IS-*.xlsx 和 OSS-*.xlsx +""" +from __future__ import annotations + +import glob +import html +import os +import sys +from typing import Any, Dict, List, Optional, Tuple + +import numpy as np +import pandas as pd + +import mt5_report_parser as mp +import walk_forward as wf + +OUT_DIR = "output" +HTML_PATH = os.path.join(OUT_DIR, "report.html") +WEEKDAY_NAMES = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] + + +# =========================================================================== # +# 加载 +# =========================================================================== # +def load_reports(argv: List[str]) -> List[Tuple[str, mp.MT5Report]]: + """加载两份报告。优先用命令行参数,否则自动找 IS-/OSS- 前缀文件。""" + if len(argv) >= 3: + files = argv[1:3] + else: + is_files = sorted(glob.glob("IS-*.xlsx")) + oss_files = sorted(glob.glob("OSS-*.xlsx")) + if not is_files or not oss_files: + raise SystemExit("未找到 IS-*.xlsx / OSS-*.xlsx,请显式传两个文件名") + files = [is_files[0], oss_files[0]] + return [(os.path.basename(f), mp.parse_report(f)) for f in files] + + +def actual_range(rep: mp.MT5Report) -> Tuple[Optional[str], Optional[str]]: + t = rep.trades + if t is None or t.empty: + return None, None + return str(t["open_time"].min()), str(t["open_time"].max()) + + +# =========================================================================== # +# 扩展指标 +# =========================================================================== # +def extended_metrics(trades: pd.DataFrame) -> Dict[str, Any]: + """计算单份报告的扩展指标。纯数据驱动。""" + 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] + gp = wins.sum() + gl = -losses.sum() + pf = gp / gl if gl > 0 else np.inf + + equity = net.cumsum() + running_max = equity.cummax() + dd = equity - running_max + max_dd = float(dd.min()) + + # Sortino(逐笔,下行波动) + downside = losses + downside_std = downside.std(ddof=0) if len(downside) > 1 else 0.0 + sortino = float(net.mean() / downside_std) if downside_std > 0 else np.nan + + # Calmar = 总净盈利 / |最大回撤| + calmar = float(net.sum() / abs(max_dd)) if max_dd != 0 else np.nan + + # 滚动 PF:每 100 笔窗口 + win = 100 + roll_pf = [] + for i in range(0, n - win + 1, max(1, win // 4)): + seg = net.iloc[i : i + win] + w = seg[seg > 0].sum() + l = -seg[seg <= 0].sum() + roll_pf.append(w / l if l > 0 else np.inf) + roll_pf = [x for x in roll_pf if not np.isinf(x)] + roll_pf_min = float(min(roll_pf)) if roll_pf else np.nan + roll_pf_max = float(max(roll_pf)) if roll_pf else np.nan + pct_profitable_window = ( + sum(1 for x in roll_pf if x > 1) / len(roll_pf) * 100 if roll_pf else 0.0 + ) + + # 连续盈亏 + cur_w = cur_l = 0 + max_sw = max_sl = 0 + for v in net: + if v > 0: + cur_w += 1 + cur_l = 0 + max_sw = max(max_sw, cur_w) + else: + cur_l += 1 + cur_w = 0 + max_sl = max(max_sl, cur_l) + + # 按方向 + by_dir: Dict[str, Dict[str, Any]] = {} + for direction, g in t.groupby("direction"): + gn = g["net_profit"].astype(float) + w = gn[gn > 0] + l = gn[gn <= 0] + gp_d = w.sum() + gl_d = -l.sum() + by_dir[direction] = { + "n": int(len(g)), + "win_rate": float(len(w) / len(g) * 100) if len(g) else 0, + "net_profit": float(gn.sum()), + "profit_factor": float(gp_d / gl_d) if gl_d > 0 else np.inf, + "avg_win": float(w.mean()) if len(w) else 0.0, + "avg_loss": float(l.mean()) if len(l) else 0.0, + "expectancy": float(gn.mean()), + } + + t2 = t.copy() + t2["hour"] = t2["open_time"].dt.hour + t2["weekday"] = t2["open_time"].dt.dayofweek + 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") + + # 持仓时间分桶 + bins = [0, 5, 15, 30, 60, 120, 1e9] + labels = ["<5m", "5-15m", "15-30m", "30-60m", "1-2h", ">2h"] + t2["dur_bin"] = pd.cut(t2["duration_min"], bins=bins, labels=labels, right=False) + by_dur = ( + t2.groupby("dur_bin", observed=True)["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_dd": max_dd, + "sortino": sortino, + "calmar": calmar, + "max_streak_win": int(max_sw), + "max_streak_loss": int(max_sl), + "avg_duration_min": float(t["duration_min"].mean()), + "median_duration_min": float(t["duration_min"].median()), + "roll_pf_min": roll_pf_min, + "roll_pf_max": roll_pf_max, + "pct_profitable_window": float(pct_profitable_window), + "by_direction": by_dir, + "by_hour": by_hour, + "by_weekday": by_weekday, + "by_month": by_month, + "by_duration": by_dur, + "_equity": equity, + "_dd": dd, + "_net": net, + } + + +# =========================================================================== # +# What-If 假设分析(通用场景) +# =========================================================================== # +def _stats_from_net(net: pd.Series) -> Dict[str, float]: + """由净盈亏序列算关键统计。""" + n = len(net) + if n == 0: + return {"n": 0, "net": 0, "pf": 0, "win": 0, "dd": 0, "exp": 0} + wins = net[net > 0] + losses = net[net <= 0] + gp = wins.sum() + gl = -losses.sum() + pf = gp / gl if gl > 0 else np.inf + equity = net.cumsum() + dd = (equity - equity.cummax()).min() + return { + "n": int(n), + "net": float(net.sum()), + "pf": float(pf), + "win": float(len(wins) / n * 100), + "dd": float(dd), + "exp": float(net.mean()), + } + + +def whatif_scenarios(rep: mp.MT5Report) -> List[Dict[str, Any]]: + """ + 生成通用 What-If 场景。不依赖任何具体 EA 的参数。 + 场景选择基于 trades DataFrame 的通用字段(direction / open_time / net_profit)。 + """ + t = rep.trades.copy() + t["hour"] = t["open_time"].dt.hour + t["weekday"] = t["open_time"].dt.dayofweek + net = t["net_profit"].astype(float) + + scenarios: List[Dict[str, Any]] = [] + + # 基线 + scenarios.append({"name": "基线(现状)", "desc": "不做任何修改", **_stats_from_net(net)}) + + # 仅做空(若存在 short 方向) + short_net = t.loc[t.direction == "short", "net_profit"] + if len(short_net) > 0: + scenarios.append({ + "name": "仅做空 (sell-only)", + "desc": "禁用多头,验证空头方向是否有正期望", + **_stats_from_net(short_net), + }) + + # 仅做多(若存在 long 方向) + long_net = t.loc[t.direction == "long", "net_profit"] + if len(long_net) > 0: + scenarios.append({ + "name": "仅做多 (buy-only)", + "desc": "禁用空头,验证多头方向是否有正期望", + **_stats_from_net(long_net), + }) + + # 信号反向 —— 检测信号方向是否设反 + scenarios.append({ + "name": "信号反向 (net × -1)", + "desc": "盈亏整体取反。若反向后净盈利为正且 PF>1,需警惕信号方向逻辑写反", + **_stats_from_net(-net), + }) + + # 过滤"双负时段"——这里只能基于单份报告自身, + # 但因为本分析器同时持有两份报告,跨报告双负过滤在 build_html 里组合 + # 此处提供单份报告的"过滤最差星期"作为通用场景 + wd_sums = t.groupby("weekday")["net_profit"].sum() + if len(wd_sums) > 0: + worst_wd = int(wd_sums.idxmin()) + scenarios.append({ + "name": f"过滤最差星期({WEEKDAY_NAMES[worst_wd]})", + "desc": f"剔除净盈亏最差的星期 {WEEKDAY_NAMES[worst_wd]}", + **_stats_from_net(t.loc[t.weekday != worst_wd, "net_profit"]), + }) + + # 过滤最差小时 + hr_sums = t.groupby("hour")["net_profit"].sum() + if len(hr_sums) > 0: + worst_hrs = hr_sums.nsmallest(max(1, len(hr_sums) // 6)).index.tolist() + scenarios.append({ + "name": f"过滤最差 {len(worst_hrs)} 个小时", + "desc": f"剔除净盈亏最差的 {len(worst_hrs)} 个小时窗口", + **_stats_from_net(t.loc[~t.hour.isin(worst_hrs), "net_profit"]), + }) + + # 仓位减半 + scenarios.append({ + "name": "仓位减半 (net × 0.5)", + "desc": "缩小仓位,回撤与盈利同步减半", + **_stats_from_net(net * 0.5), + }) + + # 盈利单放大(模拟让盈利单跑更远) + net_tp = net.copy() + net_tp[net_tp > 0] = net_tp[net_tp > 0] * 1.5 + scenarios.append({ + "name": "盈利单放大 ×1.5", + "desc": "模拟改进入场/离场让盈利单兑现更多利润", + **_stats_from_net(net_tp), + }) + + # 亏损截断(模拟更紧止损) + net_cut = net.copy() + med_loss = float(abs(losses).median()) if (losses := net[net <= 0]).size else 3.0 + cap = med_loss # 以中位亏损为截断阈值,避免硬编码 + net_cut[net_cut < -cap] = -cap + scenarios.append({ + "name": f"亏损截断 ≤ ${cap:.2f}", + "desc": "模拟更紧止损,把单笔亏损封顶在中位亏损水平", + **_stats_from_net(net_cut), + }) + + # 盈亏平衡(盈利单部分回吐,模拟 BE 触发) + net_be = net.copy() + net_be[net_be > 0] = net_be[net_be > 0] * 0.5 + scenarios.append({ + "name": "启用 BE (盈利单 ×0.5)", + "desc": "模拟盈亏平移触发后盈利单部分回吐,但被保护", + **_stats_from_net(net_be), + }) + + return scenarios + + +# =========================================================================== # +# 蒙特卡洛回撤模拟 +# =========================================================================== # +def monte_carlo_dd(net: pd.Series, n_sim: int = 1000, seed: int = 42) -> Dict[str, float]: + """打乱交易顺序,看最大回撤分布。检验顺序自相关性。""" + rng = np.random.default_rng(seed) + arr = net.to_numpy() + n = len(arr) + if n == 0: + return {"p5": 0, "p50": 0, "p95": 0, "actual": 0, "mean": 0} + dds = np.empty(n_sim) + for i in range(n_sim): + perm = rng.permutation(n) + eq = np.cumsum(arr[perm]) + dd = (eq - np.maximum.accumulate(eq)).min() + dds[i] = dd + return { + "p5": float(np.percentile(dds, 5)), + "p50": float(np.percentile(dds, 50)), + "p95": float(np.percentile(dds, 95)), + "actual": float((net.cumsum() - net.cumsum().cummax()).min()), + "mean": float(dds.mean()), + } + + +# =========================================================================== # +# 内联 SVG 图表 +# =========================================================================== # +def svg_equity(reports: List[Tuple[str, mp.MT5Report]], w: int = 760, h: int = 240) -> str: + figs = [] + for name, rep in reports: + m = extended_metrics(rep.trades) + if not m: + continue + figs.append((name, m["_equity"].to_numpy())) + if not figs: + return "" + all_vals = np.concatenate([f[1] for f in figs]) + y_min, y_max = float(all_vals.min()), float(all_vals.max()) + if y_min == y_max: + y_max = y_min + 1 + pad = 40 + colors = ["#2563eb", "#dc2626"] + paths = [] + for i, (name, eq) in enumerate(figs): + n = len(eq) + xs = np.linspace(pad, w - 10, n) + ys = h - pad - (eq - y_min) / (y_max - y_min) * (h - pad - 10) + d = " ".join(f"{x:.1f},{y:.1f}" for x, y in zip(xs, ys)) + paths.append( + f'{html.escape(name)}' + ) + zero_y = h - pad - (0 - y_min) / (y_max - y_min) * (h - pad - 10) + grid = [] + for frac in (0, 0.25, 0.5, 0.75, 1.0): + yv = y_min + frac * (y_max - y_min) + gy = h - pad - frac * (h - pad - 10) + grid.append(f'' + f'{yv:.0f}') + zero_line = ( + f'' + if y_min < 0 < y_max else "" + ) + return ( + f'' + f'{"".join(grid)}{zero_line}{"".join(paths)}' + f'交易序号 →' + f'累计盈亏' + f'' + ) + + +def svg_dd_hist(mc: Dict[str, float], w: int = 460, h: int = 220) -> str: + """蒙特卡洛回撤分布条形图。""" + top_pad = 20 + bottom_pad = 40 + left_pad = 50 + right_pad = 16 + plot_h = h - top_pad - bottom_pad + plot_w = w - left_pad - right_pad + + items = [ + ("p5(更糟)", mc["p5"]), + ("均值", mc["mean"]), + ("p50", mc["p50"]), + ("p95(较好)", mc["p95"]), + ("实际", mc["actual"]), + ] + vmin = min(v for _, v in items) + vmax = max(v for _, v in items) + if vmin == vmax: + vmax = vmin + 1 + pad_range = (vmax - vmin) * 0.12 + vmin -= pad_range * 0.3 + vmax += pad_range + + n = len(items) + slot = plot_w / n + bw = slot * 0.6 + bars = [] + for i, (lbl, v) in enumerate(items): + x = left_pad + i * slot + (slot - bw) / 2 + frac = (v - vmin) / (vmax - vmin) + y1 = top_pad + plot_h - frac * plot_h + color = "#dc2626" if lbl == "实际" else "#3b82f6" + bars.append( + f'' + f'{v:.0f}' + f'{lbl}' + ) + yticks = "" + for frac in (0, 0.5, 1.0): + yv = vmin + frac * (vmax - vmin) + gy = top_pad + plot_h - frac * plot_h + yticks += (f'' + f'{yv:.0f}') + return f'{yticks}{"".join(bars)}' + + +def svg_bars(items: List[Tuple[str, float]], w: int = 520, h: int = 240, + title: str = "") -> str: + """柱状图。画布留足标题区(顶 24)与标签区(底 30),柱子不再与文字重叠。""" + top_pad = 24 + bottom_pad = 40 + left_pad = 50 + right_pad = 16 + plot_h = h - top_pad - bottom_pad + plot_w = w - left_pad - right_pad + + vals = [v for _, v in items] + vmin = min(vals + [0]) + vmax = max(vals + [0]) + if vmin == vmax: + vmax = vmin + 1 + # 留 10% 顶部余量,避免数值标签贴边 + pad_range = (vmax - vmin) * 0.12 + vmin -= pad_range * 0.3 + vmax += pad_range + zero_y = top_pad + plot_h - (0 - vmin) / (vmax - vmin) * plot_h + + n = len(items) + slot = plot_w / n + bw = slot * 0.6 + bars = [] + for i, (lbl, v) in enumerate(items): + x = left_pad + i * slot + (slot - bw) / 2 + y1 = top_pad + plot_h - (v - vmin) / (vmax - vmin) * plot_h + yt, yb = min(zero_y, y1), max(zero_y, y1) + col = "#16a34a" if v >= 0 else "#dc2626" + # 数值标签放在柱子顶端外侧 + val_y = yt - 4 if v >= 0 else yb + 12 + bars.append( + f'' + f'{v:.0f}' + f'{html.escape(lbl)}' + ) + ttl = f'{html.escape(title)}' if title else "" + zero_line = f'' + # Y 轴刻度 + yticks = "" + for frac in (0, 0.5, 1.0): + yv = vmin + frac * (vmax - vmin) + gy = top_pad + plot_h - frac * plot_h + yticks += (f'' + f'{yv:.0f}') + return f'{yticks}{zero_line}{ttl}{"".join(bars)}' + + +# =========================================================================== # +# HTML 工具 +# =========================================================================== # +def fmt(v: Any, suffix: str = "") -> str: + if v is None: + return "—" + if isinstance(v, float): + if np.isnan(v): + return "—" + return f"{v:.2f}{suffix}" + return f"{v}{suffix}" + + +def heatmap_color(v: float, vmax: float) -> str: + if vmax == 0: + return "transparent" + ratio = max(-1, min(1, v / vmax)) + if ratio >= 0: + return f"rgba(22,163,74,{0.15 + 0.5*abs(ratio)})" + else: + return f"rgba(220,38,38,{0.15 + 0.5*abs(ratio)})" + + +def whatif_table(scenarios: List[Dict[str, Any]]) -> str: + rows = [] + for s in scenarios: + cls = " class='pos'" if s["net"] > 0 else " class='neg'" + rows.append( + f"" + f"{html.escape(s['name'])}
{html.escape(s['desc'])}" + f"{s['n']}" + f"{s['net']:+.2f}" + f"{s['pf']:.2f}" + f"{s['win']:.1f}%" + f"{s['dd']:.2f}" + f"{s['exp']:+.3f}" + f"" + ) + return ( + "" + "" + "" + "" + "".join(rows) + "
场景笔数净盈利PF胜率最大回撤期望/笔
" + ) + + +# =========================================================================== # +# 数据驱动的优化方向建议 +# =========================================================================== # +def build_advice(reports: List[Tuple[str, mp.MT5Report]]) -> str: + """ + 完全由数据特征触发建议,不预写任何策略特定结论。 + 每条建议都附"触发条件 → 数据 → 动作",便于使用者核对。 + """ + parts: List[str] = [] + (is_name, is_rep), (oss_name, oss_rep) = reports + is_m = extended_metrics(is_rep.trades) + oss_m = extended_metrics(oss_rep.trades) + is_wif = whatif_scenarios(is_rep) + oss_wif = whatif_scenarios(oss_rep) + + def find_wif(wif: List[Dict[str, Any]], name_contains: str) -> Optional[Dict[str, Any]]: + for s in wif: + if name_contains in s["name"]: + return s + return None + + # ---- 规则1:信号反向自检 ---- + rev_is = find_wif(is_wif, "信号反向") + rev_oss = find_wif(oss_wif, "信号反向") + if rev_is and rev_oss: + cond = rev_is["net"] > 0 and rev_oss["net"] > 0 and rev_is["pf"] > 1 and rev_oss["pf"] > 1 + flag = "⚠️ 触发" if cond else "未触发" + parts.append(f""" +

① 信号方向自检 {flag}

+

触发条件:两份报告"信号反向"场景净盈利均为正且 PF>1。

+

数据:IS 反向后 净=${rev_is['net']:+.2f} / PF={rev_is['pf']:.2f}; + OSS 反向后 净=${rev_oss['net']:+.2f} / PF={rev_oss['pf']:.2f}。

+

动作:{'强烈建议人工复核 EA 源码里 buy/sell 信号的方向判定,怀疑方向逻辑写反。' if cond else '方向逻辑未见反向特征,正常。'}

+ """) + + # ---- 规则2:方向不对称 ---- + is_bd = is_m["by_direction"] + oss_bd = oss_m["by_direction"] + if "short" in is_bd and "long" in is_bd and "short" in oss_bd and "long" in oss_bd: + is_wr_diff = abs(is_bd["short"]["win_rate"] - is_bd["long"]["win_rate"]) + oss_wr_diff = abs(oss_bd["short"]["win_rate"] - oss_bd["long"]["win_rate"]) + cond = is_wr_diff > 20 and oss_wr_diff > 20 + flag = "⚠️ 触发" if cond else "未触发" + worse_dir = "long" if is_bd["long"]["win_rate"] < is_bd["short"]["win_rate"] else "short" + parts.append(f""" +

② 多空方向不对称 {flag}

+

触发条件:两份报告多空胜率差均 > 20 个百分点。

+

数据:IS 多空胜率差 {is_wr_diff:.1f},OSS 多空胜率差 {oss_wr_diff:.1f}。 + 较弱方向:{worse_dir}(IS 胜率 {is_bd[worse_dir]['win_rate']:.1f}%)。

+

动作:{'审查较弱方向的入场信号;可先单独跑该方向 What-If(sell-only/buy-only)确认其期望。' if cond else '多空较均衡,无需特殊处理。'}

+ """) + + # ---- 规则3:盈亏比 vs 胜率失衡 ---- + for d in ("short", "long"): + if d not in is_bd or d not in oss_bd: + continue + di = is_bd[d] + rr_i = di["avg_win"] / abs(di["avg_loss"]) if di["avg_loss"] != 0 else 0 + cond = di["win_rate"] < 40 and rr_i > 1.5 + if cond: + parts.append(f""" +

③ {d} 方向:高盈亏比低胜率

+

触发条件:胜率 < 40% 且盈亏比 > 1.5(入场时机差但单笔盈亏结构尚可)。

+

数据:IS {d} 胜率 {di['win_rate']:.1f}%,盈亏比 {rr_i:.2f}。

+

动作:改进入场过滤条件以提升胜率;或确认止损/止盈设置是否合理。

+ """) + cond2 = di["win_rate"] > 55 and rr_i < 0.8 + if cond2: + parts.append(f""" +

③ {d} 方向:高胜率低盈亏比

+

触发条件:胜率 > 55% 且盈亏比 < 0.8(赢的太小输的太大,离场管理问题)。

+

数据:IS {d} 胜率 {di['win_rate']:.1f}%,盈亏比 {rr_i:.2f}。

+

动作:检查提前离场逻辑是否砍断盈利单;考虑启用盈亏平衡或让盈利单跑到目标价。

+ """) + + # ---- 规则4:双负时段过滤 ---- + is_hr = is_m["by_hour"] + oss_hr = oss_m["by_hour"] + both_neg_hours = [ + h for h in range(24) + if is_hr.get(h, {}).get("sum", 0) < 0 and oss_hr.get(h, {}).get("sum", 0) < 0 + ] + is_wd = is_m["by_weekday"] + oss_wd = oss_m["by_weekday"] + both_neg_wds = [ + d for d in range(7) + if is_wd.get(d, {}).get("sum", 0) < 0 and oss_wd.get(d, {}).get("sum", 0) < 0 + ] + if both_neg_hours or both_neg_wds: + wd_str = "、".join(WEEKDAY_NAMES[d] for d in both_neg_wds) or "无" + parts.append(f""" +

④ 时段/星期过滤 触发

+

触发条件:某时段在两份报告同时为负(系统性劣势)。

+

数据:双负小时 {len(both_neg_hours)} 个 {[f'{h:02d}' for h in both_neg_hours]}; + 双负星期:{wd_str}。

+

动作:若 EA 有时段过滤参数,开启并剔除上述窗口;否则在信号逻辑中加入时间过滤条件。

+ """) + + # ---- 规则5:回撤过大 ---- + is_dd_pct = is_rep.summary_norm.get("max_balance_dd_pct") + oss_dd_pct = oss_rep.summary_norm.get("max_balance_dd_pct") + if is_dd_pct and oss_dd_pct and (is_dd_pct > 40 or oss_dd_pct > 40): + parts.append(f""" +

⑤ 回撤控制 触发

+

触发条件:任一报告最大回撤 > 40%。

+

数据:IS 回撤 {is_dd_pct}%,OSS 回撤 {oss_dd_pct}%,最大连败 IS {is_m['max_streak_loss']} 笔 / OSS {oss_m['max_streak_loss']} 笔。

+

动作:缩小单笔风险(止损/仓位);考虑在连亏达 N 笔时暂停或减仓; + 参考 What-If 表"仓位减半"和"亏损截断"场景的回撤改善效果。

+ """) + + # ---- 规则6:蒙特卡洛顺序自相关 ---- + is_mc = monte_carlo_dd(is_m["_net"]) + oss_mc = monte_carlo_dd(oss_m["_net"]) + # 实际回撤显著差于中位 → 顺序有自相关(连败聚集) + is_cluster = is_mc["actual"] < is_mc["p50"] * 1.3 + oss_cluster = oss_mc["actual"] < oss_mc["p50"] * 1.3 + if is_cluster or oss_cluster: + parts.append(f""" +

⑥ 连败聚集检测 触发

+

触发条件:实际回撤显著差于蒙特卡洛中位回撤(>1.3倍)。

+

数据:IS 实际 {is_mc['actual']:.0f} vs 中位 {is_mc['p50']:.0f}; + OSS 实际 {oss_mc['actual']:.0f} vs 中位 {oss_mc['p50']:.0f}。

+

动作:交易顺序存在自我相关(亏损倾向连发),建议加连亏保护机制。

+ """) + + # ---- 规则7:盈利单放大场景效果 ---- + tp_is = find_wif(is_wif, "盈利单放大") + tp_oss = find_wif(oss_wif, "盈利单放大") + if tp_is and tp_oss: + cond = tp_is["pf"] > 1 and tp_oss["pf"] > 1 and \ + tp_is["pf"] > is_m["profit_factor"] and tp_oss["pf"] > oss_m["profit_factor"] + if cond: + parts.append(f""" +

⑦ 离场优化潜力

+

触发条件:"盈利单放大×1.5"场景 PF 站上 1 且优于基线。

+

数据:IS 基线 PF {is_m['profit_factor']:.2f} → 场景 PF {tp_is['pf']:.2f}; + OSS 基线 PF {oss_m['profit_factor']:.2f} → 场景 PF {tp_oss['pf']:.2f}。

+

动作:离场逻辑有改进空间,考虑让盈利单兑现更多利润(放宽止盈/调整提前离场条件)。

+ """) + + # ---- 规则8:参数稳健性 ---- + pf_diff = abs(is_m["profit_factor"] - oss_m["profit_factor"]) + if pf_diff > 0.2: + parts.append(f""" +

⑧ 参数稳健性

+

触发条件:两份报告 PF 差异 > 0.2。

+

数据:IS PF {is_m['profit_factor']:.2f},OSS PF {oss_m['profit_factor']:.2f},差 {pf_diff:.2f}。

+

动作:策略对行情敏感,建议做参数敏感度扫描找稳健高原(而非单点峰值),并用 Walk-Forward 验证。

+ """) + + if not parts: + parts.append("

当前数据未触发任何预设告警规则,策略各维度表现均在阈值内。

") + + return "".join(parts) + + +# =========================================================================== # +# HTML 主构建 +# =========================================================================== # +def build_html(reports: List[Tuple[str, mp.MT5Report]]) -> str: + a_name, a_rep = reports[0] + b_name, b_rep = reports[1] + a_m = extended_metrics(a_rep.trades) + b_m = extended_metrics(b_rep.trades) + a_s = a_rep.summary_norm + b_s = b_rep.summary_norm + + a_start, a_end = actual_range(a_rep) + b_start, b_end = actual_range(b_rep) + + a_wif = whatif_scenarios(a_rep) + b_wif = whatif_scenarios(b_rep) + a_mc = monte_carlo_dd(a_m["_net"]) + b_mc = monte_carlo_dd(b_m["_net"]) + + ea_name = a_rep.meta.get("专家", "—") + symbol = a_rep.meta.get("交易品种", "—") + + # ---------- 核心指标对比 ---------- + core_rows = [ + ("交易笔数", a_m["n_trades"], b_m["n_trades"], ""), + ("净盈利", a_m["net_profit"], b_m["net_profit"], ""), + ("盈利因子 PF", a_m["profit_factor"], b_m["profit_factor"], ""), + ("胜率 (%)", a_m["win_rate"], b_m["win_rate"], ""), + ("平均盈利", a_m["avg_win"], b_m["avg_win"], ""), + ("平均亏损", a_m["avg_loss"], b_m["avg_loss"], ""), + ("盈亏比 (avgW/|avgL|)", + a_m["avg_win"]/abs(a_m["avg_loss"]) if a_m["avg_loss"] else 0, + b_m["avg_win"]/abs(b_m["avg_loss"]) if b_m["avg_loss"] else 0, ""), + ("期望收益/笔", a_m["expectancy"], b_m["expectancy"], ""), + ("最大回撤", a_m["max_dd"], b_m["max_dd"], ""), + ("最大回撤 (%)", a_s.get("max_balance_dd_pct"), b_s.get("max_balance_dd_pct"), "%"), + ("夏普比率", a_s.get("sharpe"), b_s.get("sharpe"), ""), + ("Sortino (逐笔)", a_m["sortino"], b_m["sortino"], ""), + ("Calmar (净利/|回撤|)", a_m["calmar"], b_m["calmar"], ""), + ("最大连败", a_m["max_streak_loss"], b_m["max_streak_loss"], "笔"), + ("平均持仓 (分钟)", a_m["avg_duration_min"], b_m["avg_duration_min"], ""), + ("滚动PF 区间", f"{a_m['roll_pf_min']:.2f}~{a_m['roll_pf_max']:.2f}", + f"{b_m['roll_pf_min']:.2f}~{b_m['roll_pf_max']:.2f}", ""), + ("滚动窗口盈利比例", a_m["pct_profitable_window"], b_m["pct_profitable_window"], "%"), + ] + core_html = ( + "" + f"" + + "".join( + f"" + for n, iv, ov, u in core_rows + ) + + "
指标{html.escape(a_name)}{html.escape(b_name)}单位
{n}{fmt(iv)}{fmt(ov)}{u}
" + ) + + # ---------- 方向诊断 ---------- + all_dirs = sorted(set(list(a_m["by_direction"].keys()) + list(b_m["by_direction"].keys()))) + dir_rows = [] + for d in all_dirs: + di = a_m["by_direction"].get(d, {}) + do = b_m["by_direction"].get(d, {}) + rr_i = di.get('avg_win',0)/abs(di.get('avg_loss',1)) if di.get('avg_loss') else 0 + rr_o = do.get('avg_win',0)/abs(do.get('avg_loss',1)) if do.get('avg_loss') else 0 + for lbl, iv, ov in [ + ("笔数", di.get('n',0), do.get('n',0)), + ("胜率(%)", di.get('win_rate',0), do.get('win_rate',0)), + ("净盈利", di.get('net_profit',0), do.get('net_profit',0)), + ("PF", di.get('profit_factor',0), do.get('profit_factor',0)), + ("平均盈利", di.get('avg_win',0), do.get('avg_win',0)), + ("平均亏损", di.get('avg_loss',0), do.get('avg_loss',0)), + ("盈亏比", rr_i, rr_o), + ("期望/笔", di.get('expectancy',0), do.get('expectancy',0)), + ]: + cls = " class='neg'" if (isinstance(iv,(int,float)) and iv < 0) else "" + dir_rows.append(f"{d}{lbl}{fmt(iv)}{fmt(ov)}") + dir_html = ( + "" + f"" + + "".join(dir_rows) + "
方向指标{html.escape(a_name)}{html.escape(b_name)}
" + ) + + # ---------- What-If ---------- + wif_html = ( + f"

{html.escape(a_name)}

" + whatif_table(a_wif) + + f"

{html.escape(b_name)}

" + whatif_table(b_wif) + ) + + # ---------- 蒙特卡洛 ---------- + mc_html = ( + "" + f"" + f"" + f"" + f"" + f"" + f"" + + "
分位{html.escape(a_name)}{html.escape(b_name)}
实际最大回撤{a_mc['actual']:.2f}{b_mc['actual']:.2f}
蒙特卡洛 5% 分位(更糟){a_mc['p5']:.2f}{b_mc['p5']:.2f}
蒙特卡洛 中位{a_mc['p50']:.2f}{b_mc['p50']:.2f}
蒙特卡洛 95% 分位(较好){a_mc['p95']:.2f}{b_mc['p95']:.2f}
蒙特卡洛 均值{a_mc['mean']:.2f}{b_mc['mean']:.2f}
" + ) + + # ---------- 时段表 ---------- + hour_rows = [] + max_abs = max( + [abs(a_m["by_hour"].get(h, {}).get("sum", 0)) for h in range(24)] + + [abs(b_m["by_hour"].get(h, {}).get("sum", 0)) for h in range(24)] + ) or 1 + for h in range(24): + ih = a_m["by_hour"].get(h, {}) + oh = b_m["by_hour"].get(h, {}) + if not ih and not oh: + continue + a_sum = ih.get('sum', 0) + b_sum = oh.get('sum', 0) + ci = heatmap_color(a_sum, max_abs) + co = heatmap_color(b_sum, max_abs) + both_neg = "⚠️" if (a_sum < 0 and b_sum < 0) else "" + hour_rows.append( + f"{h:02d}" + f"{a_sum:+.2f}{ih.get('count',0)}" + f"{b_sum:+.2f}{oh.get('count',0)}" + f"{both_neg}" + ) + hour_html = ( + "" + f"" + f"" + + "".join(hour_rows) + "
小时{html.escape(a_name)}净{html.escape(b_name)}净双负
" + ) + + # ---------- 星期表 ---------- + wd_rows = [] + max_abs_w = max( + [abs(a_m["by_weekday"].get(d, {}).get("sum", 0)) for d in range(7)] + + [abs(b_m["by_weekday"].get(d, {}).get("sum", 0)) for d in range(7)] + ) or 1 + for d in range(7): + iw = a_m["by_weekday"].get(d, {}) + ow = b_m["by_weekday"].get(d, {}) + if not iw and not ow: + continue + a_sum = iw.get('sum', 0) + b_sum = ow.get('sum', 0) + ci = heatmap_color(a_sum, max_abs_w) + co = heatmap_color(b_sum, max_abs_w) + both_neg = "⚠️" if (a_sum < 0 and b_sum < 0) else "" + wd_rows.append( + f"{WEEKDAY_NAMES[d]}" + f"{a_sum:+.2f}{iw.get('count',0)}" + f"{b_sum:+.2f}{ow.get('count',0)}" + f"{both_neg}" + ) + wd_html = ( + "" + f"" + f"" + + "".join(wd_rows) + "
星期{html.escape(a_name)}净{html.escape(b_name)}净双负
" + ) + + # ---------- 持仓时间分桶 ---------- + dur_labels = ["<5m", "5-15m", "15-30m", "30-60m", "1-2h", ">2h"] + dur_rows = [] + for lbl in dur_labels: + ad = a_m["by_duration"].get(lbl, {}) + bd = b_m["by_duration"].get(lbl, {}) + if not ad and not bd: + continue + dur_rows.append( + f"{lbl}" + f"{ad.get('count',0)}{ad.get('sum',0):+.2f}{ad.get('mean',0):+.3f}" + f"{bd.get('count',0)}{bd.get('sum',0):+.2f}{bd.get('mean',0):+.3f}" + ) + dur_html = ( + "" + f"" + f"" + + "".join(dur_rows) + "
持仓区间{html.escape(a_name)}笔均值{html.escape(b_name)}笔均值
" + ) + + # ---------- SVG ---------- + equity_svg = svg_equity(reports) + dir_bar_items = [] + for d in all_dirs: + di = a_m["by_direction"].get(d, {}) + do = b_m["by_direction"].get(d, {}) + dir_bar_items.append((f"{d}-A", di.get('net_profit', 0))) + dir_bar_items.append((f"{d}-B", do.get('net_profit', 0))) + dir_svg = svg_bars(dir_bar_items, title="各方向净盈亏(A=报告A, B=报告B)") + a_mc_svg = svg_dd_hist(a_mc) + b_mc_svg = svg_dd_hist(b_mc) + + # ---------- 数据驱动建议 ---------- + advice_html = build_advice(reports) + + # ---------- Walk-Forward ---------- + # 对两份报告分别做滚动 IS→OOS 验证(窗口太短会自动返回空片段) + wf_a = wf.wf_html_fragment(wf.walk_forward(a_rep.trades, is_days=45, oos_days=30)) + wf_b = wf.wf_html_fragment(wf.walk_forward(b_rep.trades, is_days=45, oos_days=30)) + + # ---------- 元信息 ---------- + inputs = a_rep.meta.get("inputs", {}) + inputs_match = (inputs == b_rep.meta.get("inputs", {})) + inputs_html = "" + if inputs: + rows = "".join( + f"{html.escape(k)}{html.escape(v)}" + f"{'✓ 一致' if inputs_match else html.escape(b_rep.meta.get('inputs',{}).get(k,'—'))}" + for k, v in inputs.items() + ) + inputs_html = ( + "

输入参数

" + "" + + rows + "
参数报告A报告B
" + ) + + # ---------- 拼装 ---------- + doc = f""" + +EA 回测分析报告 +
+

EA 回测分析报告

+

EA: {html.escape(str(ea_name))} 品种: {html.escape(str(symbol))}  +报告A = {html.escape(a_name)} 报告B = {html.escape(b_name)}

+ +
+
报告A 净盈利
{a_m['net_profit']:+.2f}
+
报告B 净盈利
{b_m['net_profit']:+.2f}
+
PF (A / B)
{a_m['profit_factor']:.2f} / {b_m['profit_factor']:.2f}
+
回撤% (A / B)
{a_s.get('max_balance_dd_pct')}% / {b_s.get('max_balance_dd_pct')}%
+
+ +
+实际交易区间:报告A = {a_start} ~ {a_end};报告B = {b_start} ~ {b_end}。
+输入参数:{'完全一致' if inputs_match else '存在差异(见下表)'}。 +若两份报告参数相同而结果差异大,说明策略对行情敏感;若参数不同,需先确认对比是否公平。 +
+ +{inputs_html} + +

1. 核心指标对比

+{core_html} +

Sortino/Calmar 为逐笔口径估算;滚动 PF 以 100 笔窗口滑动,"滚动窗口盈利比例"=窗口 PF>1 的占比(越高越稳定)。

+ +

2. 资金曲线

+{equity_svg} + +

3. 方向性诊断

+{dir_html} +{dir_svg} +

若多空胜率/盈亏比显著不对称,是结构性偏差信号,需结合下方 What-If 与建议规则分析。

+ +

4. What-If 假设分析

+

对每份报告模拟多种改造场景,重算指标。场景为通用逻辑,不依赖具体 EA 参数。 +重点看 PF 是否站上 1、回撤是否收敛、期望是否转正

+{wif_html} +
+必看:"信号反向"场景若两份报告净盈利均转正且 PF>1,需警惕信号方向逻辑写反,应人工复核源码。 +
+ +

5. 蒙特卡洛回撤模拟

+

将逐笔盈亏随机打乱 1000 次,看顺序无关下的回撤分布。 +若"实际回撤"显著差于中位,说明存在连败聚集(顺序有自我相关),建议加连亏保护。

+
+

报告A 蒙特卡洛回撤

{a_mc_svg}
+

报告B 蒙特卡洛回撤

{b_mc_svg}
+
+{mc_html} + +

6. 时段诊断

+

6.1 按小时

+{hour_html} +

6.2 按星期

+{wd_html} +

⚠️ = 两份报告同时为负,属系统性劣势时段,应优先过滤。单元格颜色:红=负、绿=正。

+ +

7. 持仓时间分桶

+{dur_html} +

观察哪个持仓区间贡献正/负盈亏,可指导止盈止损时间维度调整。

+ +

8. 数据驱动的优化方向建议

+

以下建议由当前数据的特征触发(每条附触发条件、数据、动作),不预设任何策略特定结论。 +未触发的规则不显示,未列出的维度表示数据正常。

+
+{advice_html} +
+ +

9. Walk-Forward 滚动验证

+

将每份报告的逐笔交易按时间切成滚动窗口(IS 45天 + OOS 30天,步长 30天), + 计算每个窗口的 IS/OOS 指标。WFE = ΣOOS净 / ΣIS净,越高越稳健; + IS-OOS PF 相关性高=泛化好,低=过拟合风险。数据太短无法切窗会提示。

+

报告A: {html.escape(a_name)}

+{wf_a} +

报告B: {html.escape(b_name)}

+{wf_b} + +

10. 可选扩展分析

+

以下三项已实现为独立可复用工具,按需调用:

+
    +
  • 参数敏感度扫描python param_scan.py gen-set ... 生成网格 .set + 批处理脚本, + 在 MT5 跑完后 python param_scan.py analyze ... 出响应面热力图 + 3D 曲面 + 高原检测。 + python param_scan.py demo 可先看效果。
  • +
  • MAE/MFE 分析:MT5 标准 xlsx 不含逐笔 MAE/MFE,需先用 python mae_mfe.py --show-snippet + 取 MQL5 代码粘进 EA 导出 CSV,再 python mae_mfe.py mae_mfe.csv 出散点 + TP/SL 扫描热力图。
  • +
  • Walk-Forward 独立报告python walk_forward.py <report.xlsx> --is-days 60 --oos-days 30 + 出滚动窗口 IS/OOS 验证(本报告第 8 节已内嵌简化版)。
  • +
+

其它建议(未实现,需自行扩展):

+
    +
  • 多品种/多周期:同策略测多品种多周期,看泛化能力。
  • +
  • 交易成本敏感度:点差/手续费放大 1.5x、2x 看 PF 退化曲线,评估实盘可行性。
  • +
  • 信号因子分解:复合信号拆单因子分别回测,剔除拖累项。
  • +
+ +
由 run_analysis.py 自动生成 · 数据来自 MT5 Strategy Tester 导出的 xlsx · {pd.Timestamp.now().strftime('%Y-%m-%d %H:%M')}
+
+""" + return doc + + +# =========================================================================== # +# 控制台摘要 +# =========================================================================== # +def print_console(reports: List[Tuple[str, mp.MT5Report]]) -> None: + print("=" * 70) + for name, rep in reports: + m = extended_metrics(rep.trades) + s = rep.summary_norm + bd = m["by_direction"] + print(f"[{name}]") + print(f" 区间: {actual_range(rep)[0]} ~ {actual_range(rep)[1]}") + print(f" 笔数={m['n_trades']} 净={m['net_profit']:+.2f} PF={m['profit_factor']:.2f} " + f"胜率={m['win_rate']:.1f}% 回撤={s.get('max_balance_dd_pct')}% 夏普={s.get('sharpe')}") + for d, g in bd.items(): + print(f" {d}: 胜率={g['win_rate']:.1f}% 净={g['net_profit']:+.2f} PF={g['profit_factor']:.2f}") + wif = whatif_scenarios(rep) + print(f" --- What-If 关键 ---") + for sc_name in ["信号反向 (net × -1)", "盈利单放大 ×1.5"]: + for x in wif: + if x["name"] == sc_name: + print(f" {sc_name}: 净={x['net']:+.2f} PF={x['pf']:.2f} 回撤={x['dd']:.2f}") + break + print() + print("=" * 70) + + +def main(argv: List[str]) -> int: + os.makedirs(OUT_DIR, exist_ok=True) + reports = load_reports(argv) + print_console(reports) + html_doc = build_html(reports) + with open(HTML_PATH, "w", encoding="utf-8") as f: + f.write(html_doc) + print(f"\nHTML 报告已生成: {HTML_PATH}") + print("(自包含,无图片依赖,可直接浏览器打开 / AI 解析表格)") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/walk_forward.py b/walk_forward.py new file mode 100644 index 0000000..829168c --- /dev/null +++ b/walk_forward.py @@ -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 [--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'' + + # 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'' + f'{yv:.2f}') + one_line = f'' + + # 窗口标签(仅首/中/尾) + xlabs = "" + for i in [0, len(windows) // 2, len(windows) - 1]: + x = left + i / max(1, len(windows) - 1) * plot_w + xlabs += f'#{windows[i].idx}' + + return ( + f'' + f'{grid}{one_line}' + f'{poly(is_pfs, "#2563eb")}' + f'{poly(oos_pfs, "#dc2626")}' + f'{xlabs}' + f'滚动窗口 IS vs OOS 盈利因子 (PF=1 为盈亏平衡线)' + f'IS PF' + f'OOS PF' + f'窗口序号 →' + f'' + ) + + +# --------------------------------------------------------------------------- # +# HTML 片段(供 run_analysis.py 嵌入) +# --------------------------------------------------------------------------- # +def wf_html_fragment(windows: List[WFWindow]) -> str: + if not windows: + return "

数据不足以切出 Walk-Forward 窗口。

" + s = wf_summary(windows) + rows = "" + for w in windows: + oos_cls = " class='pos'" if w.oos_metrics["net"] > 0 else " class='neg'" + rows += ( + f"#{w.idx}" + f"{w.is_start:%Y-%m-%d}~{w.is_end:%Y-%m-%d}" + f"{w.oos_start:%Y-%m-%d}~{w.oos_end:%Y-%m-%d}" + f"{w.is_metrics['n']}" + f"{w.is_metrics['net']:+.2f}" + f"{w.is_metrics['pf']:.2f}" + f"{w.is_metrics['win']:.1f}%" + f"{w.oos_metrics['n']}" + f"{w.oos_metrics['net']:+.2f}" + f"{w.oos_metrics['pf']:.2f}" + f"{w.oos_metrics['win']:.1f}%" + f"{w.oos_metrics['dd']:.2f}" + ) + table = ( + "" + "" + "" + "" + "" + rows + "
窗口IS区间OOS区间IS笔IS净IS PFIS胜率OOS笔OOS净OOS PFOOS胜率OOS回撤
" + ) + + corr_str = f"{s['is_oos_pf_corr']:.2f}" if not np.isnan(s['is_oos_pf_corr']) else "—" + cards = ( + f"
" + f"
窗口数
{s['n_windows']}
" + f"
WFE (ΣOOS净/ΣIS净)
0 else 'neg'}'>{s['wfe']:+.2f}
" + f"
OOS 盈利窗口占比
{s['oos_profitable_pct']:.0f}%
" + f"
IS-OOS PF 相关性
{corr_str}
" + f"
" + ) + + interp = "" + if not np.isnan(s['is_oos_pf_corr']): + if s['is_oos_pf_corr'] > 0.6: + interp = "

IS 与 OOS 的 PF 相关性较高 → 参数对行情段有一定泛化能力。

" + elif s['is_oos_pf_corr'] < 0.2: + interp = "

IS 与 OOS 的 PF 相关性低 → IS 表现难以预测 OOS,参数不稳健,警惕过拟合单段行情。

" + else: + interp = "

IS/OOS PF 中等相关性,泛化能力中等。

" + if s['wfe'] < 0: + interp += "
WFE 为负:OOS 累计亏损。即使 IS 段盈利,策略也未能泛化。
" + elif s['wfe'] > 0.5: + interp += "
WFE > 0.5:OOS 能保留 IS 的一半以上盈利,泛化较好。
" + + 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""" +Walk-Forward 报告 — {html.escape(name)} +
+

Walk-Forward 滚动验证报告

+

来源: {html.escape(name)}

+{frag} +
""" + + +# --------------------------------------------------------------------------- # +# 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:]))