605faf5310
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>
489 lines
15 KiB
Python
489 lines
15 KiB
Python
"""MT5 Strategy Tester runner for United EA (cluster-latest/main.mq5)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import MetaTrader5 as mt5
|
|
|
|
CLUSTER = Path(__file__).resolve().parents[3] / "frontline" / "cluster-latest"
|
|
BASE_SET = CLUSTER / "123.set"
|
|
DEPOSIT = 3000
|
|
LEVERAGE = 1000
|
|
FROM_DATE = "2023.07.01"
|
|
TO_DATE = "2026.06.01"
|
|
TEST_SYMBOL = "NAS100"
|
|
TEST_PERIOD = "H1"
|
|
|
|
LABELS = {
|
|
"profit_factor": ("Profit Factor", "盈利因子"),
|
|
"net_profit": ("Total Net Profit", "总净盈利"),
|
|
"total_trades": ("Total Trades", "交易总计"),
|
|
"sharpe": ("Sharpe Ratio", "夏普比率"),
|
|
"equity_dd": ("Equity Drawdown Maximal", "最大回撤"),
|
|
"margin_level": ("Minimal margin level", "最低保证金比例"),
|
|
}
|
|
|
|
|
|
def read_text(path: Path) -> str:
|
|
raw = path.read_bytes()
|
|
for enc in ("utf-16", "utf-16-le", "utf-8", "cp1252"):
|
|
try:
|
|
text = raw.decode(enc)
|
|
if text.strip():
|
|
return text
|
|
except UnicodeError:
|
|
continue
|
|
return raw.decode("utf-8", errors="ignore")
|
|
|
|
|
|
def grab_metric(text: str, key: str) -> str | None:
|
|
for label in LABELS[key]:
|
|
for pat in (
|
|
rf">{re.escape(label)}</td>\s*<td[^>]*>(?:<b>)?([^<]+)",
|
|
rf">{re.escape(label)}:</td>\s*<td[^>]*>(?:<b>)?([^<]+)",
|
|
rf">{re.escape(label)}</td>\s*<td[^>]*><b>([^<]+)",
|
|
):
|
|
m = re.search(pat, text, re.I)
|
|
if m:
|
|
return m.group(1).strip()
|
|
return None
|
|
|
|
|
|
def parse_report(data: Path, report: str) -> dict:
|
|
candidates = [data / f"{report}{ext}" for ext in (".htm", ".html")]
|
|
candidates += sorted(data.glob(f"**/{report}*.htm*"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
seen: set[Path] = set()
|
|
for path in candidates:
|
|
if path in seen or not path.exists():
|
|
continue
|
|
seen.add(path)
|
|
text = read_text(path)
|
|
pf = grab_metric(text, "profit_factor")
|
|
profit = grab_metric(text, "net_profit")
|
|
trades = grab_metric(text, "total_trades")
|
|
sharpe = grab_metric(text, "sharpe")
|
|
dd = grab_metric(text, "equity_dd")
|
|
ml = grab_metric(text, "margin_level")
|
|
if pf or profit or trades:
|
|
return {
|
|
"profit_factor": float(pf) if pf else None,
|
|
"net_profit": _num(profit),
|
|
"total_trades": int(float(trades)) if trades and trades[0].isdigit() else _int(trades),
|
|
"sharpe": float(sharpe) if sharpe else None,
|
|
"max_drawdown": dd,
|
|
"min_margin_level": ml,
|
|
"report": str(path),
|
|
"ready": True,
|
|
}
|
|
return {"ready": False}
|
|
|
|
|
|
def _num(s: str | None) -> float | None:
|
|
if not s:
|
|
return None
|
|
s = s.replace(" ", "").replace(",", "")
|
|
if s.endswith("%"):
|
|
return float(s[:-1])
|
|
return float(s)
|
|
|
|
|
|
def _int(s: str | None) -> int | None:
|
|
if not s:
|
|
return None
|
|
try:
|
|
return int(float(s.replace(" ", "").replace(",", "")))
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
KNOWN_TERMINALS = [
|
|
Path(r"C:\Program Files\MetaTrader 5\terminal64.exe"),
|
|
]
|
|
|
|
|
|
def _terminal_data_dirs() -> list[Path]:
|
|
root = Path.home() / "AppData" / "Roaming" / "MetaQuotes" / "Terminal"
|
|
if not root.is_dir():
|
|
return []
|
|
return [p for p in root.iterdir() if p.is_dir() and (p / "origin.txt").exists()]
|
|
|
|
|
|
def _read_origin(path: Path) -> str:
|
|
raw = path.read_bytes()
|
|
for enc in ("utf-8", "utf-16", "utf-16-le", "cp1252"):
|
|
try:
|
|
return raw.decode(enc).strip()
|
|
except UnicodeError:
|
|
continue
|
|
return raw.decode("utf-8", errors="ignore").strip()
|
|
|
|
|
|
def _load_dotenv() -> None:
|
|
env_path = Path(__file__).resolve().parents[3] / ".env"
|
|
if not env_path.is_file():
|
|
return
|
|
for line in env_path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
|
line = line.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
key, val = line.split("=", 1)
|
|
key, val = key.strip(), val.strip().strip('"').strip("'")
|
|
if key and key not in os.environ:
|
|
os.environ[key] = val
|
|
|
|
|
|
_load_dotenv()
|
|
MT5_TERMINAL_DATA_ID = os.environ.get("MT5_TERMINAL_DATA_ID", "").strip()
|
|
# Back-compat alias for scripts that import PREFERRED_DATA_ID
|
|
PREFERRED_DATA_ID = MT5_TERMINAL_DATA_ID
|
|
|
|
|
|
def mt5_terminal_data_dir() -> Path | None:
|
|
"""MT5 data folder: MT5_TERMINAL_DATA_ID env, else first terminal with origin.txt."""
|
|
root = Path.home() / "AppData" / "Roaming" / "MetaQuotes" / "Terminal"
|
|
if MT5_TERMINAL_DATA_ID:
|
|
candidate = root / MT5_TERMINAL_DATA_ID
|
|
if (candidate / "origin.txt").is_file():
|
|
return candidate
|
|
dirs = _terminal_data_dirs()
|
|
return dirs[0] if dirs else None
|
|
|
|
|
|
def _resolve_terminal_exe() -> Path | None:
|
|
env_exe = os.environ.get("MT5_TERMINAL_EXE", "").strip()
|
|
if env_exe:
|
|
exe = Path(env_exe)
|
|
if exe.is_file():
|
|
return exe
|
|
data_dir = mt5_terminal_data_dir()
|
|
if data_dir:
|
|
origin = data_dir / "origin.txt"
|
|
if origin.is_file():
|
|
try:
|
|
exe = Path(_read_origin(origin))
|
|
if exe.is_file():
|
|
return exe
|
|
except OSError:
|
|
pass
|
|
for data_dir in _terminal_data_dirs():
|
|
origin = data_dir / "origin.txt"
|
|
try:
|
|
exe = Path(_read_origin(origin))
|
|
if exe.is_file():
|
|
return exe
|
|
except OSError:
|
|
continue
|
|
for exe in KNOWN_TERMINALS:
|
|
if exe.is_file():
|
|
return exe
|
|
return None
|
|
|
|
|
|
def mt5_context(*, retries: int = 6, wait_sec: float = 12.0) -> dict:
|
|
terminal_exe = _resolve_terminal_exe()
|
|
last_err = None
|
|
for attempt in range(retries):
|
|
if attempt:
|
|
time.sleep(wait_sec)
|
|
if attempt >= 1 and terminal_exe and terminal_exe.is_file():
|
|
subprocess.run(["taskkill", "/IM", "terminal64.exe", "/F"], capture_output=True)
|
|
time.sleep(3)
|
|
subprocess.Popen([str(terminal_exe)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
time.sleep(20)
|
|
mt5.shutdown()
|
|
ok = mt5.initialize(path=str(terminal_exe)) if terminal_exe else mt5.initialize()
|
|
if not ok:
|
|
last_err = mt5.last_error()
|
|
continue
|
|
info = mt5.terminal_info()
|
|
acc = mt5.account_info()
|
|
ctx = {
|
|
"data": Path(info.data_path),
|
|
"mt5_path": Path(info.path),
|
|
"login": acc.login if acc else 0,
|
|
"server": acc.server if acc else "",
|
|
}
|
|
mt5.shutdown()
|
|
return ctx
|
|
raise RuntimeError(f"MT5 init failed after {retries} tries: {last_err}")
|
|
|
|
|
|
def deploy_united(data: Path, mt5_path: Path) -> Path:
|
|
dst = data / "MQL5" / "Experts" / "cluster-latest"
|
|
dst.mkdir(parents=True, exist_ok=True)
|
|
for name in ("main.mq5", "MagicNumberHelpers.mqh", "GapGuard.mqh"):
|
|
shutil.copy2(CLUSTER / name, dst / name)
|
|
strat_dst = dst / "Strategies"
|
|
strat_dst.mkdir(exist_ok=True)
|
|
for f in (CLUSTER / "Strategies").glob("*.mqh"):
|
|
shutil.copy2(f, strat_dst / f.name)
|
|
log = dst / "compile.log"
|
|
subprocess.run(
|
|
[str(mt5_path / "metaeditor64.exe"), f"/compile:{dst / 'main.mq5'}", f"/log:{log}"],
|
|
timeout=180,
|
|
capture_output=True,
|
|
)
|
|
time.sleep(3)
|
|
ex5 = dst / "main.ex5"
|
|
if not ex5.exists():
|
|
tail = log.read_text(encoding="utf-8", errors="ignore")[-2000:] if log.exists() else ""
|
|
raise RuntimeError(f"Compile failed: {log}\n{tail}")
|
|
pub = data / "MQL5" / "Experts" / "main.ex5"
|
|
shutil.copy2(ex5, pub)
|
|
return pub
|
|
|
|
|
|
def patch_set(base: Path, overrides: dict[str, str | bool | int | float]) -> str:
|
|
lines_out: list[str] = []
|
|
seen: set[str] = set()
|
|
for line in base.read_text(encoding="utf-8", errors="ignore").splitlines():
|
|
if not line.strip() or line.strip().startswith(";") or "=" not in line:
|
|
lines_out.append(line)
|
|
continue
|
|
name = line.split("=", 1)[0].strip()
|
|
if name in overrides:
|
|
val = overrides[name]
|
|
if isinstance(val, bool):
|
|
sval = "true" if val else "false"
|
|
else:
|
|
sval = str(val)
|
|
lines_out.append(f"{name}={sval}")
|
|
seen.add(name)
|
|
else:
|
|
lines_out.append(line)
|
|
for k, v in overrides.items():
|
|
if k not in seen:
|
|
sval = "true" if v is True else "false" if v is False else str(v)
|
|
lines_out.append(f"{k}={sval}")
|
|
return "\n".join(lines_out) + "\n"
|
|
|
|
|
|
def patch_set_for_lot_genetic(
|
|
base: Path,
|
|
overrides: dict[str, str | bool | int | float],
|
|
lot_key: str,
|
|
start: float,
|
|
step: float,
|
|
stop: float,
|
|
default: float | None = None,
|
|
) -> str:
|
|
"""Build .set with a single LOT_* genetic range; all other ||Y flags forced to N."""
|
|
body = patch_set(base, overrides)
|
|
val = default if default is not None else start
|
|
genetic_line = f"{lot_key}={val}||{start}||{step}||{stop}||Y"
|
|
lines_out: list[str] = []
|
|
seen_lot = False
|
|
for line in body.splitlines():
|
|
if not line.strip() or line.strip().startswith(";") or "=" not in line:
|
|
lines_out.append(line)
|
|
continue
|
|
name = line.split("=", 1)[0].strip()
|
|
if name == lot_key:
|
|
lines_out.append(genetic_line)
|
|
seen_lot = True
|
|
elif "||" in line:
|
|
parts = line.split("||")
|
|
if len(parts) >= 5:
|
|
parts[4] = "N"
|
|
lines_out.append("||".join(parts))
|
|
else:
|
|
lines_out.append(line)
|
|
else:
|
|
lines_out.append(line)
|
|
if not seen_lot:
|
|
lines_out.append(genetic_line)
|
|
return "\n".join(lines_out) + "\n"
|
|
|
|
|
|
def parse_optimization_xml(data: Path, report: str, lot_key: str) -> dict:
|
|
candidates = [data / f"{report}.xml", data / f"{report}.opt"]
|
|
candidates += sorted(data.glob(f"**/{report}*.xml"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
seen: set[Path] = set()
|
|
xml_path: Path | None = None
|
|
for p in candidates:
|
|
if p in seen or not p.exists() or p.suffix.lower() != ".xml":
|
|
continue
|
|
seen.add(p)
|
|
xml_path = p
|
|
break
|
|
if xml_path is None:
|
|
return {"ready": False, "error": "xml_not_found"}
|
|
|
|
text = read_text(xml_path)
|
|
header = re.search(r"<Row>.*?Pass</Data>.*?</Row>", text, re.S)
|
|
if not header:
|
|
return {"ready": False, "error": "xml_header_missing", "report": str(xml_path)}
|
|
|
|
cols = re.findall(r'<Data ss:Type="String">([^<]+)</Data>', header.group(0))
|
|
rows: list[dict[str, str]] = []
|
|
for row_xml in re.findall(r"<Row>(.*?)</Row>", text, re.S)[1:]:
|
|
cells = re.findall(r'<Data ss:Type="(?:Number|String)">([^<]+)</Data>', row_xml)
|
|
if len(cells) >= len(cols):
|
|
rows.append(dict(zip(cols, cells)))
|
|
|
|
if not rows:
|
|
return {"ready": False, "error": "xml_no_rows", "report": str(xml_path)}
|
|
|
|
best_row: dict[str, str] | None = None
|
|
best_score = -1e18
|
|
for row in rows:
|
|
try:
|
|
profit = float(row.get("Profit", 0))
|
|
pf = float(row.get("Profit Factor", 0))
|
|
sharpe = float(row.get("Sharpe Ratio", row.get("Sharpe", 0)))
|
|
trades = int(float(row.get("Trades", 0)))
|
|
except (ValueError, TypeError):
|
|
continue
|
|
if trades < 20 or pf < 1.0 or profit <= 0:
|
|
score = -1e10 + profit
|
|
else:
|
|
score = sharpe * 2000.0 + profit / 500.0 + pf * 50.0
|
|
if score > best_score:
|
|
best_score = score
|
|
best_row = row
|
|
|
|
if best_row is None:
|
|
best_row = max(rows, key=lambda r: float(r.get("Profit", 0)))
|
|
|
|
lot_raw = best_row.get(lot_key)
|
|
if lot_raw is None:
|
|
for k, v in best_row.items():
|
|
if k.replace(" ", "") == lot_key or lot_key in k:
|
|
lot_raw = v
|
|
break
|
|
best_lot = float(lot_raw) if lot_raw is not None else None
|
|
|
|
return {
|
|
"ready": True,
|
|
"report": str(xml_path),
|
|
"best_lot": best_lot,
|
|
"best_row": best_row,
|
|
"best_score": best_score,
|
|
"passes": len(rows),
|
|
"profit": float(best_row.get("Profit", 0)),
|
|
"profit_factor": float(best_row.get("Profit Factor", 0)),
|
|
"sharpe": float(best_row.get("Sharpe Ratio", best_row.get("Sharpe", 0))),
|
|
"trades": int(float(best_row.get("Trades", 0))),
|
|
}
|
|
|
|
|
|
def run_genetic_lot_optimize(
|
|
data: Path,
|
|
mt5_path: Path,
|
|
login: int,
|
|
server: str,
|
|
set_body: str,
|
|
set_name: str,
|
|
report: str,
|
|
lot_key: str,
|
|
*,
|
|
test_symbol: str | None = None,
|
|
optimization: int = 2,
|
|
timeout_sec: int = 7200,
|
|
) -> dict:
|
|
write_tester_set(data, set_name, set_body)
|
|
ini = data / f"{report}.ini"
|
|
ini.write_text(
|
|
build_ini(set_name, report, login, server, symbol=test_symbol, optimization=optimization),
|
|
encoding="utf-8",
|
|
)
|
|
for ext in (".htm", ".html", ".xml"):
|
|
p = data / f"{report}{ext}"
|
|
if p.exists():
|
|
p.unlink(missing_ok=True)
|
|
|
|
subprocess.run(["taskkill", "/IM", "terminal64.exe", "/F"], capture_output=True)
|
|
subprocess.run(["taskkill", "/IM", "metatester64.exe", "/F"], capture_output=True)
|
|
time.sleep(6)
|
|
|
|
t0 = time.time()
|
|
subprocess.run([str(mt5_path / "terminal64.exe"), f"/config:{ini}"], timeout=timeout_sec)
|
|
elapsed = round(time.time() - t0, 1)
|
|
|
|
opt = parse_optimization_xml(data, report, lot_key)
|
|
opt["elapsed_sec"] = elapsed
|
|
if not opt.get("ready"):
|
|
metrics = parse_report(data, report)
|
|
opt.update(metrics)
|
|
return opt
|
|
|
|
|
|
def write_tester_set(data: Path, set_name: str, body: str) -> Path:
|
|
profiles = data / "MQL5" / "Profiles" / "Tester"
|
|
profiles.mkdir(parents=True, exist_ok=True)
|
|
path = profiles / set_name
|
|
path.write_text(body, encoding="utf-8")
|
|
return path
|
|
|
|
|
|
def build_ini(
|
|
set_name: str,
|
|
report: str,
|
|
login: int,
|
|
server: str,
|
|
*,
|
|
symbol: str | None = None,
|
|
optimization: int = 0,
|
|
) -> str:
|
|
sym = symbol or TEST_SYMBOL
|
|
return f"""[Common]
|
|
Login={login}
|
|
Server={server}
|
|
[Tester]
|
|
Expert=main.ex5
|
|
ExpertParameters={set_name}
|
|
Symbol={sym}
|
|
Period={TEST_PERIOD}
|
|
Optimization={optimization}
|
|
Model=1
|
|
Dates=1
|
|
FromDate={FROM_DATE}
|
|
ToDate={TO_DATE}
|
|
ForwardMode=0
|
|
Deposit={DEPOSIT}
|
|
Currency=USD
|
|
Leverage={LEVERAGE}
|
|
ExecutionMode=0
|
|
Report={report}
|
|
ReplaceReport=1
|
|
ShutdownTerminal=1
|
|
Visual=0
|
|
"""
|
|
|
|
|
|
def run_backtest(
|
|
data: Path,
|
|
mt5_path: Path,
|
|
login: int,
|
|
server: str,
|
|
set_body: str,
|
|
set_name: str,
|
|
report: str,
|
|
*,
|
|
test_symbol: str | None = None,
|
|
timeout_sec: int = 1800,
|
|
) -> dict:
|
|
write_tester_set(data, set_name, set_body)
|
|
ini = data / f"{report}.ini"
|
|
ini.write_text(build_ini(set_name, report, login, server, symbol=test_symbol), encoding="utf-8")
|
|
for ext in (".htm", ".html"):
|
|
p = data / f"{report}{ext}"
|
|
if p.exists():
|
|
p.unlink(missing_ok=True)
|
|
|
|
subprocess.run(["taskkill", "/IM", "terminal64.exe", "/F"], capture_output=True)
|
|
subprocess.run(["taskkill", "/IM", "metatester64.exe", "/F"], capture_output=True)
|
|
time.sleep(6)
|
|
|
|
t0 = time.time()
|
|
subprocess.run([str(mt5_path / "terminal64.exe"), f"/config:{ini}"], timeout=timeout_sec)
|
|
metrics = parse_report(data, report)
|
|
metrics["elapsed_sec"] = round(time.time() - t0, 1)
|
|
return metrics
|