# -*- 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())}
""" # =========================================================================== # # 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:]))