回测基本一致

This commit is contained in:
2026-06-26 20:50:07 +08:00
parent 49be922517
commit 0dcbfe0781
58 changed files with 4843 additions and 40 deletions
+28
View File
@@ -0,0 +1,28 @@
"""The MetaTrader 5 bridge (doc 07).
Connects the fast Python search to the gold-standard MT5 Strategy Tester.
Four responsibilities: (1) compile the EA, (2) auto-run a backtest from a
generated config, (3) parse the HTML report, (4) compare Python vs MT5.
Topology A (all-Windows) is implemented here — no SSH / scheduled-task
plumbing is needed; the official ``MetaTrader5`` package and local file paths
drive the terminal directly.
"""
from .compare import build_comparison_table, write_comparison
from .compile import compile_ea, default_metaeditor_path
from .ini_gen import TesterConfig, write_tester_ini
from .runner import run_tester, wait_for_report
from .set_gen import ParamMapping, write_set_file
__all__ = [
"compile_ea",
"default_metaeditor_path",
"TesterConfig",
"write_tester_ini",
"ParamMapping",
"write_set_file",
"run_tester",
"wait_for_report",
"build_comparison_table",
"write_comparison",
]
+106
View File
@@ -0,0 +1,106 @@
"""Python-vs-MT5 comparison table (doc 07 §8).
For every finalist, write an ``auto-verification.md`` that puts the two tiers
side by side and judges the delta against the expected fidelity gap (doc 03
§7): a clean-directional setup shows only a modest negative gap (MT5 a little
below Python); a trailing/grid setup in volatile history can gap much wider,
and that's *expected*, not a bug.
Decision rule: if the **MT5** number still clears your bar after the gap, the
finalist is real; if the edge only existed in the optimistic Python figure,
discard it.
"""
from __future__ import annotations
from pathlib import Path
from typing import Mapping
from ..core.metrics import Metrics
# Rows shown in the comparison table (doc 07 §8).
COMPARISON_ROWS: list[tuple[str, str, str]] = [
("net_profit", "Net", "{:,.2f}"),
("profit_factor", "Profit Factor", "{:.2f}"),
("max_equity_dd", "Equity DD max", "{:,.2f}"),
("total_trades", "Total trades", "{:d}"),
("win_rate", "Win rate", "{:.2%}"),
("sharpe", "Sharpe", "{:.2f}"),
]
def build_comparison_table(
py_metrics: Metrics | Mapping[str, object],
mt5_metrics: Mapping[str, object],
*,
rows: list[tuple[str, str, str]] | None = None,
) -> str:
"""Build the Python-vs-MT5 markdown comparison table.
``py_metrics`` may be a :class:`Metrics` dataclass or a mapping. MT5
metrics come from :func:`shared.data.mt5_report.parse_mt5_report`. The
``Δ`` column is the relative difference where both values are numeric.
"""
rows = rows or COMPARISON_ROWS
py = _as_mapping(py_metrics)
lines = [
"| Metric | Python | MT5 | Δ |",
"|--------|--------|-----|---|",
]
for key, label, fmt in rows:
pv = py.get(key)
mv = mt5_metrics.get(label) or mt5_metrics.get(key)
p_str = _fmt(pv, fmt)
m_str = _fmt(mv, fmt)
delta = _delta(pv, mv)
lines.append(f"| {label} | {p_str} | {m_str} | {delta} |")
return "\n".join(lines) + "\n"
def write_comparison(
py_metrics: Metrics | Mapping[str, object],
mt5_metrics: Mapping[str, object],
path: str | Path,
*,
notes: str = "",
rows: list[tuple[str, str, str]] | None = None,
) -> None:
"""Write the comparison table + notes to an ``auto-verification.md``."""
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
table = build_comparison_table(py_metrics, mt5_metrics, rows=rows)
body = "# Auto-verification: Python vs MT5\n\n" + table
if notes:
body += "\n\n## Notes\n\n" + notes + "\n"
body += (
"\n## Decision rule (doc 03 §7)\n"
"If the MT5 number still clears the bar after the expected fidelity "
"gap, the finalist is real. If the edge only existed in the optimistic "
"Python figure, discard it.\n"
)
p.write_text(body, encoding="utf-8")
def _as_mapping(metrics: Metrics | Mapping[str, object]) -> Mapping[str, object]:
if isinstance(metrics, Mapping):
return metrics
return {k: getattr(metrics, k) for k, _ in COMPARISON_ROWS if hasattr(metrics, k)}
def _fmt(v: object, fmt: str) -> str:
if v is None:
return ""
if isinstance(v, (int, float)) and fmt:
try:
return fmt.format(v)
except (ValueError, TypeError):
return str(v)
return str(v)
def _delta(py: object, mt5: object) -> str:
if not isinstance(py, (int, float)) or not isinstance(mt5, (int, float)):
return ""
if py == 0:
return ""
pct = (mt5 - py) / abs(py) * 100.0
return f"{pct:+.1f}%"
+66
View File
@@ -0,0 +1,66 @@
"""Compile an EA from .mq5 to .ex5 with MetaEditor (doc 07 §1).
The tester runs a compiled ``.ex5``. Compile from the command line so the
bridge can do it programmatically. Custom indicators the EA calls must be
compiled too and placed in ``MQL5\\Indicators\\``. If the EA's ``#include``
files live in a non-standard folder, compile the terminal in portable mode
(``/portable``) so MetaEditor resolves includes from that terminal's
``MQL5\\Include\\``.
"""
from __future__ import annotations
import subprocess
from pathlib import Path
DEFAULT_MT5_INSTALL = r"C:\Program Files\MetaTrader 5 IC Markets Global"
def default_metaeditor_path(mt5_install: str = DEFAULT_MT5_INSTALL) -> str:
"""Return the metaeditor64.exe path for the given MT5 install."""
return str(Path(mt5_install) / "metaeditor64.exe")
def compile_ea(
mq5_path: str | Path,
*,
metaeditor_path: str | None = None,
mt5_install: str = DEFAULT_MT5_INSTALL,
timeout: int = 120,
) -> tuple[bool, str]:
"""Compile an ``.mq5`` EA to ``.ex5`` via MetaEditor's command line.
Returns ``(success, log_text)``. MetaEditor writes a ``.log`` next to the
source; on a clean compile the ``.ex5`` appears beside the ``.mq5``.
Command line (doc 07 §1)::
metaeditor64.exe /compile:"C:\\path\\to\\Expert.mq5" /log
"""
mq5 = Path(mq5_path)
if not mq5.exists():
return False, f"source not found: {mq5}"
editor = metaeditor_path or default_metaeditor_path(mt5_install)
if not Path(editor).exists():
return False, f"metaeditor not found: {editor}"
cmd = [editor, f"/compile:{mq5}", "/log"]
try:
proc = subprocess.run(
cmd, capture_output=True, text=True, timeout=timeout, check=False,
)
log_path = mq5.with_suffix(".log")
log_text = proc.stdout + "\n" + proc.stderr
if log_path.exists():
try:
log_text += "\n--- metaeditor log ---\n" + log_path.read_text(
encoding="utf-16-le", errors="replace"
)
except Exception:
pass
ex5 = mq5.with_suffix(".ex5")
success = ex5.exists() and ex5.stat().st_size > 0
return success, log_text
except subprocess.TimeoutExpired:
return False, f"compile timed out after {timeout}s"
except FileNotFoundError as e:
return False, f"failed to launch metaeditor: {e}"
+82
View File
@@ -0,0 +1,82 @@
"""tester.ini generator (doc 07 §2b).
A small INI tells the tester *what* to run: expert, symbol, period, tick
model, date range, deposit, leverage, report name, and ``ShutdownTerminal=1``
so the terminal closes itself when done (the bridge then knows it's finished).
"""
from __future__ import annotations
import configparser
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
DEFAULT_MT5_INSTALL = r"C:\Program Files\MetaTrader 5 IC Markets Global"
# Tick models (doc 07 §2b):
# 0 = Every tick based on real ticks (highest accuracy, slowest)
# 1 = Every tick (generated from M1)
# 2 = 1-minute OHLC (good for non-tick-sensitive, fast) — routine verification
# 4 = Open prices only (rough, fastest)
MODEL_OHLC = 2
MODEL_EVERY_TICK = 1
MODEL_REAL_TICKS = 0
MODEL_OPEN_PRICES = 4
@dataclass
class TesterConfig:
"""Inputs for one tester run (doc 07 §2b).
``FromDate`` / ``ToDate`` are formatted ``YYYY.MM.DD`` for MT5. The login
section lives separately (kept out of code via the env file, doc 07 §5).
"""
expert: str # e.g. "Experts\\MyEA.ex5"
symbol: str
period: str = "H1" # chart timeframe (≤ the EA's signal timeframe)
model: int = MODEL_OHLC # tick model
from_date: str = "2024.01.01" # YYYY.MM.DD
to_date: str = "2026.01.01"
deposit: float = 10000.0
leverage: int = 100
report: str = "report_myrun" # output HTML name (no extension)
shutdown_terminal: bool = True
set_file: Optional[str] = None # path to the .set, or None to inline
login: Optional[int] = None # from env, never hard-coded
password: Optional[str] = None # from env, never hard-coded
server: Optional[str] = None # broker server
def write_tester_ini(cfg: TesterConfig, path: str | Path) -> str:
"""Write a ``tester.ini`` for ``terminal64.exe /config:`` and return its path."""
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
cp = configparser.ConfigParser()
cp.optionxform = str # preserve case (MT5 keys are case-sensitive)
cp["Tester"] = {
"Expert": cfg.expert,
"Symbol": cfg.symbol,
"Period": cfg.period,
"Model": str(cfg.model),
"FromDate": cfg.from_date,
"ToDate": cfg.to_date,
"Deposit": str(cfg.deposit),
"Leverage": str(cfg.leverage),
"Report": cfg.report,
"ShutdownTerminal": "1" if cfg.shutdown_terminal else "0",
}
if cfg.set_file:
cp["Tester"]["TestReplaceExpert"] = "0"
common: dict[str, str] = {}
if cfg.login is not None:
common["Login"] = str(cfg.login)
if cfg.password is not None:
common["Password"] = cfg.password
if cfg.server is not None:
common["Server"] = cfg.server
if common:
cp["Common"] = common
with p.open("w", encoding="utf-8") as f:
cp.write(f)
return str(p)
+93
View File
@@ -0,0 +1,93 @@
"""Run the MT5 Strategy Tester and wait for the report (doc 07 §3, §7).
Topology A (all-Windows): launch ``terminal64.exe /config:tester.ini`` locally.
``ShutdownTerminal=1`` makes the terminal close itself when the run finishes;
the bridge waits for the process to exit (or for the report file to appear),
then parses the report. No copy/poll step — read the HTML straight from the
terminal's report folder.
"""
from __future__ import annotations
import subprocess
import time
from pathlib import Path
from typing import Optional
DEFAULT_MT5_INSTALL = r"C:\Program Files\MetaTrader 5 IC Markets Global"
# MT5 writes reports into the terminal's installation directory.
DEFAULT_REPORT_SUBDIR = "Reports"
def run_tester(
ini_path: str | Path,
*,
mt5_install: str = DEFAULT_MT5_INSTALL,
timeout: int = 1800,
report_subdir: str = DEFAULT_REPORT_SUBDIR,
poll_interval: float = 5.0,
) -> tuple[int, Optional[Path]]:
"""Launch the tester with the generated ini and wait for the report.
Returns ``(exit_code, report_path)``. ``report_path`` is ``None`` if no
report appeared before ``timeout``. The terminal is launched
non-interactively; ``ShutdownTerminal=1`` in the ini closes it on finish.
"""
ini = Path(ini_path)
if not ini.exists():
raise FileNotFoundError(f"tester.ini not found: {ini}")
terminal = Path(mt5_install) / "terminal64.exe"
if not terminal.exists():
raise FileNotFoundError(f"terminal64.exe not found: {terminal}")
cmd = [str(terminal), f"/config:{ini}"]
# Detach so the terminal's own GUI lifecycle controls shutdown.
proc = subprocess.Popen(cmd)
report_dir = Path(mt5_install) / report_subdir
deadline = time.time() + timeout
while time.time() < deadline:
if proc.poll() is not None:
break
# Some runs leave the terminal open if ShutdownTerminal didn't fire;
# check for the report regardless.
rep = _find_latest_report(report_dir, since=ini.stat().st_mtime)
if rep is not None:
return proc.wait(), rep
time.sleep(poll_interval)
# One last check after exit / timeout.
rep = _find_latest_report(report_dir, since=ini.stat().st_mtime)
exit_code = proc.poll() if proc.poll() is not None else -1
return exit_code, rep
def wait_for_report(
report_dir: str | Path,
*,
since_ts: float,
timeout: int = 1800,
poll_interval: float = 5.0,
) -> Optional[Path]:
"""Poll ``report_dir`` for a fresh ``report*.htm`` and return its path."""
deadline = time.time() + timeout
rdir = Path(report_dir)
while time.time() < deadline:
rep = _find_latest_report(rdir, since=since_ts)
if rep is not None:
return rep
time.sleep(poll_interval)
return None
def _find_latest_report(report_dir: Path, since: float) -> Optional[Path]:
"""Return the newest ``.htm`` report in ``report_dir`` newer than ``since``."""
if not report_dir.exists():
return None
candidates = [
f for f in report_dir.glob("*.htm")
if f.stat().st_mtime >= since and f.stat().st_size > 1024
]
if not candidates:
return None
return max(candidates, key=lambda f: f.stat().st_mtime)
+71
View File
@@ -0,0 +1,71 @@
""".set file generator (doc 07 §2a).
A ``.set`` is the EA's inputs serialized as ``key=value`` lines. **MT5 writes
``.set`` files as UTF-16-LE** — generate yours in the same encoding or the
tester silently ignores them.
The bridge's set generator takes the *same* parameter dict you backtested in
Python and writes the matching ``.set``. The mapping from Python parameter
names to EA input names is strategy-specific — keep a small mapping table
next to the strategy so the two tiers always agree. Watch the enum-valued
inputs (mode flags, timeframe codes): MT5 inputs are often integers.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Mapping
SET_FILE_ENCODING = "utf-16-le"
@dataclass
class ParamMapping:
"""Maps a Python param name to an EA input name (+ enum cast if needed).
``cast`` converts a Python value to the EA input's wire form (e.g. a
timeframe string to its ``ENUM_TIMEFRAMES`` integer, a bool to ``true``/
``false``). Defaults to identity.
"""
py_name: str
ea_name: str
cast: Any = None # callable(value) -> str, optional
def to_wire(self, value: Any) -> str:
v = self.cast(value) if self.cast is not None else value
if isinstance(v, bool):
return "true" if v else "false"
if isinstance(v, float) and v.is_integer():
return str(int(v))
return str(v)
def write_set_file(
params: Mapping[str, Any],
mappings: list[ParamMapping],
path: str | Path,
*,
extra_lines: list[str] | None = None,
) -> None:
"""Write a ``.set`` file (UTF-16-LE) from a Python params dict.
Only parameters with a mapping are written — frozen baseline values that
match the EA's compiled-in defaults can be omitted. ``extra_lines`` lets a
strategy inject raw ``key=value`` lines that don't have a Python
counterpart (e.g. EA constants).
**Lot-mode guard (doc 05 §4, doc 07 §2a):** make sure the fixed-lot input
is ``0`` if you intend money mode — a mismatched ``.set`` is the #1 reason
a verified MT5 number disagrees with Python.
"""
out = Path(path)
out.parent.mkdir(parents=True, exist_ok=True)
lines: list[str] = ["; Generated by the MT5 bridge (UTF-16-LE)"]
for m in mappings:
if m.py_name in params:
lines.append(f"{m.ea_name}={m.to_wire(params[m.py_name])}")
if extra_lines:
lines.extend(extra_lines)
text = "\n".join(lines) + "\n"
out.write_bytes(text.encode(SET_FILE_ENCODING))