回测基本一致
This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -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}%"
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user