Files
2026-07-11 03:22:50 +08:00

451 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
MAE / MFE 分析
==============
MT5 标准回测 xlsx 不含逐笔 MAE/MFE(最大不利/有利偏移),需要由 EA 在
OnDeinit 里把每笔交易的 MAE/MFE 写到 CSV。本模块:
1. mql5_snippet() : 返回可粘贴进 EA 的 MQL5 代码,自动导出 mae_mfe.csv
2. analyze(df) : 由 CSV 计算最优 TP/SL 区间
3. build_html(df) : 散点图 + 推荐区间
CSV 列约定(首行表头):
ticket,direction,open_time,close_time,open_price,close_price,
mae_points,mfe_points,profit_money
direction: long / short
mae_points: 持仓期间最大不利偏移(点,正数表示逆向走了多少点)
mfe_points: 持仓期间最大有利偏移(点,正数)
profit_money: 该笔最终盈亏(金额)
分析原理:
- MFE vs 盈亏散点:能涨到 X 点的笔里,最终盈利占比?→ 若大量高 MFE 笔最终
亏损,说明 TP 太贪;把 TP 放到 MFE 高胜率区间可改善。
- MAE vs 盈亏散点:逆向超过 Y 点的笔几乎全亏 → SL 设在 Y 内可避免大损。
- 用扫描法找使"理论净盈利"最大的 TP/SL 组合(基于历史 MAE/MFE 重算)。
用法:
python mae_mfe.py <mae_mfe.csv>
python mae_mfe.py --show-snippet # 打印 MQL5 代码
python mae_mfe.py --demo # 合成数据演示
"""
from __future__ import annotations
import argparse
import html
import os
import sys
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
import pandas as pd
OUT_DIR = "output"
# =========================================================================== #
# 1. MQL5 代码片段
# =========================================================================== #
def mql5_snippet() -> str:
"""返回 MQL5 代码:在 OnDeinit 里把每笔历史仓位的 MAE/MFE 写入 CSV。"""
return r'''// ====== MAE/MFE 日志片段(粘贴进 EA ======
// 在文件顶部加:
#include <Trade\DealInfo.mqh>
#include <Trade\PositionInfo.mqh>
// 在 OnDeinit(const int reason) 末尾加:
void ExportMAEMFE()
{
string fname = "mae_mfe_" + TimeToString(TimeCurrent(), TIME_DATE) + ".csv";
int h = FileOpen(fname, FILE_WRITE | FILE_CSV | FILE_ANSI, ',');
if(h == INVALID_HANDLE) { Print("MAE/MFE: 文件打开失败"); return; }
FileWrite(h, "ticket","direction","open_time","close_time","open_price","close_price",
"mae_points","mfe_points","profit_money");
HistorySelect(0, TimeCurrent() + 86400);
int total = HistoryDealsTotal();
// 按订单(order)配对 in/out
for(int i = 0; i < total; i++)
{
ulong dealIn = HistoryDealGetTicket(i);
if(HistoryDealGetInteger(dealIn, DEAL_ENTRY) != DEAL_ENTRY_IN) continue;
long order = HistoryDealGetInteger(dealIn, DEAL_ORDER);
ulong dealOut = 0;
// 找同 order 的 out 成交
for(int j = i + 1; j < total; j++)
{
ulong d = HistoryDealGetTicket(j);
if(HistoryDealGetInteger(d, DEAL_ORDER) == order &&
HistoryDealGetInteger(d, DEAL_ENTRY) == DEAL_ENTRY_OUT)
{ dealOut = d; break; }
}
if(dealOut == 0) continue;
datetime tIn = (datetime)HistoryDealGetInteger(dealIn, DEAL_TIME);
datetime tOut = (datetime)HistoryDealGetInteger(dealOut, DEAL_TIME);
double pIn = HistoryDealGetDouble(dealIn, DEAL_PRICE);
double pOut = HistoryDealGetDouble(dealOut, DEAL_PRICE);
long type = HistoryDealGetInteger(dealIn, DEAL_TYPE); // DEAL_TYPE_BUY / SELL
double profit = HistoryDealGetDouble(dealOut, DEAL_PROFIT)
+ HistoryDealGetDouble(dealOut, DEAL_SWAP)
+ HistoryDealGetDouble(dealOut, DEAL_COMMISSION);
string dir = (type == DEAL_TYPE_BUY) ? "long" : "short";
// 扫描持仓期间最高/最低价算 MAE/MFE(点)
double mfe = 0, mae = 0;
double pt = SymbolInfoDouble(_Symbol, SYMBOL_POINT);
for(datetime t = tIn; t <= tOut; t += PeriodSeconds())
{
double hi = iHigh(_Symbol, PERIOD_M1, iBarShift(_Symbol, PERIOD_M1, t, false));
double lo = iLow (_Symbol, PERIOD_M1, iBarShift(_Symbol, PERIOD_M1, t, false));
if(hi <= 0 || lo <= 0) continue;
if(type == DEAL_TYPE_BUY)
{
double fav = (hi - pIn) / pt; // 有利
double adv = (pIn - lo) / pt; // 不利
if(fav > mfe) mfe = fav;
if(adv > mae) mae = adv;
}
else
{
double fav = (pIn - lo) / pt;
double adv = (hi - pIn) / pt;
if(fav > mfe) mfe = fav;
if(adv > mae) mae = adv;
}
}
FileWrite(h, order, dir, TimeToString(tIn), TimeToString(tOut),
DoubleToString(pIn, _Digits), DoubleToString(pOut, _Digits),
DoubleToString(mae, 1), DoubleToString(mfe, 1),
DoubleToString(profit, 2));
}
FileClose(h);
Print("MAE/MFE 已导出: ", fname);
}
// 在 OnDeinit 里调用: ExportMAEMFE();
// ====== 片段结束 ======
'''
# =========================================================================== #
# 2. 加载
# =========================================================================== #
def load_csv(path: str) -> pd.DataFrame:
df = pd.read_csv(path)
# 列名容错
rename = {
"mae_points": "mae", "mfe_points": "mfe",
"profit_money": "profit",
"open_price": "open", "close_price": "close",
}
df = df.rename(columns={k: v for k, v in rename.items() if k in df.columns})
for c in ("mae", "mfe", "profit"):
if c in df.columns:
df[c] = pd.to_numeric(df[c], errors="coerce")
return df
# =========================================================================== #
# 3. 分析:最优 TP/SL
# =========================================================================== #
def analyze(df: pd.DataFrame, tp_grid: Optional[List[float]] = None,
sl_grid: Optional[List[float]] = None) -> Dict[str, Any]:
"""
扫描 TP/SL 组合,找使理论净盈利最大的参数。
理论模型:对每笔,若 mfe >= TP 则按 TP 离场(盈利 = TP×点值);
若 mae >= SL 则按 SL 离场(亏损 = -SL×点值);
否则按原 profit。
这里用"点数口径"近似:盈利/亏损点数替代金额(点值统一假设)。
"""
if df.empty:
return {}
if tp_grid is None:
mfe_max = float(df["mfe"].max())
tp_grid = list(np.linspace(0, mfe_max, 30))
if sl_grid is None:
mae_max = float(df["mae"].max())
sl_grid = list(np.linspace(0, mae_max, 30))
mae = df["mae"].to_numpy()
mfe = df["mfe"].to_numpy()
n = len(df)
n_sim = len(sl_grid) * len(tp_grid)
# 向量化:构造 (n_sim, n) 矩阵,避免 Python 嵌套循环
mae_arr = np.broadcast_to(mae, (len(sl_grid), len(tp_grid), n)).reshape(-1, n)
mfe_arr = np.broadcast_to(mfe, (len(sl_grid), len(tp_grid), n)).reshape(-1, n)
sl_arr = np.tile(np.array(sl_grid)[:, np.newaxis], (1, n)).reshape(-1, n)
tp_arr = np.tile(np.array(tp_grid)[np.newaxis, :], (len(sl_grid), n)).reshape(-1, n)
# 优先 SL 触发(更保守)
result_pts = np.where(mae_arr >= sl_arr, -sl_arr,
np.where(mfe_arr >= tp_arr, tp_arr,
(mfe_arr - mae_arr) * 0.5))
net_arr = result_pts.sum(axis=1)
wins_arr = (result_pts > 0).sum(axis=1)
losses_arr = -(result_pts[result_pts <= 0]).sum(axis=1)
pf_arr = np.where(losses_arr > 0, result_pts[result_pts > 0].sum(axis=1) / losses_arr, np.inf)
wr_arr = wins_arr / n * 100
# 找最佳
best_i = int(np.nanargmax(net_arr))
grid_df = pd.DataFrame({
"tp": np.repeat(np.array(tp_grid), len(sl_grid)),
"sl": np.tile(np.array(sl_grid), len(tp_grid)),
"net": net_arr,
"win_rate": wr_arr,
"pf": pf_arr,
})
best = {"tp": float(tp_grid[best_i % len(tp_grid)]),
"sl": float(sl_grid[best_i // len(tp_grid)]),
"net": float(net_arr[best_i]),
"win_rate": float(wr_arr[best_i]),
"pf": float(pf_arr[best_i])}
# MAE/MFE 分布统计
mae_threshold = float(df.loc[df["profit"] <= 0, "mae"].quantile(0.75)) if (df["profit"] <= 0).any() else 0
mfe_threshold = float(df.loc[df["profit"] > 0, "mfe"].quantile(0.5)) if (df["profit"] > 0).any() else 0
return {
"best": best,
"grid": grid_df,
"mae_q3_of_losers": mae_threshold, # 75% 亏损笔的 MAE 上限 → SL 候选
"mfe_median_of_winners": mfe_threshold, # 盈利笔 MFE 中位 → TP 候选
"n": n,
"profit_corr_mae": float(df["profit"].corr(df["mae"])),
"profit_corr_mfe": float(df["profit"].corr(df["mfe"])),
}
# =========================================================================== #
# 4. SVG 散点
# =========================================================================== #
def svg_scatter(x: np.ndarray, y: np.ndarray, profit: np.ndarray,
xlabel: str, ylabel: str, title: str,
threshold: Optional[float] = None, threshold_label: str = "",
w: int = 460, h: int = 320) -> str:
top, bottom, left, right = 30, 40, 50, 16
plot_w = w - left - right
plot_h = h - top - bottom
x_min, x_max = float(x.min()), float(x.max())
y_min, y_max = float(y.min()), float(y.max())
if x_max == x_min: x_max = x_min + 1
if y_max == y_min: y_max = y_min + 1
def sx(v): return left + (v - x_min) / (x_max - x_min) * plot_w
def sy(v): return top + plot_h - (v - y_min) / (y_max - y_min) * plot_h
pts = ""
for xi, yi, pi in zip(x, y, profit):
col = "#16a34a" if pi > 0 else "#dc2626"
pts += f'<circle cx="{sx(xi):.1f}" cy="{sy(yi):.1f}" r="2.2" fill="{col}" opacity="0.45"><title>{xlabel}={xi:.1f}, {ylabel}={yi:.2f}, profit={pi:.2f}</title></circle>'
thr = ""
if threshold is not None and x_min <= threshold <= x_max:
tx = sx(threshold)
thr = (f'<line x1="{tx:.1f}" y1="{top}" x2="{tx:.1f}" y2="{top+plot_h}" stroke="#1e3a8a" stroke-width="1.5" stroke-dasharray="4,3"/>'
f'<text x="{tx+4:.1f}" y="{top+12:.1f}" font-size="9" fill="#1e3a8a">{html.escape(threshold_label)}={threshold:.1f}</text>')
grid = ""
for frac in (0, 0.5, 1.0):
xv = x_min + frac * (x_max - x_min)
yv = y_min + frac * (y_max - y_min)
gx = left + frac * plot_w
gy = top + plot_h - frac * plot_h
grid += (f'<line x1="{gx:.1f}" y1="{top}" x2="{gx:.1f}" y2="{top+plot_h}" stroke="#f1f5f9" stroke-width="0.5"/>'
f'<text x="{gx:.1f}" y="{h-bottom+14:.1f}" font-size="9" fill="#6b7280" text-anchor="middle">{xv:.0f}</text>'
f'<line x1="{left}" y1="{gy:.1f}" x2="{w-right}" y2="{gy:.1f}" stroke="#f1f5f9" stroke-width="0.5"/>'
f'<text x="{left-6:.1f}" y="{gy+3:.1f}" font-size="9" fill="#6b7280" text-anchor="end">{yv:.0f}</text>')
return (f'<svg viewBox="0 0 {w} {h}" class="chart">'
f'<text x="{w//2}" y="16" font-size="11" fill="#374151" text-anchor="middle">{html.escape(title)}</text>'
f'{grid}{pts}{thr}'
f'<text x="{w//2}" y="{h-4}" font-size="9" fill="#6b7280" text-anchor="middle">{html.escape(xlabel)} →</text>'
f'<text x="14" y="{h//2}" font-size="9" fill="#6b7280" text-anchor="middle" transform="rotate(-90 14 {h//2})">{html.escape(ylabel)} →</text>'
f'</svg>')
def svg_tp_sl_heatmap(grid_df: pd.DataFrame, w: int = 460, h: int = 360) -> str:
"""TP×SL 净盈利热力图。"""
p = grid_df.pivot_table(index="sl", columns="tp", values="net", aggfunc="first")
p = p.sort_index().sort_index(axis=1)
Z = p.to_numpy()
xs = list(p.columns)
ys = list(p.index)
top, bottom, left, right = 30, 40, 60, 16
plot_w = w - left - right
plot_h = h - top - bottom
ny, nx = Z.shape
cw = plot_w / nx
ch = plot_h / ny
valid = Z[~np.isnan(Z)]
vmin, vmax = float(valid.min()), float(valid.max())
if vmax == vmin: vmax = vmin + 1
def col(v):
if np.isnan(v): return "#f3f4f6"
r = (v - vmin) / (vmax - vmin)
if r < 0.5:
t = r * 2
return f"rgb({int(220-60*t)},{int(60+160*t)},{int(60+40*t)})"
t = (r - 0.5) * 2
return f"rgb({int(160-100*t)},{int(220-40*t)},{int(100-40*t)})"
cells = ""
for j in range(ny):
for i in range(nx):
v = Z[j, i]
x = left + i * cw
y = top + (ny - 1 - j) * ch
cells += f'<rect x="{x:.1f}" y="{y:.1f}" width="{cw:.1f}" height="{ch:.1f}" fill="{col(v)}" stroke="#fff" stroke-width="0.4"/>'
# 最优点
bi = np.unravel_index(np.nanargmax(Z), Z.shape)
bx = left + bi[1] * cw + cw / 2
by = top + (ny - 1 - bi[0]) * ch + ch / 2
marker = f'<circle cx="{bx:.1f}" cy="{by:.1f}" r="6" fill="none" stroke="#1e3a8a" stroke-width="2"/>'
xlabs = "".join(f'<text x="{left+(i+0.5)*cw:.1f}" y="{h-bottom+14:.1f}" font-size="8" fill="#374151" text-anchor="middle">{xs[i]:.0f}</text>' for i in range(0, nx, 2))
ylabs = "".join(f'<text x="{left-6:.1f}" y="{top+(ny-0.5-j)*ch+3:.1f}" font-size="8" fill="#374151" text-anchor="end">{ys[j]:.0f}</text>' for j in range(0, ny, 2))
return (f'<svg viewBox="0 0 {w} {h}" class="chart">'
f'<text x="{w//2}" y="16" font-size="11" fill="#374151" text-anchor="middle">理论净盈利 (TP × SL 扫描) — 圈=最优</text>'
f'{cells}{marker}{xlabs}{ylabs}'
f'<text x="{w//2}" y="{h-4}" font-size="9" fill="#6b7280" text-anchor="middle">TP(点) →</text>'
f'<text x="14" y="{h//2}" font-size="9" fill="#6b7280" text-anchor="middle" transform="rotate(-90 14 {h//2})">SL(点) →</text>'
f'</svg>')
# =========================================================================== #
# 5. HTML
# =========================================================================== #
def build_html(df: pd.DataFrame, res: Dict[str, Any]) -> str:
mae_svg = svg_scatter(
df["mae"].to_numpy(), df["profit"].to_numpy(), df["profit"].to_numpy(),
"MAE(点)", "盈利($)", "MAE vs 盈亏(绿=赢/红=输)",
threshold=res["mae_q3_of_losers"], threshold_label="SL候选",
)
mfe_svg = svg_scatter(
df["mfe"].to_numpy(), df["profit"].to_numpy(), df["profit"].to_numpy(),
"MFE(点)", "盈利($)", "MFE vs 盈亏(绿=赢/红=输)",
threshold=res["mfe_median_of_winners"], threshold_label="TP候选",
)
tpsl_svg = svg_tp_sl_heatmap(res["grid"])
b = res["best"]
cards = (
"<div class='cards'>"
f"<div class='card'><div class='k'>样本笔数</div><div class='v'>{res['n']}</div></div>"
f"<div class='card'><div class='k'>最优 TP(点)</div><div class='v'>{b['tp']:.0f}</div></div>"
f"<div class='card'><div class='k'>最优 SL(点)</div><div class='v'>{b['sl']:.0f}</div></div>"
f"<div class='card'><div class='k'>理论净盈利(点)</div><div class='v pos'>{b['net']:.0f}</div></div>"
f"<div class='card'><div class='k'>理论胜率</div><div class='v'>{b['win_rate']:.1f}%</div></div>"
f"<div class='card'><div class='k'>理论 PF</div><div class='v'>{b['pf']:.2f}</div></div>"
"</div>"
)
interp = ""
if res["profit_corr_mae"] < -0.3:
interp += f"<p class='muted'>盈亏与 MAE 负相关 ({res['profit_corr_mae']:.2f}):逆向偏移越大越易亏,设 SL 合理。SL 候选 = 亏损笔 MAE 75 分位 ≈ <b>{res['mae_q3_of_losers']:.0f} 点</b>。</p>"
if res["profit_corr_mfe"] > 0.3:
interp += f"<p class='muted'>盈亏与 MFE 正相关 ({res['profit_corr_mfe']:.2f}):能跑到 X 点的笔更易盈利,TP 候选 = 盈利笔 MFE 中位 ≈ <b>{res['mfe_median_of_winners']:.0f} 点</b>。</p>"
if not interp:
interp = "<p class='muted'>MAE/MFE 与盈亏相关性弱,TP/SL 优化空间有限,建议关注入场逻辑。</p>"
interp += (f"<div class='note'><b>理论最优 TP/SL</b>TP={b['tp']:.0f}点 / SL={b['sl']:.0f}点,"
f"在该组合下重算净盈利 {b['net']:.0f}点、胜率 {b['win_rate']:.1f}%、PF {b['pf']:.2f}。"
f"此为基于历史 MAE/MFE 的理论值,需回测验证。</div>")
return f"""<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
<title>MAE/MFE 分析</title>
<style>
body {{ font-family:-apple-system,"Microsoft YaHei",sans-serif; background:#f8fafc; color:#111827; margin:0; padding:20px;}}
.wrap {{ max-width:1100px; margin:0 auto;}}
h1,h2 {{ color:#1e3a8a;}} h1 {{ border-bottom:3px solid #1e3a8a; padding-bottom:8px;}}
.muted {{ color:#6b7280; font-size:12px;}}
.cards {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:12px; margin:16px 0;}}
.card {{ background:#fff; padding:12px; border-radius:8px; border:1px solid #e2e8f0;}}
.card .k {{ color:#6b7280; font-size:11px;}} .card .v {{ font-size:18px; font-weight:600; margin-top:4px;}}
.card .v.pos {{ color:#16a34a;}}
.chart {{ width:100%; height:auto; background:#fff; border:1px solid #e2e8f0; border-radius:6px;}}
.grid2 {{ display:grid; grid-template-columns:1fr 1fr; gap:12px;}}
.note {{ background:#fef3c7; border-left:4px solid #f59e0b; padding:10px 14px; margin:12px 0; border-radius:4px;}}
pre {{ background:#1e293b; color:#e2e8f0; padding:14px; border-radius:6px; overflow:auto; font-size:11px;}}
footer {{ color:#6b7280; font-size:11px; margin-top:32px; text-align:center;}}
</style></head><body><div class="wrap">
<h1>MAE / MFE 分析报告</h1>
<p class='muted'>样本: {res['n']} 笔 · 盈亏-MAE 相关 {res['profit_corr_mae']:.2f} · 盈亏-MFE 相关 {res['profit_corr_mfe']:.2f}</p>
{cards}
{interp}
<h2>1. MAE / MFE 散点</h2>
<div class="grid2"><div>{mae_svg}</div><div>{mfe_svg}</div></div>
<p class='muted'>蓝虚线 = 推荐的 SL/TP 候选阈值。MAE 图:右侧红点=逆向走很远还亏=该早止损;MFE 图:右侧绿点=涨得高最终赚=TP 可放到此处。</p>
<h2>2. TP × SL 扫描热力图</h2>
{tpsl_svg}
<p class='muted'>圈=理论最优组合。色越绿=净盈利越高。注意"宽绿区"比"单点深绿"更稳健。</p>
<h2>3. 如何获取 MAE/MFE 数据</h2>
<p class='muted'>MT5 标准 xlsx 报告不含逐笔 MAE/MFE。需在 EA 的 OnDeinit 里加如下代码导出 CSV,再喂给本工具:</p>
<pre>{html.escape(mql5_snippet())}</pre>
<footer>由 mae_mfe.py 生成 · {pd.Timestamp.now().strftime('%Y-%m-%d %H:%M')}</footer>
</div></body></html>"""
# =========================================================================== #
# 6. demo
# =========================================================================== #
def _demo_df() -> pd.DataFrame:
rng = np.random.default_rng(3)
n = 400
direction = rng.choice(["long", "short"], n)
mae = np.abs(rng.normal(20, 15, n))
mfe = np.abs(rng.normal(40, 20, n))
# 盈亏与 mfe 正相关、与 mae 负相关
base = (mfe - mae) * 0.3 + rng.normal(0, 5, n)
profit = np.where(base > 0, mfe * 0.2, -mae * 0.25) + rng.normal(0, 2, n)
return pd.DataFrame({
"ticket": np.arange(n),
"direction": direction,
"mae": np.round(mae, 1),
"mfe": np.round(mfe, 1),
"profit": np.round(profit, 2),
})
# =========================================================================== #
# CLI
# =========================================================================== #
def main(argv: List[str]) -> int:
ap = argparse.ArgumentParser(description="MAE/MFE 分析")
ap.add_argument("csv", nargs="?", help="mae_mfe.csv 路径")
ap.add_argument("--show-snippet", action="store_true", help="打印 MQL5 代码片段")
ap.add_argument("--demo", action="store_true")
args = ap.parse_args(argv)
os.makedirs(OUT_DIR, exist_ok=True)
if args.show_snippet:
print(mql5_snippet())
return 0
if args.demo:
df = _demo_df()
else:
if not args.csv:
ap.error("需要 csv 路径,或 --demo,或 --show-snippet")
df = load_csv(args.csv)
res = analyze(df)
html_doc = build_html(df, res)
out = os.path.join(OUT_DIR, "mae_mfe.html")
with open(out, "w", encoding="utf-8") as f:
f.write(html_doc)
print(f"HTML 报告: {out}")
print(f"最优 TP={res['best']['tp']:.0f}点 SL={res['best']['sl']:.0f}点 "
f"理论净盈利={res['best']['net']:.0f}点 PF={res['best']['pf']:.2f}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))