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

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