Files
zhutoutoutousan 605faf5310 Prepare source-only public release for develop.
Add cluster audit pipeline, united EA updates, brochure generators, and publication hygiene (gitignore, MT5 path desensitization, pre-upload scan). Remove tracked reports, models, and binary artifacts from the repo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 15:03:43 +02:00

773 lines
31 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.
#!/usr/bin/env python3
"""Generate cluster backtest evaluation: charts + LaTeX PDF."""
from __future__ import annotations
import json
import re
import subprocess
import textwrap
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime
from html import unescape
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
import MetaTrader5 as mt5
import numpy as np
import pandas as pd
from matplotlib.patches import Rectangle
ROOT = Path(__file__).resolve().parent
def _find_tester_report() -> Path:
"""Locate MT5 Strategy Tester HTML (ReportTester-*.html) in cluster-latest."""
candidates = sorted(ROOT.parent.glob("ReportTester*.html"), key=lambda p: p.stat().st_mtime, reverse=True)
if not candidates:
raise FileNotFoundError(
"No ReportTester*.html found. Export from MT5 Strategy Tester into frontline/cluster-latest/"
)
return candidates[0]
REPORT_HTML = _find_tester_report()
FIG_DIR = ROOT / "figures"
DATA_DIR = ROOT / "data"
TEX_FILE = ROOT / "cluster_evaluation.tex"
plt.rcParams.update({
"figure.dpi": 150,
"savefig.dpi": 150,
"font.size": 9,
"axes.titlesize": 10,
"axes.labelsize": 9,
})
STRATEGY_META = {
"RM_EMA_Cross": {"name": "RM MidPoint EMA Cross", "group": "gold_core", "type": "trend"},
"RM_RSI_Follow": {"name": "RM MidPoint RSI Follow", "group": "gold_core", "type": "trend"},
"RM_RSI_Reverse": {"name": "RM MidPoint RSI Reverse", "group": "gold_core", "type": "mean_rev"},
"RS_XAUUSD": {"name": "RSI Scalping XAUUSD", "group": "gold_core", "type": "scalp"},
"RCO_RSIConsolidation": {"name": "RSI Consolidation", "group": "gold_core", "type": "range"},
"RS_BTCUSD": {"name": "RSI Scalping BTCUSD", "group": "crypto", "type": "scalp"},
"RS_TSLA": {"name": "RSI Scalping TSLA", "group": "equity", "type": "scalp"},
"RS_NVDA": {"name": "RSI Scalping NVDA", "group": "equity", "type": "scalp"},
"RS_APPL": {"name": "RSI Scalping AAPL", "group": "equity", "type": "scalp"},
"RC_RSICrossOver": {"name": "RSI CrossOver Reversal", "group": "gold_core", "type": "reversal"},
"SE_SuperEMA": {"name": "SuperEMA", "group": "gold_core", "type": "trend"},
"DB_DarvasBox": {"name": "Darvas Box", "group": "gold_core", "type": "breakout"},
"ES_EMASlope": {"name": "EMA Slope Distance", "group": "gold_core", "type": "trend"},
"ST_SimpleTrendline": {"name": "SimpleTrendline", "group": "multi", "type": "hedge"},
"RRA_EURUSD": {"name": "RSI Asian EURUSD", "group": "fx", "type": "session"},
"RRA_AUDUSD": {"name": "RSI Asian AUDUSD", "group": "fx", "type": "session"},
"UB_USDJPY": {"name": "USDJPY Buster", "group": "fx", "type": "breakout"},
}
def classify_comment(c: str, symbol: str = "") -> str | None:
c = c.strip()
if not c:
return None
cl = c.lower()
if "darvas" in cl:
return "DB_DarvasBox"
if "ema cross distance" in cl:
return "ES_EMASlope"
if "ema crossover trade" in cl:
return "RM_EMA_Cross"
if c in ("Buy Order", "Sell Order"):
return "RC_RSICrossOver"
if "united superema" in cl:
return "SE_SuperEMA"
if "rsiconsolidation" in cl.replace(" ", ""):
return "RCO_RSIConsolidation"
if "simpletrendline" in cl.replace(" ", ""):
return "ST_SimpleTrendline"
if "rsi overbought crossover" in cl or "rsi oversold crossover" in cl:
return "RRA_EURUSD" if "EUR" in symbol else "RRA_AUDUSD" if "AUD" in symbol else "RRA_Asian"
if c.startswith("UB range"):
return "UB_USDJPY"
if "rsi follow" in cl:
return "RM_RSI_Follow"
if "rsi reverse" in cl:
return "RM_RSI_Reverse"
if "rsi scalping" in cl:
sym_map = {
"AAPL.NAS": "RS_APPL", "ADBE.NAS": "RS_ADBE", "BTCUSD": "RS_BTCUSD",
"NVDA.NAS": "RS_NVDA", "TSLA.NAS": "RS_TSLA", "XAUUSD": "RS_XAUUSD", "MU.NAS": "RS_MU",
}
return sym_map.get(symbol, f"RS_{symbol}")
if c.startswith("sl ") or c.startswith("tp ") or c == "end of test":
return None
return None
def st_side(comment: str) -> str | None:
cl = comment.lower().replace(" ", "")
if "simpletrendlinebuy" in cl:
return "long"
if "simpletrendlinesell" in cl:
return "short"
return None
def parse_num(s: str) -> float:
s = s.strip().replace(" ", "").replace(",", "")
if not s:
return 0.0
if s.endswith("K"):
return float(s[:-1]) * 1000
return float(s)
def parse_deals(html: str) -> list[dict]:
marker = '<div style="font: 10pt Tahoma"><b>成交</b></div>'
start = html.find(marker)
sub = html[start:] if start >= 0 else html
row_re = re.compile(
r"align=right><td>([^<]*)</td><td>(\d+)</td><td>([^<]*)</td><td>([^<]*)</td>"
r"<td>([^<]*)</td><td>([^<]*)</td><td>([^<]*)</td><td>(\d+)</td><td>([^<]*)</td>"
r"<td>([^<]*)</td><td>([^<]*)</td><td>([^<]*)</td><td>([^<]*)</td></tr>",
re.I,
)
open_map: dict[str, str] = {}
open_side: dict[str, str] = {}
open_stack: dict[str, list[tuple[str, str, str | None]]] = defaultdict(list)
deals = []
for row in row_re.findall(sub):
time_s, _did, symbol, typ, direction, _vol, _price, order, comm, swap, profit, balance, comment = row
if typ.strip().lower() == "balance":
continue
net = parse_num(profit) + parse_num(comm) + parse_num(swap)
comment = unescape(comment.strip())
symbol = symbol.strip()
direction = direction.strip().lower()
strategy = classify_comment(comment, symbol)
side = st_side(comment) if strategy == "ST_SimpleTrendline" else None
if direction == "in":
if strategy:
open_map[order] = strategy
if side:
open_side[order] = side
open_stack[symbol].append((order, strategy, side))
continue
if direction != "out":
continue
if strategy is None:
if open_stack[symbol]:
_o, strategy, side = open_stack[symbol].pop(0)
elif order in open_map:
strategy = open_map[order]
side = open_side.get(order)
else:
strategy = "UNATTRIBUTED"
elif open_stack[symbol]:
# explicit close comment: still consume FIFO slot for side/symbol match
for i, (o, s, sd) in enumerate(open_stack[symbol]):
if s == strategy:
_o, _, side = open_stack[symbol].pop(i)
break
open_map.pop(order, None)
open_side.pop(order, None)
open_stack[symbol] = [(o, s, sd) for o, s, sd in open_stack[symbol] if o != order]
try:
ts = pd.to_datetime(time_s, format="%Y.%m.%d %H:%M:%S")
except Exception:
ts = pd.to_datetime(time_s.replace(".", "-", 2))
deals.append({
"time": ts,
"symbol": symbol,
"strategy": strategy,
"side": side,
"net": net,
"balance": parse_num(balance),
"comment": comment,
})
return deals
def load_xau_h1(start: datetime, end: datetime) -> pd.DataFrame:
if not mt5.initialize():
raise RuntimeError("MT5 init failed")
sym = "XAUUSD"
if not mt5.symbol_select(sym, True):
for alt in ("GOLD", "XAUUSDm", "XAUUSD."):
if mt5.symbol_select(alt, True):
sym = alt
break
rates = mt5.copy_rates_range(sym, mt5.TIMEFRAME_H1, start, end)
mt5.shutdown()
if rates is None or len(rates) < 100:
raise RuntimeError("No XAUUSD H1 data from MT5")
df = pd.DataFrame(rates)
df["time"] = pd.to_datetime(df["time"], unit="s")
df.set_index("time", inplace=True)
return df
def market_features(df: pd.DataFrame) -> pd.DataFrame:
out = df.copy()
out["ret"] = out["close"].pct_change()
out["ema20"] = out["close"].ewm(span=20).mean()
out["ema50"] = out["close"].ewm(span=50).mean()
hl = out["high"] - out["low"]
hc = (out["high"] - out["close"].shift()).abs()
lc = (out["low"] - out["close"].shift()).abs()
tr = pd.concat([hl, hc, lc], axis=1).max(axis=1)
out["atr14"] = tr.rolling(14).mean()
out["atr_pct"] = out["atr14"] / out["close"] * 100
up = out["high"].diff()
down = -out["low"].diff()
plus_dm = np.where((up > down) & (up > 0), up, 0.0)
minus_dm = np.where((down > up) & (down > 0), down, 0.0)
atr = out["atr14"]
plus_di = 100 * pd.Series(plus_dm, index=out.index).rolling(14).mean() / atr
minus_di = 100 * pd.Series(minus_dm, index=out.index).rolling(14).mean() / atr
dx = (plus_di - minus_di).abs() / (plus_di + minus_di) * 100
out["adx14"] = dx.rolling(14).mean()
out["trend_slope"] = (out["ema20"] - out["ema50"]) / out["close"] * 100
out["chop"] = out["ret"].rolling(24).std() * np.sqrt(24) * 100
return out
def find_drawdowns(equity: pd.Series, top_n: int = 5) -> list[dict]:
peak = equity.cummax()
dd = equity - peak
dd_pct = dd / peak.replace(0, np.nan) * 100
episodes = []
in_dd = False
start = None
trough_t = None
trough_v = 0.0
peak_v = 0.0
for t, v in dd.items():
if v < -1 and not in_dd:
in_dd = True
start = t
trough_t = t
trough_v = v
peak_v = peak.loc[t]
elif in_dd:
if v < trough_v:
trough_v = v
trough_t = t
if v >= -0.5:
episodes.append({
"start": start, "trough": trough_t, "end": t,
"depth_usd": abs(trough_v), "depth_pct": abs(trough_v / peak_v * 100) if peak_v else 0,
})
in_dd = False
if in_dd:
episodes.append({
"start": start, "trough": trough_t, "end": equity.index[-1],
"depth_usd": abs(trough_v), "depth_pct": abs(trough_v / peak_v * 100) if peak_v else 0,
})
episodes.sort(key=lambda x: x["depth_usd"], reverse=True)
return episodes[:top_n]
def plot_candles(ax, df: pd.DataFrame, title: str):
sub = df.tail(min(120, len(df)))
xs = np.arange(len(sub))
for i, (t, row) in enumerate(sub.iterrows()):
o, h, l, c = row["open"], row["high"], row["low"], row["close"]
color = "#26a69a" if c >= o else "#ef5350"
ax.plot([i, i], [l, h], color=color, linewidth=0.8)
ax.add_patch(Rectangle((i - 0.35, min(o, c)), 0.7, abs(c - o) or 0.5, facecolor=color, edgecolor=color))
ax.plot(xs, sub["ema20"].values, color="#1565c0", linewidth=1, label="EMA20")
ax.plot(xs, sub["ema50"].values, color="#ff8f00", linewidth=1, label="EMA50")
ax.set_title(title)
ax.set_xlim(-1, len(sub))
ax.legend(loc="upper left", fontsize=7)
step = max(1, len(sub) // 6)
ax.set_xticks(xs[::step])
ax.set_xticklabels([sub.index[j].strftime("%m-%d") for j in range(0, len(sub), step)], rotation=30, ha="right")
def monthly_issue_text(strat: str, monthly: pd.Series, meta: dict) -> list[str]:
issues = []
neg = monthly[monthly < 0]
if len(neg) > 0:
worst_m = neg.idxmin()
issues.append(f"{worst_m} 单月亏损 {neg.min():,.0f} USD")
pos = monthly[monthly > 0]
if len(pos) > 0 and len(neg) > len(pos):
issues.append(f"亏损月份 ({len(neg)}) 多于盈利月份 ({len(pos)})")
if strat == "ST_SimpleTrendline":
issues.append("对冲型策略:多空均有信号,需分方向评估而非整体关停")
if meta.get("type") == "scalp" and monthly.std() > abs(monthly.mean()) * 2:
issues.append("月度波动大,手数复利放大后尾部风险显著")
if monthly.tail(3).sum() < 0 and monthly.sum() > 0:
issues.append("近 3 个月转弱,存在 regime change 迹象")
return issues[:4]
def latex_escape(s: str) -> str:
return s.replace("\\", "\\textbackslash{}").replace("_", "\\_").replace("&", "\\&").replace("%", "\\%")
def build_latex(summary: dict) -> str:
s = summary
robots_tex = []
for strat in s["strategy_order"]:
meta = STRATEGY_META.get(strat, {"name": strat, "type": "other"})
m = s["monthly"].get(strat, {})
issues = s["issues"].get(strat, [])
total = s["totals"].get(strat, 0)
robots_tex.append(
f"\\subsubsection{{{latex_escape(meta['name'])}}}\n"
f"累计净利润 \\textbf{{{total:,.0f}}} USD。"
+ ("\\par\\noindent\\textbf{逐月问题:}\\begin{itemize}\\setlength\\itemsep{2pt}\n"
+ "".join(f"\\item {latex_escape(x)}" for x in issues)
+ "\\end{itemize}" if issues else "")
)
dd_rows = []
for i, ep in enumerate(s["drawdowns"][:5], 1):
dd_rows.append(
f"{i} & {ep['start'].strftime('%Y-%m-%d')} & {ep['trough'].strftime('%Y-%m-%d')} & "
f"{ep['end'].strftime('%Y-%m-%d')} & {ep['depth_usd']:,.0f} & {ep['depth_pct']:.1f}\\% \\\\"
)
st_total = s["totals"].get("ST_SimpleTrendline", 0)
body = f"""
\\section{{执行摘要}}
本报告基于 MT5 策略测试器 HTML 报告(初始资金 \\$3,000,杠杆 1:1000,余额复利缩放)对 UnitedEA 集群进行逐机器人、逐月、回撤期市场结构分析。
\\begin{{table}}[H]
\\centering
\\caption{{组合层面关键指标}}
\\begin{{tabular}}{{lr}}
\\toprule
指标 & 数值 \\\\
\\midrule
总净盈利 & {s['portfolio']['net_profit']:,.0f} USD \\\\
盈利因子 & {s['portfolio']['pf']:.2f} \\\\
夏普比率 & {s['portfolio']['sharpe']:.2f} \\\\
最大净值回撤 & {s['portfolio']['max_dd_pct']:.2f}\\% \\\\
总交易笔数 & {s['portfolio']['trades']:,} \\\\
胜率 & {s['portfolio']['win_rate']:.2f}\\% \\\\
\\bottomrule
\\end{{tabular}}
\\end{{table}}
\\textbf{{核心结论:}}
\\begin{{itemize}}
\\item 利润高度集中于 XAUUSD 上的 RM EMA Cross、RSI Scalping XAU、RSI Consolidation;名义多品种分散,实际为黄金 beta 集群。
\\item 2025 末至 2026 初复利手数放大后,收益与回撤同步膨胀;评估 edge 需配合固定手数对照。
\\item SimpleTrendline 为\\textbf{{对冲型}}(多空双向),整体虽亏但分方向与品种后可能仍有价值,不宜简单一刀切关闭。
\\item 最大回撤期 XAUUSD 呈现更高 ATR、更低 ADX(震荡/假突破增多),趋势型与突破型策略易共振亏损。
\\end{{itemize}}
\\section{{组合曲线与回撤}}
\\begin{{figure}}[H]
\\centering
\\includegraphics[width=0.95\\textwidth]{{figures/equity_drawdown.pdf}}
\\caption{{净值曲线与回撤百分比}}
\\end{{figure}}
\\begin{{table}}[H]
\\centering
\\caption{{主要回撤 episodeTop 5}}
\\begin{{tabular}}{{clllrr}}
\\toprule
\\# & 起始 & 谷底 & 恢复 & 深度(USD) & 深度(\\%) \\\\
\\midrule
{chr(10).join(dd_rows)}
\\bottomrule
\\end{{tabular}}
\\end{{table}}
\\section{{逐机器人月度热力与累计贡献}}
\\begin{{figure}}[H]
\\centering
\\includegraphics[width=0.98\\textwidth]{{figures/monthly_heatmap.pdf}}
\\caption{{各机器人逐月净利润热力图(USD}}
\\end{{figure}}
\\begin{{figure}}[H]
\\centering
\\includegraphics[width=0.85\\textwidth]{{figures/strategy_contribution.pdf}}
\\caption{{各机器人累计净利润贡献}}
\\end{{figure}}
\\section{{SimpleTrendline:对冲型专项分析}}
SimpleTrendline 基于高周期 MA 交叉趋势线,\\textbf{{多空双向}}触发,设计目标是对冲而非单边趋势押注。
回测合计 {st_total:,.0f} USD,但 2026 年在黄金高位剧烈震荡中大幅回撤。
\\begin{{figure}}[H]
\\centering
\\includegraphics[width=0.95\\textwidth]{{figures/st_long_short_monthly.pdf}}
\\caption{{SimpleTrendline 多空方向逐月 P/L 分解}}
\\end{{figure}}
\\begin{{figure}}[H]
\\centering
\\includegraphics[width=0.95\\textwidth]{{figures/st_by_symbol_monthly.pdf}}
\\caption{{SimpleTrendline 分品种(XAU/GER/BTC)逐月 P/L}}
\\end{{figure}}
\\begin{{figure}}[H]
\\centering
\\includegraphics[width=0.98\\textwidth]{{figures/top6_monthly.pdf}}
\\caption{{核心六机器人逐月 P/L 明细}}
\\end{{figure}}
\\textbf{{建议:}}保留策略框架,但 (1) 在 ADX>{s['regime']['dd_adx']:.0f} 且 ATR 分位>{s['regime']['dd_atr_pct']:.0f}\\% 的强趋势月减少逆势侧仓位;
(2) 与 RM/RCO 等同向 exposure 设上限;(3) 2026 类高位宽幅震荡月单独降 LOT。
\\section{{回撤期 vs 平稳期:XAUUSD H1 市场结构}}
从 MT5 拉取 XAUUSD H1。回撤窗口取 Top3 drawdown episode{', '.join(s['regime'].get('dd_months', []))}),对照期为 2024-03 至 2024-09 平稳盈利段。
\\begin{{table}}[H]
\\centering
\\caption{{市场特征对比(亏损月 vs 对照月)}}
\\begin{{tabular}}{{lrr}}
\\toprule
特征 & 亏损月均值 & 对照月均值 \\\\
\\midrule
ADX(14) & {s['regime']['dd_adx']:.1f} & {s['regime']['calm_adx']:.1f} \\\\
ATR\\% & {s['regime']['dd_atr_pct']:.2f} & {s['regime']['calm_atr_pct']:.2f} \\\\
日收益波动(chop) & {s['regime']['dd_chop']:.2f} & {s['regime']['calm_chop']:.2f} \\\\
EMA20-50 斜率\\% & {s['regime']['dd_slope']:.3f} & {s['regime']['calm_slope']:.3f} \\\\
\\bottomrule
\\end{{tabular}}
\\end{{table}}
\\begin{{figure}}[H]
\\centering
\\begin{{subfigure}}{{0.48\\textwidth}}
\\includegraphics[width=\\textwidth]{{figures/xau_dd_candles.pdf}}
\\caption{{回撤谷底附近 K 线 + EMA}}
\\end{{subfigure}}
\\hfill
\\begin{{subfigure}}{{0.48\\textwidth}}
\\includegraphics[width=\\textwidth]{{figures/xau_calm_candles.pdf}}
\\caption{{盈利平稳期 K 线 + EMA}}
\\end{{subfigure}}
\\caption{{XAUUSD H1 形态对比}}
\\end{{figure}}
\\begin{{figure}}[H]
\\centering
\\includegraphics[width=0.95\\textwidth]{{figures/xau_regime_monthly.pdf}}
\\caption{{XAUUSD 逐月 ADX / ATR\\% 与组合月度 P/L 对照}}
\\end{{figure}}
\\section{{各机器人逐月问题诊断}}
{chr(10).join(robots_tex)}
\\section{{分品种月度 P/L}}
\\begin{{figure}}[H]
\\centering
\\includegraphics[width=0.95\\textwidth]{{figures/symbol_monthly.pdf}}
\\caption{{主要交易品种逐月净利润}}
\\end{{figure}}
\\section{{改进路线图}}
\\begin{{enumerate}}
\\item \\textbf{{P0 风控:}}设置 ORCH\\_MaxBalanceScale 上限、每机器人 max lot capXAUUSD 总 exposure 上限。
\\item \\textbf{{P1 精简:}}关闭 RRA EURUSD、RS AAPLSuperEMA 降权;RS NVDA 观察。
\\item \\textbf{{P2 SimpleTrendline}}分方向/分品种调参,震荡月(低 ADX + 高 ATR)减半 LOT,勿整体删除。
\\item \\textbf{{P3 验证:}}固定手数复测 2023--2026Walk-forward 2026 Q1 作为 OOS。
\\item \\textbf{{P4 监控:}}月度 dashboard 跟踪 RM EMA Cross 与 ST 多空比、ADX 过滤命中率。
\\end{{enumerate}}
\\appendix
\\section{{数据来源}}
报告 HTML: ReportTester-*.htmlMT5 导出);K 线: MT5 XAUUSD H1;成交归因基于 order comment FIFO。
"""
header = textwrap.dedent(r"""
\documentclass[11pt,a4paper]{ctexart}
\usepackage{graphicx}
\usepackage{booktabs}
\usepackage{geometry}
\usepackage{float}
\usepackage{caption}
\usepackage{subcaption}
\usepackage{hyperref}
\usepackage{xcolor}
\geometry{margin=2.2cm}
\title{UnitedEA 集群回测综合评估报告\\ \large 2023.07 -- 2026.06 · 逐月诊断 · 回撤期 K 线对比}
\author{自动生成 · cluster-latest/main.mq5}
\date{\today}
\begin{document}
\maketitle
\tableofcontents
\newpage
""")
return header + body + "\n\\end{document}\n"
def main():
FIG_DIR.mkdir(parents=True, exist_ok=True)
DATA_DIR.mkdir(parents=True, exist_ok=True)
html = REPORT_HTML.read_text(encoding="utf-16")
deals = parse_deals(html)
df = pd.DataFrame(deals).sort_values("time")
df["month"] = df["time"].dt.to_period("M").astype(str)
# Portfolio summary from HTML header
m_net = re.search(r"总净盈利:</td>\s*<td[^>]*><b>([^<]+)</b>", html)
m_pf = re.search(r"盈利因子:</td>\s*<td[^>]*><b>([^<]+)</b>", html)
m_sh = re.search(r"夏普比率:</td>\s*<td[^>]*><b>([^<]+)</b>", html)
m_dd = re.search(r"相对净值亏损:</td>\s*<td[^>]*><b>([0-9.]+)%", html)
m_tr = re.search(r"交易总计:</td>\s*<td[^>]*><b>([^<]+)</b>", html)
m_wr = re.search(r"盈利交易 \(% 全部\):</td>\s*<td[^>]*><b>[0-9]+ \(([0-9.]+)%\)", html)
portfolio = {
"net_profit": parse_num(m_net.group(1)) if m_net else df["net"].sum(),
"pf": float(m_pf.group(1)) if m_pf else 0,
"sharpe": float(m_sh.group(1)) if m_sh else 0,
"max_dd_pct": float(m_dd.group(1)) if m_dd else 0,
"trades": int(m_tr.group(1).replace(" ", "")) if m_tr else len(df),
"win_rate": float(m_wr.group(1)) if m_wr else 0,
}
# Equity curve
eq = df.groupby("time")["balance"].last().sort_index()
eq_daily = eq.resample("D").last().ffill()
peak = eq_daily.cummax()
dd_pct = (eq_daily - peak) / peak * 100
fig, axes = plt.subplots(2, 1, figsize=(11, 5), sharex=True, gridspec_kw={"height_ratios": [3, 1]})
axes[0].semilogy(eq_daily.index, eq_daily.values, color="#1565c0", linewidth=1.2)
axes[0].set_ylabel("Balance (USD, log)")
axes[0].set_title("Portfolio Equity")
axes[0].grid(True, alpha=0.3)
axes[1].fill_between(dd_pct.index, dd_pct.values, 0, color="#ef5350", alpha=0.5)
axes[1].set_ylabel("Drawdown %")
axes[1].set_xlabel("Date")
axes[1].grid(True, alpha=0.3)
fig.autofmt_xdate()
fig.tight_layout()
fig.savefig(FIG_DIR / "equity_drawdown.pdf")
plt.close()
drawdowns = find_drawdowns(eq_daily, 5)
# Monthly by strategy
monthly_strat = df.pivot_table(index="month", columns="strategy", values="net", aggfunc="sum", fill_value=0)
totals = df.groupby("strategy")["net"].sum().sort_values(ascending=False)
strategy_order = [s for s in totals.index if s != "UNATTRIBUTED"]
# Heatmap - top strategies
top_strats = strategy_order[:14]
heat = monthly_strat[top_strats].T
fig, ax = plt.subplots(figsize=(14, 7))
vmax = np.percentile(np.abs(heat.values), 95)
im = ax.imshow(heat.values, aspect="auto", cmap="RdYlGn", vmin=-vmax, vmax=vmax)
ax.set_yticks(range(len(top_strats)))
ax.set_yticklabels([STRATEGY_META.get(s, {}).get("name", s) for s in top_strats], fontsize=7)
ax.set_xticks(range(len(heat.columns)))
ax.set_xticklabels(heat.columns, rotation=45, ha="right", fontsize=7)
ax.set_title("Monthly Net P/L by Robot (USD)")
plt.colorbar(im, ax=ax, shrink=0.6)
fig.tight_layout()
fig.savefig(FIG_DIR / "monthly_heatmap.pdf")
plt.close()
# Contribution bar
fig, ax = plt.subplots(figsize=(9, 5))
colors = ["#2e7d32" if v >= 0 else "#c62828" for v in totals[strategy_order]]
ax.barh([STRATEGY_META.get(s, {}).get("name", s)[:28] for s in strategy_order], totals[strategy_order].values, color=colors)
ax.axvline(0, color="black", linewidth=0.8)
ax.set_xlabel("Net Profit (USD)")
ax.set_title("Cumulative Contribution by Robot")
fig.tight_layout()
fig.savefig(FIG_DIR / "strategy_contribution.pdf")
plt.close()
# ST long/short monthly
st_df = df[df["strategy"] == "ST_SimpleTrendline"].copy()
st_monthly = {"long": {}, "short": {}}
for side in ("long", "short"):
sub = st_df[st_df["side"] == side]
if len(sub):
st_monthly[side] = sub.groupby("month")["net"].sum().to_dict()
all_months = sorted(monthly_strat.index)
long_s = [st_monthly["long"].get(m, 0) for m in all_months]
short_s = [st_monthly["short"].get(m, 0) for m in all_months]
x = np.arange(len(all_months))
fig, ax = plt.subplots(figsize=(12, 4))
ax.bar(x - 0.2, long_s, 0.4, label="Long", color="#26a69a")
ax.bar(x + 0.2, short_s, 0.4, label="Short", color="#ef5350")
ax.axhline(0, color="black", linewidth=0.6)
ax.set_xticks(x)
ax.set_xticklabels(all_months, rotation=45, ha="right", fontsize=7)
ax.set_ylabel("USD")
ax.set_title("SimpleTrendline: Long vs Short Monthly P/L")
ax.legend()
fig.tight_layout()
fig.savefig(FIG_DIR / "st_long_short_monthly.pdf")
plt.close()
# ST by symbol
st_sym = st_df.groupby(["month", "symbol"])["net"].sum().unstack(fill_value=0)
if len(st_sym.columns):
fig, ax = plt.subplots(figsize=(12, 4))
st_sym.plot(kind="bar", stacked=True, ax=ax, width=0.85)
ax.axhline(0, color="black", linewidth=0.6)
ax.set_title("SimpleTrendline Monthly P/L by Symbol")
ax.set_ylabel("USD")
plt.xticks(rotation=45, ha="right", fontsize=7)
ax.legend(fontsize=7, loc="upper left")
fig.tight_layout()
fig.savefig(FIG_DIR / "st_by_symbol_monthly.pdf")
plt.close()
# Top robots monthly lines
top6 = strategy_order[:6]
fig, axes = plt.subplots(3, 2, figsize=(12, 7), sharex=True)
for ax, strat in zip(axes.flat, top6):
if strat not in monthly_strat.columns:
continue
ser = monthly_strat[strat]
ax.bar(ser.index, ser.values, color=np.where(ser.values >= 0, "#66bb6a", "#ef5350"), width=0.8)
ax.axhline(0, color="black", linewidth=0.5)
ax.set_title(STRATEGY_META.get(strat, {}).get("name", strat), fontsize=8)
ax.tick_params(axis="x", labelrotation=45, labelsize=6)
fig.suptitle("Top 6 Robots — Monthly P/L", fontsize=11)
fig.tight_layout()
fig.savefig(FIG_DIR / "top6_monthly.pdf")
plt.close()
# Symbol monthly
sym_m = df.pivot_table(index="month", columns="symbol", values="net", aggfunc="sum", fill_value=0)
main_syms = sym_m.sum().abs().sort_values(ascending=False).head(6).index
fig, ax = plt.subplots(figsize=(12, 5))
bottom = np.zeros(len(sym_m))
for sym in main_syms:
ax.bar(sym_m.index, sym_m[sym].values, bottom=bottom, label=sym)
bottom += sym_m[sym].values
ax.axhline(0, color="black", linewidth=0.6)
ax.legend(fontsize=7)
ax.set_title("Monthly P/L by Symbol (stacked)")
plt.xticks(rotation=45, ha="right", fontsize=7)
fig.tight_layout()
fig.savefig(FIG_DIR / "symbol_monthly.pdf")
plt.close()
# XAUUSD regime analysis
start = datetime(2023, 7, 1)
end = datetime(2026, 6, 4)
xau = load_xau_h1(start, end)
xau = market_features(xau)
xau.to_csv(DATA_DIR / "xau_h1_features.csv")
port_monthly = df.groupby("month")["net"].sum()
xau_m = xau.resample("ME").agg({"adx14": "mean", "atr_pct": "mean", "chop": "mean", "trend_slope": "mean", "close": "last"})
xau_m.index = xau_m.index.strftime("%Y-%m")
# Regime: portfolio drawdown windows vs calm reference (2024-03..2024-09)
dd_months: set[str] = set()
for ep in drawdowns[:3]:
t0, t1 = pd.Timestamp(ep["start"]), pd.Timestamp(ep["trough"])
for ts in pd.date_range(t0.to_period("M").to_timestamp(), t1.to_period("M").to_timestamp(), freq="MS"):
dd_months.add(ts.strftime("%Y-%m"))
calm_months = [m for m in xau_m.index if "2024-0" in m and m >= "2024-03" and m <= "2024-09"]
dd_mask = xau_m.index.isin(sorted(dd_months))
calm_mask = xau_m.index.isin(calm_months)
common = sorted(set(port_monthly.index) & set(xau_m.index))
pm = port_monthly.reindex(common, fill_value=0)
regime = {
"dd_adx": float(xau_m.loc[dd_mask, "adx14"].mean()) if dd_mask.any() else 0,
"calm_adx": float(xau_m.loc[calm_mask, "adx14"].mean()) if calm_mask.any() else 0,
"dd_atr_pct": float(xau_m.loc[dd_mask, "atr_pct"].mean()) if dd_mask.any() else 0,
"calm_atr_pct": float(xau_m.loc[calm_mask, "atr_pct"].mean()) if calm_mask.any() else 0,
"dd_chop": float(xau_m.loc[dd_mask, "chop"].mean()) if dd_mask.any() else 0,
"calm_chop": float(xau_m.loc[calm_mask, "chop"].mean()) if calm_mask.any() else 0,
"dd_slope": float(xau_m.loc[dd_mask, "trend_slope"].mean()) if dd_mask.any() else 0,
"calm_slope": float(xau_m.loc[calm_mask, "trend_slope"].mean()) if calm_mask.any() else 0,
"dd_months": sorted(dd_months),
}
# Regime monthly chart
fig, ax1 = plt.subplots(figsize=(12, 4))
ax2 = ax1.twinx()
xs = np.arange(len(common))
ax1.bar(xs, pm.values, color=np.where(pm.values >= 0, "#66bb6a", "#ef5350"), alpha=0.7, label="Portfolio P/L")
ax2.plot(xs, xau_m.reindex(common)["adx14"].values, color="#1565c0", marker="o", markersize=3, label="ADX")
ax2.plot(xs, xau_m.reindex(common)["atr_pct"].values * 2, color="#ff8f00", marker="s", markersize=3, label="ATR% x2")
ax1.set_xticks(xs)
ax1.set_xticklabels(common, rotation=45, ha="right", fontsize=7)
ax1.set_ylabel("Portfolio Monthly P/L")
ax2.set_ylabel("ADX / scaled ATR%")
ax1.set_title("Monthly Portfolio P/L vs XAUUSD Regime")
fig.tight_layout()
fig.savefig(FIG_DIR / "xau_regime_monthly.pdf")
plt.close()
# Candle windows
if drawdowns:
trough = drawdowns[0]["trough"]
dd_win = xau.loc[trough - pd.Timedelta(days=10): trough + pd.Timedelta(days=10)]
calm_start = pd.Timestamp("2024-03-01")
calm_win = xau.loc[calm_start: calm_start + pd.Timedelta(days=10)]
fig, ax = plt.subplots(figsize=(8, 3.5))
plot_candles(ax, dd_win, f"Drawdown trough window ~ {trough.strftime('%Y-%m-%d')}")
fig.tight_layout()
fig.savefig(FIG_DIR / "xau_dd_candles.pdf")
plt.close()
fig, ax = plt.subplots(figsize=(8, 3.5))
plot_candles(ax, calm_win, "Calm period reference (2024-03)")
fig.tight_layout()
fig.savefig(FIG_DIR / "xau_calm_candles.pdf")
plt.close()
# Issues per strategy
issues = {}
monthly_dict = {}
for strat in strategy_order:
if strat in monthly_strat.columns:
ser = monthly_strat[strat]
monthly_dict[strat] = ser.to_dict()
issues[strat] = monthly_issue_text(strat, ser, STRATEGY_META.get(strat, {}))
summary = {
"portfolio": portfolio,
"drawdowns": drawdowns,
"strategy_order": strategy_order,
"totals": totals.to_dict(),
"monthly": monthly_dict,
"issues": issues,
"st_monthly": st_monthly,
"regime": regime,
}
(DATA_DIR / "summary.json").write_text(json.dumps(summary, default=str, indent=2), encoding="utf-8")
tex = build_latex(summary)
TEX_FILE.write_text(tex, encoding="utf-8")
print(f"Wrote {TEX_FILE}")
print(f"Figures in {FIG_DIR}")
for _ in range(2):
subprocess.run(
["xelatex", "-interaction=nonstopmode", "cluster_evaluation.tex"],
cwd=ROOT,
check=False,
capture_output=True,
)
pdf = ROOT / "cluster_evaluation.pdf"
if pdf.exists():
print(f"PDF: {pdf}")
return summary
if __name__ == "__main__":
main()