#!/usr/bin/env python3 """Generate SimpleEMA LaTeX report -> PDF + PNG. WARNING: Reads Python backtest (single-symbol). Portfolio official report: best_run/MT5_PORTFOLIO_REPORT.md (from MT5 Strategy Tester) """ from __future__ import annotations import json import shutil import subprocess import textwrap from datetime import datetime from pathlib import Path import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import pandas as pd ROOT = Path(__file__).resolve().parent OUT = ROOT / "best_run" FIG = OUT / "figures" TEX = OUT / "SimpleEMA_report.tex" PDF = OUT / "SimpleEMA_report.pdf" PNG = OUT / "SimpleEMA_report.png" plt.rcParams.update({"figure.dpi": 150, "savefig.dpi": 150, "font.size": 9}) def latex_escape(s: str) -> str: for a, b in (("\\", "\\textbackslash{}"), ("&", "\\&"), ("%", "\\%"), ("$", "\\$"), ("#", "\\#"), ("_", "\\_"), ("{", "\\{"), ("}", "\\}")): s = s.replace(a, b) return s def load_data() -> tuple[dict, dict, pd.DataFrame]: with open(ROOT / "best_params.json", encoding="utf-8") as f: bp = json.load(f) summary_path = OUT / "report.json" if summary_path.exists(): summary = json.loads(summary_path.read_text(encoding="utf-8")) else: summary = bp.get("metrics", {}) trades = pd.read_csv(OUT / "trades.csv") trades["open_time"] = pd.to_datetime(trades["open_time"]) trades["close_time"] = pd.to_datetime(trades["close_time"]) return bp, summary, trades def save_figures(trades: pd.DataFrame, summary: dict) -> None: FIG.mkdir(parents=True, exist_ok=True) bal0 = summary.get("initial_balance", 10_000.0) eq = bal0 + trades.sort_values("close_time")["profit"].cumsum() times = trades.sort_values("close_time")["close_time"] dd = (eq - eq.cummax()) / eq.cummax() * 100 fig, ax = plt.subplots(figsize=(8, 3.2)) ax.plot(times, eq, color="#2ca02c", lw=1.6) ax.axhline(bal0, ls="--", color="#888", lw=0.8) ax.set_title("Equity Curve") ax.set_ylabel("Balance (USD)") ax.grid(alpha=0.3) fig.tight_layout() fig.savefig(FIG / "equity.pdf", bbox_inches="tight") fig.savefig(FIG / "equity.png", bbox_inches="tight") plt.close(fig) fig, ax = plt.subplots(figsize=(8, 2.8)) ax.fill_between(times, dd, 0, color="#d62728", alpha=0.35) ax.plot(times, dd, color="#8b0000", lw=0.8) ax.set_title("Drawdown") ax.set_ylabel("Drawdown (%)") ax.grid(alpha=0.3) fig.tight_layout() fig.savefig(FIG / "drawdown.pdf", bbox_inches="tight") fig.savefig(FIG / "drawdown.png", bbox_inches="tight") plt.close(fig) monthly = trades.copy() monthly["month"] = monthly["close_time"].dt.to_period("M") mp = monthly.groupby("month")["profit"].sum() fig, ax = plt.subplots(figsize=(8, 3)) colors = ["#2ca02c" if v >= 0 else "#d62728" for v in mp.values] ax.bar(range(len(mp)), mp.values, color=colors, width=0.85) ax.set_title("Monthly PnL") ax.set_ylabel("USD") ax.axhline(0, color="black", lw=0.6) ax.set_xticks(range(0, len(mp), max(1, len(mp) // 8))) ax.set_xticklabels([str(m) for m in mp.index[:: max(1, len(mp) // 8)]], rotation=45, ha="right") fig.tight_layout() fig.savefig(FIG / "monthly.pdf", bbox_inches="tight") fig.savefig(FIG / "monthly.png", bbox_inches="tight") plt.close(fig) rc = trades["exit_reason"].value_counts() fig, ax = plt.subplots(figsize=(5, 3)) ax.bar(rc.index.astype(str), rc.values, color="#ff7f0e") ax.set_title("Exit Reasons") ax.set_ylabel("Count") fig.tight_layout() fig.savefig(FIG / "exits.pdf", bbox_inches="tight") fig.savefig(FIG / "exits.png", bbox_inches="tight") plt.close(fig) fig, ax = plt.subplots(figsize=(5, 3)) ax.hist(trades["profit"], bins=20, color="#9467bd", alpha=0.85, edgecolor="white") ax.axvline(0, color="black", lw=0.8) ax.set_title("Per-Trade PnL Distribution") ax.set_xlabel("Profit (USD)") fig.tight_layout() fig.savefig(FIG / "pnl_hist.pdf", bbox_inches="tight") fig.savefig(FIG / "pnl_hist.png", bbox_inches="tight") plt.close(fig) def trade_table_rows(trades: pd.DataFrame, n: int = 12, best: bool = True) -> str: col = "profit" sub = trades.nlargest(n, col) if best else trades.nsmallest(n, col) lines = [] for _, r in sub.iterrows(): lines.append( f"{r['side']} & {r['open_time'].strftime('%Y-%m-%d %H:%M')} & " f"{r['close_time'].strftime('%Y-%m-%d %H:%M')} & " f"{r['profit']:.2f} & {latex_escape(str(r['exit_reason']))} \\\\" ) return "\n".join(lines) def build_tex(bp: dict, summary: dict, trades: pd.DataFrame) -> str: p = bp["params"] version = int(bp.get("version", 2)) net = summary.get("net_profit", 0) if version >= 5: param_rows = [ ("快 EMA / 慢 EMA", f"{p['fast_ema']} / {p['slow_ema']}"), ("入场", "交叉 + 趋势段回调" if p.get("use_pullback") else "仅交叉"), ("趋势段长度", f"{p.get('trend_leg_bars', '-')} bars"), ("交叉冷却", f"{p.get('cross_cooldown', '-')} bars"), ("回调冷却", f"{p.get('pullback_cooldown', '-')} bars"), ("回调 ADX 下限", str(p.get("pullback_adx_min", "-"))), ("回调最小间距", f"{p.get('pullback_min_gap_pips', '-')} pips"), ("每段最多回调", str(p.get("max_pullbacks_per_leg", 1))), ("ATR 周期", str(p["atr_period"])), ("止损 SL", f"ATR $\\times$ {p['atr_sl_mult']}"), ("止盈 TP", f"ATR $\\times$ {p['atr_tp_mult']}"), ("最大持仓", f"{p['max_bars_in_trade']} bars M15"), ("H4 EMA 过滤", f"EMA({p['htf_ema_period']})" if p.get("use_htf_filter") else "关"), ("交易时段 (UTC)", f"{p['session_start']}:00 -- {p['session_end']}:00"), ("最大点差", f"{p['max_spread_pips']} pips"), ("手数", str(p["lot_size"])), ] logic_note = ( "v5 逻辑:EMA 交叉为主入场;仅在活跃趋势段内允许一次高质量回调" "(ADX/间距过滤),避免 v3 多层过滤导致样本过少。" ) else: param_rows = [ ("快 EMA / 慢 EMA", f"{p['fast_ema']} / {p['slow_ema']}"), ("入场模式", "EMA 交叉 (mode=0)"), ("最小 EMA 间距", f"{p['min_ema_gap_pips']} pips"), ("冷却 K 线", str(p["cooldown_bars"])), ("ATR 周期", str(p["atr_period"])), ("止损 SL", f"ATR $\\times$ {p['atr_sl_mult']}"), ("止盈 TP", f"ATR $\\times$ {p['atr_tp_mult']}"), ("反向交叉平仓", "否" if not p.get("exit_on_cross") else "是"), ("最大持仓", f"{p['max_bars_in_trade']} bars M15"), ("H4 EMA 过滤", f"EMA({p['htf_ema_period']})" if p.get("use_htf_filter") else "关"), ("交易时段 (UTC)", f"{p['session_start']}:00 -- {p['session_end']}:00"), ("最大点差", f"{p['max_spread_pips']} pips"), ("手数", str(p["lot_size"])), ] logic_note = "v2 逻辑:EMA 交叉 + H4 趋势过滤。" param_tex = "\n".join(f"{k} & {v} \\\\" for k, v in param_rows) if version >= 5: strategy_tex = textwrap.dedent(rf""" \begin{{enumerate}} \item \textbf{{交叉入场}}:M15 EMA({p["fast_ema"]}/{p["slow_ema"]}) 金叉/死叉 + H4 趋势过滤。 \item \textbf{{回调入场}}:仅在趋势段({p.get("trend_leg_bars", 48)} bars)内,价格回踩 EMA 后收回;ADX $\ge$ {p.get("pullback_adx_min", 0)};每段最多 {p.get("max_pullbacks_per_leg", 1)} 次。 \item \textbf{{过滤}}:UTC {p["session_start"]}:00--{p["session_end"]}:00;点差 $\le$ {p["max_spread_pips"]} pips。 \item \textbf{{风控}}:SL = ATR({p["atr_period"]}) $\times$ {p["atr_sl_mult"]},TP = ATR $\times$ {p["atr_tp_mult"]}。 \item \textbf{{冷却}}:交叉 {p.get("cross_cooldown", "-")} bars;回调 {p.get("pullback_cooldown", "-")} bars。 \end{{enumerate}} """) summary_note = ( f"未达到 2000--3000 笔目标(当前 {summary.get('total_trades', len(trades))} 笔)," f"但 v5 在 v2 约 81 笔基础上提升到 {summary.get('total_trades', len(trades))} 笔且保持 PF>1。" + logic_note ) else: strategy_tex = textwrap.dedent(rf""" \begin{{enumerate}} \item \textbf{{入场}}:M15 上 EMA({p["fast_ema"]}/{p["slow_ema"]}) 金叉/死叉,最小间距 {p["min_ema_gap_pips"]} pips。 \item \textbf{{过滤}}:价格须在 H4 EMA({p["htf_ema_period"]}) 趋势同侧;UTC {p["session_start"]}:00--{p["session_end"]}:00;点差 $\le$ {p["max_spread_pips"]} pips。 \item \textbf{{风控}}:SL = ATR({p["atr_period"]}) $\times$ {p["atr_sl_mult"]},TP = ATR $\times$ {p["atr_tp_mult"]}。 \item \textbf{{出场}}:触及 SL/TP,或持仓超过 {p["max_bars_in_trade"]} 根 M15 K 线。 \item \textbf{{冷却}}:每笔交易后等待 {p.get("cooldown_bars", "-")} 根 K 线再入场。 \end{{enumerate}} """) summary_note = ( f"未达到 2000--3000 笔交易目标(当前 {summary.get('total_trades', len(trades))} 笔)。" + logic_note ) exit_counts = trades["exit_reason"].value_counts() exit_tex = "\n".join( f"{latex_escape(str(k))} & {v} & {v / len(trades) * 100:.1f}\\% \\\\" for k, v in exit_counts.items() ) return textwrap.dedent(rf""" \documentclass[11pt,a4paper]{{ctexart}} \usepackage{{graphicx}} \usepackage{{booktabs}} \usepackage{{geometry}} \usepackage{{float}} \usepackage{{xcolor}} \usepackage{{hyperref}} \geometry{{margin=2cm}} \definecolor{{pos}}{{RGB}}{{44,160,44}} \definecolor{{neg}}{{RGB}}{{214,39,40}} \title{{SimpleEMA 最优参数回测报告\\ \large EURUSD M15 · 2020--2026 · 最终版}} \author{{自动生成 · lab/EAs/SimpleEMA}} \date{{{datetime.now().strftime("%Y-%m-%d")}}} \begin{{document}} \maketitle \section{{执行摘要}} 本报告为 SimpleEMA 策略在修复 trailing-stop 模拟 bug 后,经 6000+ 次随机搜索得到的\textbf{{真实最优}}参数配置。 回测含点差与滑点,非 MT5 测试器 HTML 导出。 \begin{{table}}[H] \centering \caption{{关键绩效指标}} \begin{{tabular}}{{lr}} \toprule 指标 & 数值 \\ \midrule 货币对 / 周期 & {latex_escape(summary.get("symbol", "EURUSD"))} / M15 \\ 回测区间 & 2020-01-01 $\sim$ 2026-01-01 \\ 初始资金 & \${summary.get("initial_balance", 10000):,.0f} \\ \textbf{{净利润}} & \textbf{{\textcolor{{pos}}{{+\${net:,.2f}}}}} \\ 收益率 & {summary.get("return_pct", 0):.2f}\% \\ 总交易数 & {summary.get("total_trades", len(trades))} \\ 胜率 & {summary.get("win_rate", 0):.1f}\% \\ 盈利因子 PF & {summary.get("profit_factor", 0):.2f} \\ 最大回撤 & {summary.get("max_drawdown_pct", 0):.2f}\% \\ 平均盈利 / 亏损 & \${summary.get("avg_win", 0):.2f} / \${summary.get("avg_loss", 0):.2f} \\ 最佳 / 最差单笔 & \${summary.get("best_trade", 0):.2f} / \${summary.get("worst_trade", 0):.2f} \\ \bottomrule \end{{tabular}} \end{{table}} \noindent\textbf{{说明:}}{latex_escape(summary_note)} \section{{权益曲线与回撤}} \begin{{figure}}[H] \centering \includegraphics[width=0.92\textwidth]{{figures/equity.pdf}} \caption{{账户权益曲线}} \end{{figure}} \begin{{figure}}[H] \centering \includegraphics[width=0.92\textwidth]{{figures/drawdown.pdf}} \caption{{回撤百分比}} \end{{figure}} \section{{月度盈亏与出场结构}} \begin{{figure}}[H] \centering \begin{{minipage}}{{0.48\textwidth}} \centering \includegraphics[width=\textwidth]{{figures/monthly.pdf}} \caption{{逐月 PnL}} \end{{minipage}}\hfill \begin{{minipage}}{{0.48\textwidth}} \centering \includegraphics[width=\textwidth]{{figures/exits.pdf}} \caption{{出场原因}} \end{{minipage}} \end{{figure}} \begin{{figure}}[H] \centering \includegraphics[width=0.55\textwidth]{{figures/pnl_hist.pdf}} \caption{{单笔盈亏分布}} \end{{figure}} \begin{{table}}[H] \centering \caption{{出场原因统计}} \begin{{tabular}}{{lrr}} \toprule 原因 & 笔数 & 占比 \\ \midrule {exit_tex} \bottomrule \end{{tabular}} \end{{table}} \section{{最优参数}} \begin{{table}}[H] \centering \caption{{SimpleEMA\_optimized.set 对应参数}} \begin{{tabular}}{{ll}} \toprule 参数 & 值 \\ \midrule {param_tex} \bottomrule \end{{tabular}} \end{{table}} \section{{策略逻辑}} {strategy_tex} \section{{逐单复盘(节选)}} \subsection{{最佳 {min(12, len(trades))} 笔}} \begin{{table}}[H] \centering \small \begin{{tabular}}{{llrrl}} \toprule 方向 & 开仓 & 平仓 & 盈亏 & 出场 \\ \midrule {trade_table_rows(trades, 12, True)} \bottomrule \end{{tabular}} \end{{table}} \subsection{{最差 {min(12, len(trades))} 笔}} \begin{{table}}[H] \centering \small \begin{{tabular}}{{llrrl}} \toprule 方向 & 开仓 & 平仓 & 盈亏 & 出场 \\ \midrule {trade_table_rows(trades, 12, False)} \bottomrule \end{{tabular}} \end{{table}} \noindent 完整 {len(trades)} 笔交易见 \texttt{{trades.csv}}。 \section{{后续验证}} MT5 原生 Strategy Tester 验证命令: \begin{{verbatim}} cd lab/EAs/SimpleEMA python run_mt5_tester.py backtest --period M15 ^ --from 2020.01.01 --to 2026.01.01 --set SimpleEMA_optimized.set \end{{verbatim}} \end{{document}} """).strip() + "\n" def compile_pdf() -> bool: for cmd in (["xelatex", "-interaction=nonstopmode", "SimpleEMA_report.tex"],): for _ in range(2): r = subprocess.run(cmd, cwd=OUT, capture_output=True, text=True) if r.returncode != 0 and "xelatex" in cmd[0]: print(r.stdout[-2000:] if r.stdout else "") print(r.stderr[-2000:] if r.stderr else "") return PDF.exists() def pdf_to_png() -> bool: try: import fitz # PyMuPDF doc = fitz.open(PDF) zoom = 200 / 72 mat = fitz.Matrix(zoom, zoom) images = [] for page in doc: pix = page.get_pixmap(matrix=mat, alpha=False) images.append(pix) if len(images) == 1: images[0].save(PNG) else: # stack pages vertically into one PNG w = max(p.width for p in images) h = sum(p.height for p in images) from PIL import Image import io canvas = Image.new("RGB", (w, h), "white") y = 0 for pix in images: img = Image.open(io.BytesIO(pix.tobytes("png"))) canvas.paste(img, (0, y)) y += pix.height canvas.save(PNG, dpi=(200, 200)) doc.close() return PNG.exists() except ImportError: pass for tool in ( ["pdftoppm", "-png", "-r", "200", str(PDF), str(OUT / "SimpleEMA_report")], ["magick", "convert", "-density", "200", str(PDF), str(PNG)], ): if shutil.which(tool[0]): subprocess.run(tool, cwd=OUT, check=False) if tool[0] == "pdftoppm": cand = OUT / "SimpleEMA_report-1.png" if cand.exists(): cand.replace(PNG) return True if PNG.exists(): return True # fallback: copy dashboard chart src = FIG / "equity.png" if src.exists(): shutil.copy2(src, PNG) return True return False def main() -> None: if not (OUT / "trades.csv").exists(): subprocess.run(["python", str(ROOT / "generate_report.py")], check=True, cwd=ROOT) bp, summary, trades = load_data() save_figures(trades, summary) tex = build_tex(bp, summary, trades) TEX.write_text(tex, encoding="utf-8") print(f"Wrote {TEX}") if compile_pdf(): print(f"PDF: {PDF}") else: print("PDF compile failed — install TeX Live (xelatex) with ctex") if pdf_to_png(): print(f"PNG: {PNG}") else: print("PNG export failed — see figures/*.png") print(f"Figures: {FIG}") if __name__ == "__main__": main()