2026-06-24 13:57:49 +08:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
"""
|
|
|
|
|
|
Parse MT5 Strategy Tester HTML report.
|
|
|
|
|
|
|
|
|
|
|
|
Extracts: account properties, EA parameters, P&L metrics, orders, deals.
|
|
|
|
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
|
python skills/mql5/scripts/parse_tester_report.py <report.html>
|
|
|
|
|
|
python skills/mql5/scripts/parse_tester_report.py <report.html> --json
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
|
import json
|
|
|
|
|
|
import re
|
|
|
|
|
|
import sys
|
|
|
|
|
|
from dataclasses import dataclass, field, asdict
|
2026-07-04 01:18:33 +08:00
|
|
|
|
from datetime import datetime, timedelta
|
2026-06-24 13:57:49 +08:00
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
from bs4 import BeautifulSoup, Tag
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Data classes ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class Settings:
|
|
|
|
|
|
expert: str = ""
|
|
|
|
|
|
symbol: str = ""
|
|
|
|
|
|
period: str = ""
|
|
|
|
|
|
company: str = ""
|
|
|
|
|
|
currency: str = ""
|
|
|
|
|
|
initial_deposit: float = 0.0
|
|
|
|
|
|
leverage: str = ""
|
|
|
|
|
|
inputs: dict[str, str] = field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class Results:
|
|
|
|
|
|
history_quality: str = ""
|
|
|
|
|
|
bars: int = 0
|
|
|
|
|
|
ticks: int = 0
|
|
|
|
|
|
symbols: int = 0
|
|
|
|
|
|
total_net_profit: float = 0.0
|
|
|
|
|
|
gross_profit: float = 0.0
|
|
|
|
|
|
gross_loss: float = 0.0
|
|
|
|
|
|
balance_drawdown_abs: float = 0.0
|
|
|
|
|
|
balance_drawdown_max: float = 0.0
|
|
|
|
|
|
balance_drawdown_max_pct: float = 0.0
|
|
|
|
|
|
balance_drawdown_rel: float = 0.0
|
|
|
|
|
|
balance_drawdown_rel_pct: float = 0.0
|
|
|
|
|
|
equity_drawdown_abs: float = 0.0
|
|
|
|
|
|
equity_drawdown_max: float = 0.0
|
|
|
|
|
|
equity_drawdown_max_pct: float = 0.0
|
|
|
|
|
|
equity_drawdown_rel: float = 0.0
|
|
|
|
|
|
equity_drawdown_rel_pct: float = 0.0
|
|
|
|
|
|
profit_factor: float = 0.0
|
|
|
|
|
|
expected_payoff: float = 0.0
|
|
|
|
|
|
margin_level: float = 0.0
|
|
|
|
|
|
recovery_factor: float = 0.0
|
|
|
|
|
|
sharpe_ratio: float = 0.0
|
|
|
|
|
|
z_score: float = 0.0
|
|
|
|
|
|
z_score_pct: float = 0.0
|
|
|
|
|
|
ahpr: float = 0.0
|
|
|
|
|
|
ahpr_pct: float = 0.0
|
|
|
|
|
|
ghpr: float = 0.0
|
|
|
|
|
|
ghpr_pct: float = 0.0
|
|
|
|
|
|
lr_correlation: float = 0.0
|
|
|
|
|
|
lr_standard_error: float = 0.0
|
|
|
|
|
|
on_tester_result: float = 0.0
|
|
|
|
|
|
total_trades: int = 0
|
|
|
|
|
|
total_deals: int = 0
|
|
|
|
|
|
short_trades: int = 0
|
|
|
|
|
|
short_won_pct: float = 0.0
|
|
|
|
|
|
long_trades: int = 0
|
|
|
|
|
|
long_won_pct: float = 0.0
|
|
|
|
|
|
profit_trades: int = 0
|
|
|
|
|
|
profit_trades_pct: float = 0.0
|
|
|
|
|
|
loss_trades: int = 0
|
|
|
|
|
|
loss_trades_pct: float = 0.0
|
|
|
|
|
|
largest_profit_trade: float = 0.0
|
|
|
|
|
|
largest_loss_trade: float = 0.0
|
|
|
|
|
|
avg_profit_trade: float = 0.0
|
|
|
|
|
|
avg_loss_trade: float = 0.0
|
|
|
|
|
|
max_consec_wins: int = 0
|
|
|
|
|
|
max_consec_wins_amt: float = 0.0
|
|
|
|
|
|
max_consec_losses: int = 0
|
|
|
|
|
|
max_consec_losses_amt: float = 0.0
|
|
|
|
|
|
max_consec_profit: float = 0.0
|
|
|
|
|
|
max_consec_profit_count: int = 0
|
|
|
|
|
|
max_consec_loss: float = 0.0
|
|
|
|
|
|
max_consec_loss_count: int = 0
|
|
|
|
|
|
avg_consec_wins: int = 0
|
|
|
|
|
|
avg_consec_losses: int = 0
|
|
|
|
|
|
min_hold_time: str = ""
|
|
|
|
|
|
max_hold_time: str = ""
|
|
|
|
|
|
avg_hold_time: str = ""
|
|
|
|
|
|
# MFE/MAE
|
|
|
|
|
|
corr_profit_mfe: float = 0.0
|
|
|
|
|
|
corr_profit_mae: float = 0.0
|
|
|
|
|
|
corr_mfe_mae: float = 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class Order:
|
|
|
|
|
|
open_time: str = ""
|
|
|
|
|
|
order: int = 0
|
|
|
|
|
|
symbol: str = ""
|
|
|
|
|
|
type: str = ""
|
|
|
|
|
|
volume: str = ""
|
|
|
|
|
|
price: float = 0.0
|
|
|
|
|
|
sl: float = 0.0
|
|
|
|
|
|
tp: float = 0.0
|
|
|
|
|
|
close_time: str = ""
|
|
|
|
|
|
state: str = ""
|
|
|
|
|
|
comment: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class Deal:
|
|
|
|
|
|
time: str = ""
|
|
|
|
|
|
deal: int = 0
|
|
|
|
|
|
symbol: str = ""
|
|
|
|
|
|
type: str = ""
|
|
|
|
|
|
direction: str = ""
|
|
|
|
|
|
volume: float = 0.0
|
|
|
|
|
|
price: float = 0.0
|
|
|
|
|
|
order: int = 0
|
|
|
|
|
|
commission: float = 0.0
|
|
|
|
|
|
swap: float = 0.0
|
|
|
|
|
|
profit: float = 0.0
|
|
|
|
|
|
balance: float = 0.0
|
|
|
|
|
|
comment: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class Report:
|
|
|
|
|
|
settings: Settings = field(default_factory=Settings)
|
|
|
|
|
|
results: Results = field(default_factory=Results)
|
|
|
|
|
|
orders: list[Order] = field(default_factory=list)
|
|
|
|
|
|
deals: list[Deal] = field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Parsing helpers ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def decode_html(path: Path) -> str:
|
|
|
|
|
|
"""Read MT5 report (UTF-16LE) and return UTF-8 string."""
|
|
|
|
|
|
raw = path.read_bytes()
|
|
|
|
|
|
# Detect BOM
|
|
|
|
|
|
if raw[:2] == b"\xff\xfe":
|
|
|
|
|
|
return raw.decode("utf-16-le")
|
|
|
|
|
|
if raw[:2] == b"\xfe\xff":
|
|
|
|
|
|
return raw.decode("utf-16-be")
|
|
|
|
|
|
# Try utf-16-le without BOM
|
|
|
|
|
|
try:
|
|
|
|
|
|
return raw.decode("utf-16-le")
|
|
|
|
|
|
except UnicodeDecodeError:
|
|
|
|
|
|
return raw.decode("utf-8", errors="replace")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_number(text: str) -> float:
|
|
|
|
|
|
"""Parse number from MT5 report format: '1 305.90' → 1305.90, '-201.39' → -201.39"""
|
|
|
|
|
|
text = text.strip()
|
|
|
|
|
|
if not text:
|
|
|
|
|
|
return 0.0
|
|
|
|
|
|
# Remove spaces used as thousand separators
|
|
|
|
|
|
text = text.replace(" ", "")
|
|
|
|
|
|
# Extract first number-like token (may include %, parentheses)
|
|
|
|
|
|
m = re.search(r"[-\d][\d,.]*", text)
|
|
|
|
|
|
if not m:
|
|
|
|
|
|
return 0.0
|
|
|
|
|
|
num_str = m.group().replace(",", "")
|
|
|
|
|
|
try:
|
|
|
|
|
|
return float(num_str)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
return 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_pct(text: str) -> float:
|
|
|
|
|
|
"""Extract percentage value: '100.27% (516.89)' → 100.27"""
|
|
|
|
|
|
m = re.search(r"([\d.]+)%", text)
|
|
|
|
|
|
return float(m.group(1)) if m else 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def td_text(td: Tag) -> str:
|
|
|
|
|
|
"""Get text content of a <td>, stripping whitespace."""
|
|
|
|
|
|
return td.get_text(strip=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Main parser ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def parse_report(html_path: Path) -> Report:
|
|
|
|
|
|
html = decode_html(html_path)
|
|
|
|
|
|
soup = BeautifulSoup(html, "html.parser")
|
|
|
|
|
|
report = Report()
|
|
|
|
|
|
|
|
|
|
|
|
tables = soup.find_all("table")
|
|
|
|
|
|
if not tables:
|
|
|
|
|
|
print("Error: no tables found in HTML", file=sys.stderr)
|
|
|
|
|
|
return report
|
|
|
|
|
|
|
|
|
|
|
|
# ── Table 0: Settings + Results ──────────────────────────────────
|
|
|
|
|
|
main_table = tables[0]
|
|
|
|
|
|
rows = main_table.find_all("tr")
|
|
|
|
|
|
|
|
|
|
|
|
section = "settings"
|
|
|
|
|
|
stats_map: dict[str, str] = {}
|
|
|
|
|
|
|
|
|
|
|
|
for row in rows:
|
|
|
|
|
|
cells = row.find_all(["td", "th"])
|
|
|
|
|
|
if not cells:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# Detect section headers
|
|
|
|
|
|
text_all = " ".join(td_text(c) for c in cells)
|
|
|
|
|
|
if "Settings" in text_all and len(cells) <= 3:
|
|
|
|
|
|
section = "settings"
|
|
|
|
|
|
continue
|
|
|
|
|
|
if "Results" in text_all and len(cells) <= 3:
|
|
|
|
|
|
section = "results"
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
if section == "settings":
|
|
|
|
|
|
# Settings rows: label in col 0-2, value in col 3+
|
|
|
|
|
|
if len(cells) < 2:
|
|
|
|
|
|
continue
|
|
|
|
|
|
label = td_text(cells[0])
|
|
|
|
|
|
# Input parameters: label is empty, value is in the next cell
|
|
|
|
|
|
if not label and len(cells) >= 2:
|
|
|
|
|
|
val = td_text(cells[-1])
|
|
|
|
|
|
if val.startswith("==="):
|
|
|
|
|
|
continue # group header
|
|
|
|
|
|
if "=" in val:
|
|
|
|
|
|
k, v = val.split("=", 1)
|
|
|
|
|
|
report.settings.inputs[k.strip()] = v.strip()
|
|
|
|
|
|
continue
|
|
|
|
|
|
# Standard settings fields
|
|
|
|
|
|
if label.endswith(":"):
|
|
|
|
|
|
label = label[:-1]
|
|
|
|
|
|
val = td_text(cells[-1]) if len(cells) >= 2 else ""
|
|
|
|
|
|
if label == "Expert":
|
|
|
|
|
|
report.settings.expert = val
|
|
|
|
|
|
elif label == "Symbol":
|
|
|
|
|
|
report.settings.symbol = val
|
|
|
|
|
|
elif label == "Period":
|
|
|
|
|
|
report.settings.period = val
|
|
|
|
|
|
elif label == "Company":
|
|
|
|
|
|
report.settings.company = val
|
|
|
|
|
|
elif label == "Currency":
|
|
|
|
|
|
report.settings.currency = val
|
|
|
|
|
|
elif label == "Initial Deposit":
|
|
|
|
|
|
report.settings.initial_deposit = parse_number(val)
|
|
|
|
|
|
elif label == "Leverage":
|
|
|
|
|
|
report.settings.leverage = val
|
|
|
|
|
|
|
|
|
|
|
|
elif section == "results":
|
|
|
|
|
|
# Results: find label cells (ending with ":") and pair with next cell
|
|
|
|
|
|
for i, cell in enumerate(cells):
|
|
|
|
|
|
lbl = td_text(cell)
|
|
|
|
|
|
if not lbl.endswith(":") or not lbl:
|
|
|
|
|
|
continue
|
|
|
|
|
|
lbl = lbl.rstrip(":")
|
|
|
|
|
|
# Value is the next cell
|
|
|
|
|
|
if i + 1 < len(cells):
|
|
|
|
|
|
val = td_text(cells[i + 1])
|
|
|
|
|
|
else:
|
|
|
|
|
|
val = ""
|
|
|
|
|
|
stats_map[lbl] = val
|
|
|
|
|
|
|
|
|
|
|
|
# ── Map stats_map to Results fields ──────────────────────────────
|
|
|
|
|
|
r = report.results
|
|
|
|
|
|
r.history_quality = stats_map.get("History Quality", "")
|
|
|
|
|
|
r.bars = int(parse_number(stats_map.get("Bars", "0")))
|
|
|
|
|
|
r.ticks = int(parse_number(stats_map.get("Ticks", "0")))
|
|
|
|
|
|
r.symbols = int(parse_number(stats_map.get("Symbols", "0")))
|
|
|
|
|
|
r.total_net_profit = parse_number(stats_map.get("Total Net Profit", "0"))
|
|
|
|
|
|
r.gross_profit = parse_number(stats_map.get("Gross Profit", "0"))
|
|
|
|
|
|
r.gross_loss = parse_number(stats_map.get("Gross Loss", "0"))
|
|
|
|
|
|
r.balance_drawdown_abs = parse_number(stats_map.get("Balance Drawdown Absolute", "0"))
|
|
|
|
|
|
r.balance_drawdown_max = parse_number(stats_map.get("Balance Drawdown Maximal", "0"))
|
|
|
|
|
|
r.balance_drawdown_max_pct = parse_pct(stats_map.get("Balance Drawdown Maximal", "0"))
|
|
|
|
|
|
r.balance_drawdown_rel = parse_number(stats_map.get("Balance Drawdown Relative", "0"))
|
|
|
|
|
|
r.balance_drawdown_rel_pct = parse_pct(stats_map.get("Balance Drawdown Relative", "0"))
|
|
|
|
|
|
r.equity_drawdown_abs = parse_number(stats_map.get("Equity Drawdown Absolute", "0"))
|
|
|
|
|
|
r.equity_drawdown_max = parse_number(stats_map.get("Equity Drawdown Maximal", "0"))
|
|
|
|
|
|
r.equity_drawdown_max_pct = parse_pct(stats_map.get("Equity Drawdown Maximal", "0"))
|
|
|
|
|
|
r.equity_drawdown_rel = parse_number(stats_map.get("Equity Drawdown Relative", "0"))
|
|
|
|
|
|
r.equity_drawdown_rel_pct = parse_pct(stats_map.get("Equity Drawdown Relative", "0"))
|
|
|
|
|
|
r.profit_factor = parse_number(stats_map.get("Profit Factor", "0"))
|
|
|
|
|
|
r.expected_payoff = parse_number(stats_map.get("Expected Payoff", "0"))
|
|
|
|
|
|
r.margin_level = parse_pct(stats_map.get("Margin Level", "0"))
|
|
|
|
|
|
r.recovery_factor = parse_number(stats_map.get("Recovery Factor", "0"))
|
|
|
|
|
|
r.sharpe_ratio = parse_number(stats_map.get("Sharpe Ratio", "0"))
|
|
|
|
|
|
z = stats_map.get("Z-Score", "0")
|
|
|
|
|
|
r.z_score = parse_number(z)
|
|
|
|
|
|
r.z_score_pct = parse_pct(z)
|
|
|
|
|
|
ahpr = stats_map.get("AHPR", "0")
|
|
|
|
|
|
r.ahpr = parse_number(ahpr)
|
|
|
|
|
|
r.ahpr_pct = parse_pct(ahpr)
|
|
|
|
|
|
ghpr = stats_map.get("GHPR", "0")
|
|
|
|
|
|
r.ghpr = parse_number(ghpr)
|
|
|
|
|
|
r.ghpr_pct = parse_pct(ghpr)
|
|
|
|
|
|
r.lr_correlation = parse_number(stats_map.get("LR Correlation", "0"))
|
|
|
|
|
|
r.lr_standard_error = parse_number(stats_map.get("LR Standard Error", "0"))
|
|
|
|
|
|
r.on_tester_result = parse_number(stats_map.get("OnTester result", "0"))
|
|
|
|
|
|
r.total_trades = int(parse_number(stats_map.get("Total Trades", "0")))
|
|
|
|
|
|
r.total_deals = int(parse_number(stats_map.get("Total Deals", "0")))
|
|
|
|
|
|
|
|
|
|
|
|
# Parse Short/Long Trades: "5 (20.00%)"
|
|
|
|
|
|
short = stats_map.get("Short Trades (won %)", "0")
|
|
|
|
|
|
r.short_trades = int(parse_number(short))
|
|
|
|
|
|
r.short_won_pct = parse_pct(short)
|
|
|
|
|
|
long = stats_map.get("Long Trades (won %)", "0")
|
|
|
|
|
|
r.long_trades = int(parse_number(long))
|
|
|
|
|
|
r.long_won_pct = parse_pct(long)
|
|
|
|
|
|
|
|
|
|
|
|
pt = stats_map.get("Profit Trades (% of total)", "0")
|
|
|
|
|
|
r.profit_trades = int(parse_number(pt))
|
|
|
|
|
|
r.profit_trades_pct = parse_pct(pt)
|
|
|
|
|
|
lt = stats_map.get("Loss Trades (% of total)", "0")
|
|
|
|
|
|
r.loss_trades = int(parse_number(lt))
|
|
|
|
|
|
r.loss_trades_pct = parse_pct(lt)
|
|
|
|
|
|
|
|
|
|
|
|
r.largest_profit_trade = parse_number(stats_map.get("Largest profit trade", "0"))
|
|
|
|
|
|
r.largest_loss_trade = parse_number(stats_map.get("Largest loss trade", "0"))
|
|
|
|
|
|
r.avg_profit_trade = parse_number(stats_map.get("Average profit trade", "0"))
|
|
|
|
|
|
r.avg_loss_trade = parse_number(stats_map.get("Average loss trade", "0"))
|
|
|
|
|
|
|
|
|
|
|
|
# Consecutive: "3 (85.31)" or "1"
|
|
|
|
|
|
mcw = stats_map.get("Maximum consecutive wins ($)", "0")
|
|
|
|
|
|
r.max_consec_wins = int(parse_number(mcw))
|
|
|
|
|
|
m = re.search(r"\(([-\d.]+)\)", mcw)
|
|
|
|
|
|
r.max_consec_wins_amt = float(m.group(1)) if m else 0.0
|
|
|
|
|
|
|
|
|
|
|
|
mcl = stats_map.get("Maximum consecutive losses ($)", "0")
|
|
|
|
|
|
r.max_consec_losses = int(parse_number(mcl))
|
|
|
|
|
|
m = re.search(r"\(([-\d.]+)\)", mcl)
|
|
|
|
|
|
r.max_consec_losses_amt = float(m.group(1)) if m else 0.0
|
|
|
|
|
|
|
|
|
|
|
|
# "361.91 (2)"
|
|
|
|
|
|
mcp = stats_map.get("Maximal consecutive profit (count)", "0")
|
|
|
|
|
|
r.max_consec_profit = parse_number(mcp)
|
|
|
|
|
|
m = re.search(r"\((\d+)\)", mcp)
|
|
|
|
|
|
r.max_consec_profit_count = int(m.group(1)) if m else 0
|
|
|
|
|
|
|
|
|
|
|
|
mcl2 = stats_map.get("Maximal consecutive loss (count)", "0")
|
|
|
|
|
|
r.max_consec_loss = parse_number(mcl2)
|
|
|
|
|
|
m = re.search(r"\((\d+)\)", mcl2)
|
|
|
|
|
|
r.max_consec_loss_count = int(m.group(1)) if m else 0
|
|
|
|
|
|
|
|
|
|
|
|
r.avg_consec_wins = int(parse_number(stats_map.get("Average consecutive wins", "0")))
|
|
|
|
|
|
r.avg_consec_losses = int(parse_number(stats_map.get("Average consecutive losses", "0")))
|
|
|
|
|
|
|
|
|
|
|
|
r.min_hold_time = stats_map.get("Minimal position holding time", "")
|
|
|
|
|
|
r.max_hold_time = stats_map.get("Maximal position holding time", "")
|
|
|
|
|
|
r.avg_hold_time = stats_map.get("Average position holding time", "")
|
|
|
|
|
|
|
|
|
|
|
|
r.corr_profit_mfe = parse_number(stats_map.get("Correlation (Profits,MFE)", "0"))
|
|
|
|
|
|
r.corr_profit_mae = parse_number(stats_map.get("Correlation (Profits,MAE)", "0"))
|
|
|
|
|
|
r.corr_mfe_mae = parse_number(stats_map.get("Correlation (MFE,MAE)", "0"))
|
|
|
|
|
|
|
|
|
|
|
|
# ── Table 1+: Orders and Deals ───────────────────────────────────
|
|
|
|
|
|
# The second table contains both Orders and Deals sections,
|
|
|
|
|
|
# each with their own header row (bgcolor=#E5F0FC)
|
|
|
|
|
|
for tbl in tables[1:]:
|
|
|
|
|
|
header_rows = tbl.find_all("tr", bgcolor=re.compile(r"#E5F0FC"))
|
|
|
|
|
|
for header_row in header_rows:
|
|
|
|
|
|
headers = [td_text(th) for th in header_row.find_all(["td", "th"])]
|
|
|
|
|
|
|
|
|
|
|
|
# Find data rows that follow this header (until next header or end)
|
|
|
|
|
|
all_rows = tbl.find_all("tr")
|
|
|
|
|
|
hdr_idx = all_rows.index(header_row)
|
|
|
|
|
|
data_rows = []
|
|
|
|
|
|
for r in all_rows[hdr_idx + 1:]:
|
|
|
|
|
|
bg = r.get("bgcolor", "")
|
|
|
|
|
|
if re.match(r"#(FFFFFF|F7F7F7)", str(bg)):
|
|
|
|
|
|
data_rows.append(r)
|
|
|
|
|
|
elif r.find("th") and ("Deals" in td_text(r) or "Orders" in td_text(r)):
|
|
|
|
|
|
break # next section header
|
|
|
|
|
|
|
|
|
|
|
|
if "Open Time" in headers and "Order" in headers:
|
|
|
|
|
|
# Orders table — cells are in order, colspan only affects visual layout
|
|
|
|
|
|
for dr in data_rows:
|
|
|
|
|
|
cells = dr.find_all("td")
|
|
|
|
|
|
if len(cells) < 10:
|
|
|
|
|
|
continue
|
|
|
|
|
|
vals = [td_text(c) for c in cells]
|
|
|
|
|
|
order = Order(
|
|
|
|
|
|
open_time=vals[0],
|
|
|
|
|
|
order=int(parse_number(vals[1])),
|
|
|
|
|
|
symbol=vals[2],
|
|
|
|
|
|
type=vals[3],
|
|
|
|
|
|
volume=vals[4],
|
|
|
|
|
|
price=parse_number(vals[5]),
|
|
|
|
|
|
sl=parse_number(vals[6]),
|
|
|
|
|
|
tp=parse_number(vals[7]),
|
|
|
|
|
|
close_time=vals[8],
|
|
|
|
|
|
state=vals[9],
|
|
|
|
|
|
comment=vals[10] if len(vals) > 10 else "",
|
|
|
|
|
|
)
|
|
|
|
|
|
report.orders.append(order)
|
|
|
|
|
|
|
|
|
|
|
|
elif "Deal" in headers and "Direction" in headers:
|
|
|
|
|
|
# Deals table
|
|
|
|
|
|
for dr in data_rows:
|
|
|
|
|
|
cells = dr.find_all("td")
|
|
|
|
|
|
if len(cells) < 10:
|
|
|
|
|
|
continue
|
|
|
|
|
|
vals = [td_text(c) for c in cells]
|
|
|
|
|
|
deal = Deal(
|
|
|
|
|
|
time=vals[0],
|
|
|
|
|
|
deal=int(parse_number(vals[1])),
|
|
|
|
|
|
symbol=vals[2],
|
|
|
|
|
|
type=vals[3],
|
|
|
|
|
|
direction=vals[4],
|
|
|
|
|
|
volume=parse_number(vals[5]),
|
|
|
|
|
|
price=parse_number(vals[6]),
|
|
|
|
|
|
order=int(parse_number(vals[7])),
|
|
|
|
|
|
commission=parse_number(vals[8]),
|
|
|
|
|
|
swap=parse_number(vals[9]),
|
|
|
|
|
|
profit=parse_number(vals[10]),
|
|
|
|
|
|
balance=parse_number(vals[11]),
|
|
|
|
|
|
comment=vals[12] if len(vals) > 12 else "",
|
|
|
|
|
|
)
|
|
|
|
|
|
report.deals.append(deal)
|
|
|
|
|
|
|
|
|
|
|
|
return report
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Pretty print ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-06-29 12:20:16 +08:00
|
|
|
|
def print_report(r: Report, analyze_data: dict | None = None) -> None:
|
2026-06-24 13:57:49 +08:00
|
|
|
|
s = r.settings
|
|
|
|
|
|
res = r.results
|
|
|
|
|
|
|
|
|
|
|
|
print("=" * 72)
|
|
|
|
|
|
print(" MT5 Strategy Tester Report")
|
|
|
|
|
|
print("=" * 72)
|
|
|
|
|
|
|
|
|
|
|
|
print(f"\n Expert: {s.expert}")
|
|
|
|
|
|
print(f" Symbol: {s.symbol}")
|
|
|
|
|
|
print(f" Period: {s.period}")
|
|
|
|
|
|
print(f" Company: {s.company}")
|
|
|
|
|
|
print(f" Currency: {s.currency}")
|
|
|
|
|
|
print(f" Deposit: {s.initial_deposit:,.2f}")
|
|
|
|
|
|
print(f" Leverage: {s.leverage}")
|
|
|
|
|
|
|
|
|
|
|
|
if s.inputs:
|
|
|
|
|
|
print(f"\n EA Parameters ({len(s.inputs)}):")
|
|
|
|
|
|
for k, v in s.inputs.items():
|
|
|
|
|
|
print(f" {k} = {v}")
|
|
|
|
|
|
|
|
|
|
|
|
print(f"\n{'─' * 72}")
|
|
|
|
|
|
print(" Data Quality")
|
|
|
|
|
|
print(f"{'─' * 72}")
|
|
|
|
|
|
print(f" History Quality: {res.history_quality}")
|
|
|
|
|
|
print(f" Bars: {res.bars:,}")
|
|
|
|
|
|
print(f" Ticks: {res.ticks:,}")
|
|
|
|
|
|
print(f" Symbols: {res.symbols}")
|
|
|
|
|
|
|
|
|
|
|
|
print(f"\n{'─' * 72}")
|
|
|
|
|
|
print(" P&L Summary")
|
|
|
|
|
|
print(f"{'─' * 72}")
|
|
|
|
|
|
print(f" Net Profit: {res.total_net_profit:>12,.2f}")
|
|
|
|
|
|
print(f" Gross Profit: {res.gross_profit:>12,.2f}")
|
|
|
|
|
|
print(f" Gross Loss: {res.gross_loss:>12,.2f}")
|
|
|
|
|
|
print(f" Profit Factor: {res.profit_factor:>12.2f}")
|
|
|
|
|
|
print(f" Expected Payoff: {res.expected_payoff:>12.2f}")
|
|
|
|
|
|
print(f" Recovery Factor: {res.recovery_factor:>12.2f}")
|
|
|
|
|
|
print(f" Sharpe Ratio: {res.sharpe_ratio:>12.2f}")
|
|
|
|
|
|
|
|
|
|
|
|
print(f"\n{'─' * 72}")
|
|
|
|
|
|
print(" Drawdown")
|
|
|
|
|
|
print(f"{'─' * 72}")
|
|
|
|
|
|
print(f" Balance Abs: {res.balance_drawdown_abs:>12,.2f}")
|
|
|
|
|
|
print(f" Balance Max: {res.balance_drawdown_max:>12,.2f} ({res.balance_drawdown_max_pct:.2f}%)")
|
|
|
|
|
|
print(f" Balance Rel: {res.balance_drawdown_rel_pct:.2f}% ({res.balance_drawdown_rel:,.2f})")
|
|
|
|
|
|
print(f" Equity Abs: {res.equity_drawdown_abs:>12,.2f}")
|
|
|
|
|
|
print(f" Equity Max: {res.equity_drawdown_max:>12,.2f} ({res.equity_drawdown_max_pct:.2f}%)")
|
|
|
|
|
|
print(f" Equity Rel: {res.equity_drawdown_rel_pct:.2f}% ({res.equity_drawdown_rel:,.2f})")
|
|
|
|
|
|
|
|
|
|
|
|
print(f"\n{'─' * 72}")
|
|
|
|
|
|
print(" Trade Statistics")
|
|
|
|
|
|
print(f"{'─' * 72}")
|
|
|
|
|
|
print(f" Total Trades: {res.total_trades:>8} Total Deals: {res.total_deals}")
|
|
|
|
|
|
print(f" Short (won%): {res.short_trades:>8} ({res.short_won_pct:.2f}%)")
|
|
|
|
|
|
print(f" Long (won%): {res.long_trades:>8} ({res.long_won_pct:.2f}%)")
|
|
|
|
|
|
print(f" Profit Trades: {res.profit_trades:>8} ({res.profit_trades_pct:.2f}%)")
|
|
|
|
|
|
print(f" Loss Trades: {res.loss_trades:>8} ({res.loss_trades_pct:.2f}%)")
|
|
|
|
|
|
print(f" Largest Win: {res.largest_profit_trade:>12,.2f}")
|
|
|
|
|
|
print(f" Largest Loss: {res.largest_loss_trade:>12,.2f}")
|
|
|
|
|
|
print(f" Avg Win: {res.avg_profit_trade:>12,.2f}")
|
|
|
|
|
|
print(f" Avg Loss: {res.avg_loss_trade:>12,.2f}")
|
|
|
|
|
|
print(f" Max Consec Wins: {res.max_consec_wins:>4} (${res.max_consec_wins_amt:,.2f})")
|
|
|
|
|
|
print(f" Max Consec Loss: {res.max_consec_losses:>4} (${res.max_consec_losses_amt:,.2f})")
|
|
|
|
|
|
|
|
|
|
|
|
print(f"\n{'─' * 72}")
|
|
|
|
|
|
print(" Holding Times")
|
|
|
|
|
|
print(f"{'─' * 72}")
|
|
|
|
|
|
print(f" Min: {res.min_hold_time} Max: {res.max_hold_time} Avg: {res.avg_hold_time}")
|
2026-06-29 12:20:16 +08:00
|
|
|
|
if analyze_data:
|
|
|
|
|
|
print(f" Idle (no position): {analyze_data.get('idle_time', '')}")
|
2026-06-24 13:57:49 +08:00
|
|
|
|
|
|
|
|
|
|
print(f"\n{'─' * 72}")
|
|
|
|
|
|
print(f" Orders: {len(r.orders)} Deals: {len(r.deals)}")
|
|
|
|
|
|
print(f"{'─' * 72}")
|
|
|
|
|
|
|
|
|
|
|
|
if r.orders:
|
|
|
|
|
|
print(f"\n {'Open Time':<20} {'Ord':>5} {'Type':<5} {'Vol':>6} {'Price':>10} {'SL':>10} {'TP':>10} {'State':<8} {'Comment'}")
|
|
|
|
|
|
for o in r.orders[:10]:
|
|
|
|
|
|
print(f" {o.open_time:<20} {o.order:>5} {o.type:<5} {o.volume:>6} {o.price:>10.2f} {o.sl:>10.2f} {o.tp:>10.2f} {o.state:<8} {o.comment}")
|
|
|
|
|
|
if len(r.orders) > 10:
|
|
|
|
|
|
print(f" ... ({len(r.orders) - 10} more)")
|
|
|
|
|
|
|
|
|
|
|
|
if r.deals:
|
|
|
|
|
|
print(f"\n {'Time':<20} {'Deal':>5} {'Type':<5} {'Dir':<4} {'Vol':>6} {'Price':>10} {'Comm':>8} {'Swap':>8} {'Profit':>10} {'Balance':>10}")
|
|
|
|
|
|
for d in r.deals[:10]:
|
|
|
|
|
|
print(f" {d.time:<20} {d.deal:>5} {d.type:<5} {d.direction:<4} {d.volume:>6.2f} {d.price:>10.2f} {d.commission:>8.2f} {d.swap:>8.2f} {d.profit:>10.2f} {d.balance:>10.2f}")
|
|
|
|
|
|
if len(r.deals) > 10:
|
|
|
|
|
|
print(f" ... ({len(r.deals) - 10} more)")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-25 02:51:22 +08:00
|
|
|
|
|
|
|
|
|
|
# ── Trade Analysis ───────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def pair_trades(deals: list) -> list:
|
2026-07-04 01:18:33 +08:00
|
|
|
|
"""Pair entry/exit deals into complete trades.
|
|
|
|
|
|
|
|
|
|
|
|
Each trade is returned with two P&L views:
|
|
|
|
|
|
• `net` = exit.profit + entry.commission + exit.commission + entry.swap + exit.swap
|
|
|
|
|
|
This equals the total change in balance from the trade and
|
|
|
|
|
|
matches MT5's `STAT_PROFIT` summation (sum of `net` over all
|
|
|
|
|
|
trades = `Total Net Profit`).
|
|
|
|
|
|
• `gross_pnl` = exit.profit + exit.commission + exit.swap
|
|
|
|
|
|
This is the "exit leg" P&L. MT5 splits this between
|
|
|
|
|
|
`STAT_GROSS_PROFIT` and `STAT_GROSS_LOSS` based on its sign
|
|
|
|
|
|
(see `compute_gross_profit_loss` below), and any entry
|
|
|
|
|
|
commission/swap always goes to `STAT_GROSS_LOSS`.
|
|
|
|
|
|
|
|
|
|
|
|
The MT5 accounting quirk (entry costs in GL regardless of trade
|
|
|
|
|
|
outcome) is the reason we keep `gross_pnl` separate from `net`
|
|
|
|
|
|
rather than overloading `net` to also drive GP/GL.
|
|
|
|
|
|
"""
|
2026-06-25 02:51:22 +08:00
|
|
|
|
trading = [d for d in deals if d.type != "balance"]
|
|
|
|
|
|
|
|
|
|
|
|
trades = []
|
|
|
|
|
|
i = 0
|
|
|
|
|
|
while i < len(trading):
|
|
|
|
|
|
if trading[i].direction == "in":
|
|
|
|
|
|
entry = trading[i]
|
|
|
|
|
|
if i + 1 < len(trading) and trading[i + 1].direction == "out":
|
|
|
|
|
|
exit_d = trading[i + 1]
|
|
|
|
|
|
net = (exit_d.profit + entry.commission + exit_d.commission
|
|
|
|
|
|
+ entry.swap + exit_d.swap)
|
2026-07-04 01:18:33 +08:00
|
|
|
|
gross_pnl = exit_d.profit + exit_d.commission + exit_d.swap
|
2026-06-25 02:51:22 +08:00
|
|
|
|
sl_dist = 0.0
|
|
|
|
|
|
if "sl" in exit_d.comment:
|
|
|
|
|
|
sl_dist = abs(entry.price - exit_d.price)
|
|
|
|
|
|
trades.append({
|
|
|
|
|
|
"open_time": entry.time,
|
|
|
|
|
|
"close_time": exit_d.time,
|
|
|
|
|
|
"type": entry.type,
|
|
|
|
|
|
"volume": entry.volume,
|
|
|
|
|
|
"entry": entry.price,
|
|
|
|
|
|
"exit": exit_d.price,
|
|
|
|
|
|
"profit": exit_d.profit,
|
|
|
|
|
|
"commission": entry.commission + exit_d.commission,
|
|
|
|
|
|
"swap": entry.swap + exit_d.swap,
|
|
|
|
|
|
"net": net,
|
2026-07-04 01:18:33 +08:00
|
|
|
|
"gross_pnl": gross_pnl,
|
|
|
|
|
|
"entry_costs": entry.commission + entry.swap,
|
2026-06-25 02:51:22 +08:00
|
|
|
|
"comment": exit_d.comment,
|
|
|
|
|
|
"sl_distance": sl_dist,
|
|
|
|
|
|
})
|
|
|
|
|
|
i += 2
|
|
|
|
|
|
else:
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
return trades
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-04 01:18:33 +08:00
|
|
|
|
def compute_gross_profit_loss(trades: list) -> tuple[float, float]:
|
|
|
|
|
|
"""Compute MT5's STAT_GROSS_PROFIT / STAT_GROSS_LOSS from a list of trades.
|
|
|
|
|
|
|
|
|
|
|
|
Returns (gross_profit, gross_loss). Per MT5:
|
|
|
|
|
|
• For each trade, split `gross_pnl` (= exit.profit + exit.commission
|
|
|
|
|
|
+ exit.swap) by sign into GP (positive) or GL (negative/zero).
|
|
|
|
|
|
• Entry costs (entry.commission + entry.swap) ALWAYS go to GL.
|
|
|
|
|
|
|
|
|
|
|
|
Use this instead of naive `sum(positive nets)` — verified against
|
|
|
|
|
|
the 246753 reference report (matches exactly).
|
|
|
|
|
|
"""
|
|
|
|
|
|
gp = 0.0
|
|
|
|
|
|
gl = 0.0
|
|
|
|
|
|
for t in trades:
|
|
|
|
|
|
if t["gross_pnl"] > 0:
|
|
|
|
|
|
gp += t["gross_pnl"]
|
|
|
|
|
|
else:
|
|
|
|
|
|
gl += t["gross_pnl"]
|
|
|
|
|
|
gl += t["entry_costs"]
|
|
|
|
|
|
return round(gp, 2), round(gl, 2)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 12:20:16 +08:00
|
|
|
|
def format_duration(td) -> str:
|
|
|
|
|
|
"""Format timedelta as HH:MM:SS."""
|
|
|
|
|
|
total = int(td.total_seconds())
|
|
|
|
|
|
sign = "-" if total < 0 else ""
|
|
|
|
|
|
total = abs(total)
|
|
|
|
|
|
h, rem = divmod(total, 3600)
|
|
|
|
|
|
m, s = divmod(rem, 60)
|
|
|
|
|
|
return f"{sign}{h:02d}:{m:02d}:{s:02d}"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-25 02:51:22 +08:00
|
|
|
|
def analyze_report(report: Report) -> dict:
|
|
|
|
|
|
"""Run full trade analysis on parsed report."""
|
|
|
|
|
|
|
|
|
|
|
|
deposit = report.settings.initial_deposit
|
|
|
|
|
|
trades = pair_trades(report.deals)
|
|
|
|
|
|
|
|
|
|
|
|
if not trades:
|
|
|
|
|
|
return {"error": "No trades found", "trades": []}
|
|
|
|
|
|
|
2026-06-29 12:20:16 +08:00
|
|
|
|
# Parse backtest start/end dates from period string
|
|
|
|
|
|
# e.g. "H4 (2024.01.01 - 2025.06.22)"
|
|
|
|
|
|
bt_start = None
|
2026-06-25 03:10:32 +08:00
|
|
|
|
bt_end = None
|
|
|
|
|
|
period = report.settings.period
|
2026-06-29 12:20:16 +08:00
|
|
|
|
m_dates = re.search(
|
|
|
|
|
|
r"(\d{4}\.\d{2}\.\d{2})\s*-\s*(\d{4}\.\d{2}\.\d{2})\s*\)\s*$", period
|
|
|
|
|
|
)
|
|
|
|
|
|
if m_dates:
|
2026-06-25 03:10:32 +08:00
|
|
|
|
try:
|
2026-06-29 12:20:16 +08:00
|
|
|
|
bt_start = datetime.strptime(m_dates.group(1), "%Y.%m.%d")
|
|
|
|
|
|
bt_end = datetime.strptime(m_dates.group(2), "%Y.%m.%d")
|
2026-06-25 03:12:48 +08:00
|
|
|
|
except ValueError:
|
2026-06-25 03:10:32 +08:00
|
|
|
|
pass
|
|
|
|
|
|
|
2026-06-25 02:51:22 +08:00
|
|
|
|
# Per-trade risk check
|
|
|
|
|
|
for t in trades:
|
|
|
|
|
|
t["risk_pct"] = abs(t["net"]) / deposit * 100 if deposit > 0 else 0
|
|
|
|
|
|
|
|
|
|
|
|
# SL hit vs TP hit
|
|
|
|
|
|
sl_trades = [t for t in trades if "sl " in t["comment"]]
|
|
|
|
|
|
tp_trades = [t for t in trades if "tp " in t["comment"]]
|
|
|
|
|
|
other = [t for t in trades if t not in sl_trades and t not in tp_trades]
|
|
|
|
|
|
|
|
|
|
|
|
avg_win = (sum(t["net"] for t in tp_trades) / len(tp_trades)) if tp_trades else 0
|
|
|
|
|
|
avg_loss = (sum(t["net"] for t in sl_trades) / len(sl_trades)) if sl_trades else 0
|
|
|
|
|
|
win_loss_ratio = abs(avg_win / avg_loss) if avg_loss != 0 else 0
|
|
|
|
|
|
breakeven_wr = (abs(avg_loss) / (avg_win + abs(avg_loss))
|
|
|
|
|
|
if (avg_win + abs(avg_loss)) > 0 else 0)
|
|
|
|
|
|
|
|
|
|
|
|
# Consecutive loss analysis
|
|
|
|
|
|
streaks = []
|
|
|
|
|
|
streak = 0
|
|
|
|
|
|
for t in trades:
|
|
|
|
|
|
if t["net"] <= 0:
|
|
|
|
|
|
streak += 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
if streak > 0:
|
|
|
|
|
|
streaks.append(streak)
|
|
|
|
|
|
streak = 0
|
|
|
|
|
|
if streak > 0:
|
|
|
|
|
|
streaks.append(streak)
|
|
|
|
|
|
|
|
|
|
|
|
# Re-entry detection: SL hit followed by same direction with larger lot
|
|
|
|
|
|
reentries = []
|
|
|
|
|
|
for i in range(len(trades) - 1):
|
|
|
|
|
|
t1, t2 = trades[i], trades[i + 1]
|
|
|
|
|
|
if "sl " in t1["comment"] and t1["type"] == t2["type"]:
|
|
|
|
|
|
if t2["volume"] > t1["volume"]:
|
|
|
|
|
|
reentries.append({
|
|
|
|
|
|
"after_trade": i + 1,
|
|
|
|
|
|
"time": t2["open_time"],
|
|
|
|
|
|
"type": t2["type"],
|
|
|
|
|
|
"prev_lot": t1["volume"],
|
|
|
|
|
|
"new_lot": t2["volume"],
|
|
|
|
|
|
"multiplier": round(t2["volume"] / t1["volume"], 1),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
# Monthly breakdown
|
|
|
|
|
|
monthly = {}
|
|
|
|
|
|
for t in trades:
|
|
|
|
|
|
month = t["open_time"][:7]
|
|
|
|
|
|
if month not in monthly:
|
|
|
|
|
|
monthly[month] = {"count": 0, "net": 0.0, "wins": 0, "losses": 0}
|
|
|
|
|
|
monthly[month]["count"] += 1
|
|
|
|
|
|
monthly[month]["net"] += t["net"]
|
|
|
|
|
|
if t["net"] > 0:
|
|
|
|
|
|
monthly[month]["wins"] += 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
monthly[month]["losses"] += 1
|
|
|
|
|
|
|
|
|
|
|
|
for m in monthly:
|
|
|
|
|
|
d = monthly[m]
|
|
|
|
|
|
d["net"] = round(d["net"], 2)
|
|
|
|
|
|
d["win_rate"] = round(d["wins"] / d["count"] * 100, 1) if d["count"] else 0
|
|
|
|
|
|
|
|
|
|
|
|
# Volume pattern
|
|
|
|
|
|
lots = [t["volume"] for t in trades]
|
|
|
|
|
|
unique_lots = sorted(set(lots))
|
|
|
|
|
|
|
2026-06-29 12:20:16 +08:00
|
|
|
|
# Idle time: total backtest duration minus time in positions
|
|
|
|
|
|
idle_str = ""
|
|
|
|
|
|
if bt_start and bt_end:
|
|
|
|
|
|
total_duration = bt_end - bt_start
|
|
|
|
|
|
position_time = timedelta()
|
|
|
|
|
|
for t in trades:
|
|
|
|
|
|
close_dt = datetime.strptime(t["close_time"], "%Y.%m.%d %H:%M:%S")
|
|
|
|
|
|
open_dt = datetime.strptime(t["open_time"], "%Y.%m.%d %H:%M:%S")
|
|
|
|
|
|
position_time += close_dt - open_dt
|
|
|
|
|
|
idle_td = total_duration - position_time
|
|
|
|
|
|
idle_str = format_duration(idle_td)
|
2026-06-25 02:51:22 +08:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"sl_hits": len(sl_trades),
|
|
|
|
|
|
"tp_hits": len(tp_trades),
|
|
|
|
|
|
"other_exits": len(other),
|
|
|
|
|
|
"win_loss_ratio": round(win_loss_ratio, 2),
|
|
|
|
|
|
"breakeven_win_rate": round(breakeven_wr * 100, 1),
|
|
|
|
|
|
"win_rate_gap_pct": round((len(tp_trades) / len(trades) - breakeven_wr) * 100, 1),
|
|
|
|
|
|
"consec_loss_streaks": streaks,
|
|
|
|
|
|
"reentries": reentries,
|
|
|
|
|
|
"monthly": monthly,
|
|
|
|
|
|
"lot_pattern": {
|
|
|
|
|
|
"unique_lots": unique_lots,
|
|
|
|
|
|
"uniform": len(unique_lots) == 1,
|
|
|
|
|
|
},
|
2026-06-29 12:20:16 +08:00
|
|
|
|
"idle_time": idle_str,
|
2026-06-25 02:51:22 +08:00
|
|
|
|
"trades": trades,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-04 01:18:33 +08:00
|
|
|
|
# ── Window Analysis ──────────────────────────────────────────────────
|
|
|
|
|
|
#
|
|
|
|
|
|
# Split a backtest into N equal time windows and compute the same
|
|
|
|
|
|
# performance metrics the report shows, per window. This lets you
|
|
|
|
|
|
# spot windows whose metric values are statistical outliers vs the
|
|
|
|
|
|
# rest of the backtest (regime detection / over-fitting).
|
|
|
|
|
|
#
|
|
|
|
|
|
# Outlier detection uses per-metric z-score (sample std, N-1) across
|
|
|
|
|
|
# windows. |z| >= 2 = notable, |z| >= 5 = extreme.
|
|
|
|
|
|
#
|
|
|
|
|
|
# Conventions
|
|
|
|
|
|
# -----------
|
|
|
|
|
|
# • Time boundaries: equal-length [t_start, t_end) slices, left-closed
|
|
|
|
|
|
# right-open. Window 0 starts at the backtest start; window N-1 ends
|
|
|
|
|
|
# at the backtest end. Adjacent windows do not overlap.
|
|
|
|
|
|
# • A trade is assigned to the window where it OPENS (entry time,
|
|
|
|
|
|
# `pair_trades` field "open_time"). Its P&L lands at exit time, which
|
|
|
|
|
|
# may fall in a later window — we attribute the P&L to the opening
|
|
|
|
|
|
# window because that is the "decision moment" the user cares about.
|
|
|
|
|
|
# • For path-dependent metrics (Balance DD Rel%, Recovery Factor, Sharpe)
|
|
|
|
|
|
# we reconstruct a local balance curve starting from the balance at
|
|
|
|
|
|
# the window's left edge. The `balance` field in deal rows equals
|
|
|
|
|
|
# equity at the moment of the deal (no floating P&L at deal time),
|
|
|
|
|
|
# which is exact for closed-trade snapshots. This reconstruction
|
|
|
|
|
|
# therefore matches `STAT_BALANCE_DDREL_PERCENT` (maximum relative
|
|
|
|
|
|
# drawdown, i.e. the largest (peak-trough)/peak ratio) exactly
|
|
|
|
|
|
# (verified on 246753: 44.60% vs report 44.63%).
|
|
|
|
|
|
# • Recovery Factor per MT5's report value is `STAT_PROFIT /
|
|
|
|
|
|
# STAT_EQUITY_DD` (verified empirically — the official doc page
|
|
|
|
|
|
# says STAT_BALANCE_DD, but the reported value matches the equity
|
|
|
|
|
|
# version). We compute RF using `bal_dd_rel_abs` (the abs $ amount
|
|
|
|
|
|
# at the moment of maximum relative DD), which gives a value that
|
|
|
|
|
|
# does NOT match the report exactly — it uses a different DD
|
|
|
|
|
|
# reference (balance relative vs equity maximal). This is per the
|
|
|
|
|
|
# user's fallback rule: "如 Equity DD % 不可用,则以 Balance DD % 代
|
|
|
|
|
|
# 替".
|
|
|
|
|
|
# • Gross Profit / Gross Loss use MT5's split: each trade's
|
|
|
|
|
|
# "exit-leg" P&L (exit.profit + exit.commission + exit.swap) goes
|
|
|
|
|
|
# to GP if positive or GL if non-positive; entry costs always go
|
|
|
|
|
|
# to GL. This matches the report exactly (see
|
|
|
|
|
|
# `compute_gross_profit_loss`).
|
|
|
|
|
|
# • Sharpe Ratio uses the MQL5-community standard formula
|
|
|
|
|
|
# (AHPR - 1) / std_HPR × sqrt(N_per_year), where
|
|
|
|
|
|
# HPR_i = Balance_i / Balance_{i-1}, std is sample N-1, and the
|
|
|
|
|
|
# year is 365 days (community consensus; see references/book
|
|
|
|
|
|
# /05-automation/0475-... and MQL5 forum thread 337071).
|
|
|
|
|
|
# MT5's reported value uses a different (undocumented) computation
|
|
|
|
|
|
# that does not match this formula on every report (e.g. 246753:
|
|
|
|
|
|
# 2.49 vs 22.92, 9.2× gap). The value is internally consistent
|
|
|
|
|
|
# across all sub-windows, so the relative ranking is still
|
|
|
|
|
|
# meaningful.
|
|
|
|
|
|
|
|
|
|
|
|
def _balance_dd_relative(
|
|
|
|
|
|
balances: list[float],
|
|
|
|
|
|
) -> tuple[float, float]:
|
|
|
|
|
|
"""Return (bal_dd_rel_abs, bal_dd_rel_pct) for a balance curve.
|
|
|
|
|
|
|
|
|
|
|
|
Computes MT5's STAT_BALANCE_DDREL_PERCENT — the maximum **relative**
|
|
|
|
|
|
drawdown across the entire balance curve. For each point, compute
|
|
|
|
|
|
rel = (running_peak - current) / running_peak * 100 — the percentage
|
|
|
|
|
|
drawdown from the peak before it. Report the largest such % and the
|
|
|
|
|
|
absolute $ amount at that moment.
|
|
|
|
|
|
|
|
|
|
|
|
The `peak` resets every time the balance reaches a new high. This
|
|
|
|
|
|
differs from the "maximal" DD (STAT_BALANCEDD_PERCENT) which finds
|
|
|
|
|
|
the largest absolute $ DD first and uses that moment's % — the two
|
|
|
|
|
|
can differ when a small absolute DD happens at a very low peak
|
|
|
|
|
|
(producing a high % that doesn't register in the maximal scan).
|
|
|
|
|
|
|
|
|
|
|
|
Verified against 246753: 44.60% vs report 44.63% (rounding-close;
|
|
|
|
|
|
the 0.03% gap is from intra-trade floating P&L not visible in the
|
|
|
|
|
|
HTML).
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not balances:
|
|
|
|
|
|
return 0.0, 0.0
|
|
|
|
|
|
|
|
|
|
|
|
peak = balances[0]
|
|
|
|
|
|
max_rel_pct = 0.0
|
|
|
|
|
|
max_rel_abs = 0.0
|
|
|
|
|
|
for b in balances:
|
|
|
|
|
|
if b > peak:
|
|
|
|
|
|
peak = b
|
|
|
|
|
|
rel = (peak - b) / peak * 100 if peak > 0 else 0.0
|
|
|
|
|
|
if rel > max_rel_pct:
|
|
|
|
|
|
max_rel_pct = rel
|
|
|
|
|
|
max_rel_abs = peak - b
|
|
|
|
|
|
return max_rel_abs, max_rel_pct
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _sharpe_ratio(trade_hprs: list[float]) -> float:
|
|
|
|
|
|
"""Per-trade Sharpe using HPRs (balance ratio = balance_after / balance_before).
|
|
|
|
|
|
|
|
|
|
|
|
Per the MQL5 community reverse-engineering (forum thread 337071 +
|
|
|
|
|
|
the book example at references/book/05-automation/0475-...):
|
|
|
|
|
|
per_trade_sharpe = (AHPR - 1) / std_HPR
|
|
|
|
|
|
where
|
|
|
|
|
|
HPR_i = Balance_i / Balance_{i-1}
|
|
|
|
|
|
AHPR = mean(HPR_i) (arithmetic)
|
|
|
|
|
|
std = sample std (ddof=1) of HPR_i
|
|
|
|
|
|
|
|
|
|
|
|
Falls back to 0.0 if std == 0 or N < 2.
|
|
|
|
|
|
"""
|
|
|
|
|
|
n = len(trade_hprs)
|
|
|
|
|
|
if n < 2:
|
|
|
|
|
|
return 0.0
|
|
|
|
|
|
ahpr = sum(trade_hprs) / n
|
|
|
|
|
|
var = sum((x - ahpr) ** 2 for x in trade_hprs) / (n - 1)
|
|
|
|
|
|
if var <= 0:
|
|
|
|
|
|
return 0.0
|
|
|
|
|
|
std = var ** 0.5
|
|
|
|
|
|
return (ahpr - 1.0) / std
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _sharpe_ratio_annualized(
|
|
|
|
|
|
trade_hprs: list[float], backtest_days: float
|
|
|
|
|
|
) -> float:
|
|
|
|
|
|
"""Annualized Sharpe = per_trade_sharpe * sqrt(N_per_year).
|
|
|
|
|
|
|
|
|
|
|
|
`N_per_year = N / (backtest_days / 365)`. Uses 365 days/year
|
|
|
|
|
|
(MQL5 community consensus; 365.25 is within rounding for our
|
|
|
|
|
|
purposes — the difference is sub-0.5% for typical backtest
|
|
|
|
|
|
lengths).
|
|
|
|
|
|
|
|
|
|
|
|
Note: this is the **textbook** formula. MT5's reported
|
|
|
|
|
|
STAT_SHARPE_RATIO uses this formula and is consistent with it
|
|
|
|
|
|
on most backtests, but for high-trade-count EAs the report
|
|
|
|
|
|
value can diverge significantly (e.g. 22.92 vs 2.49 in 246753)
|
|
|
|
|
|
— MT5 does not publish the exact computation, and the gap is
|
|
|
|
|
|
not closeable without access to MT5's internal source. Use
|
|
|
|
|
|
`sharpe_ratio_raw` (this function's input) for the per-trade
|
|
|
|
|
|
Sharpe that is the most stable signal across windows.
|
|
|
|
|
|
"""
|
|
|
|
|
|
n = len(trade_hprs)
|
|
|
|
|
|
if n < 2 or backtest_days <= 0:
|
|
|
|
|
|
return 0.0
|
|
|
|
|
|
per_trade = _sharpe_ratio(trade_hprs)
|
|
|
|
|
|
n_per_year = n / (backtest_days / 365.0)
|
|
|
|
|
|
if n_per_year <= 0:
|
|
|
|
|
|
return 0.0
|
|
|
|
|
|
return per_trade * (n_per_year ** 0.5)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def compute_window_metrics(
|
|
|
|
|
|
trades: list[dict],
|
|
|
|
|
|
starting_balance: float,
|
|
|
|
|
|
deposit: float,
|
|
|
|
|
|
backtest_days: float,
|
|
|
|
|
|
) -> dict:
|
|
|
|
|
|
"""Compute the 7-window-metrics for a list of trades.
|
|
|
|
|
|
|
|
|
|
|
|
`starting_balance` is the equity at the left edge of the window
|
|
|
|
|
|
(the balance just before any trade in this window opens).
|
|
|
|
|
|
`deposit` parameter — kept for API symmetry (not used by the
|
|
|
|
|
|
balance-based relative DD calculation).
|
|
|
|
|
|
`backtest_days` is the window length in days (used for Sharpe
|
|
|
|
|
|
annualization).
|
|
|
|
|
|
|
|
|
|
|
|
Returns a dict with keys: profit, expected_payoff, profit_factor,
|
|
|
|
|
|
recovery_factor (based on bal_dd_rel_abs), bal_dd_rel_pct,
|
|
|
|
|
|
bal_dd_rel_abs, trades, sharpe_ratio (annualized),
|
|
|
|
|
|
sharpe_ratio_raw (per-trade).
|
|
|
|
|
|
"""
|
|
|
|
|
|
n = len(trades)
|
|
|
|
|
|
if n == 0:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"profit": 0.0,
|
|
|
|
|
|
"expected_payoff": 0.0,
|
|
|
|
|
|
"profit_factor": 0.0,
|
|
|
|
|
|
"recovery_factor": 0.0,
|
|
|
|
|
|
"bal_dd_rel_pct": 0.0,
|
|
|
|
|
|
"bal_dd_rel_abs": 0.0,
|
|
|
|
|
|
"trades": 0,
|
|
|
|
|
|
"sharpe_ratio": 0.0,
|
|
|
|
|
|
"sharpe_ratio_raw": 0.0,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# Per-trade P&L
|
|
|
|
|
|
# Use MT5's STAT_GROSS_PROFIT/STAT_GROSS_LOSS split (entry costs
|
|
|
|
|
|
# always go to GL; exit leg P&L split by sign). This matches the
|
|
|
|
|
|
# report's PF exactly — see compute_gross_profit_loss for details.
|
|
|
|
|
|
gp, gl = compute_gross_profit_loss(trades)
|
|
|
|
|
|
nets = [t["net"] for t in trades]
|
|
|
|
|
|
|
|
|
|
|
|
profit = sum(nets)
|
|
|
|
|
|
expected_payoff = profit / n
|
|
|
|
|
|
profit_factor = (gp / abs(gl)) if gl < 0 else 0.0
|
|
|
|
|
|
|
|
|
|
|
|
# Build local balance curve: starting_balance + each trade's net
|
|
|
|
|
|
balances = [starting_balance]
|
|
|
|
|
|
hprs = []
|
|
|
|
|
|
bal = starting_balance
|
|
|
|
|
|
for net in nets:
|
|
|
|
|
|
bal += net
|
|
|
|
|
|
# HPR = balance_after / balance_before (MT5 community formula)
|
|
|
|
|
|
hpr = (bal / balances[-1]) if balances[-1] != 0 else 0.0
|
|
|
|
|
|
hprs.append(hpr)
|
|
|
|
|
|
balances.append(bal)
|
|
|
|
|
|
|
|
|
|
|
|
bal_dd_rel_abs, bal_dd_rel_pct = _balance_dd_relative(balances)
|
|
|
|
|
|
recovery_factor = (profit / bal_dd_rel_abs) if bal_dd_rel_abs > 0 else 0.0
|
|
|
|
|
|
|
|
|
|
|
|
sharpe_raw = _sharpe_ratio(hprs)
|
|
|
|
|
|
sharpe_ann = _sharpe_ratio_annualized(hprs, backtest_days)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"profit": round(profit, 2),
|
|
|
|
|
|
"expected_payoff": round(expected_payoff, 2),
|
|
|
|
|
|
"profit_factor": round(profit_factor, 2),
|
|
|
|
|
|
"recovery_factor": round(recovery_factor, 2),
|
|
|
|
|
|
"bal_dd_rel_pct": round(bal_dd_rel_pct, 2),
|
|
|
|
|
|
"bal_dd_rel_abs": round(bal_dd_rel_abs, 2),
|
|
|
|
|
|
"trades": n,
|
|
|
|
|
|
"sharpe_ratio": round(sharpe_ann, 2),
|
|
|
|
|
|
"sharpe_ratio_raw": round(sharpe_raw, 4),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_period_dates(period: str) -> tuple[datetime | None, datetime | None]:
|
|
|
|
|
|
"""Extract (bt_start, bt_end) from a period string like 'H1 (2024.01.01 - 2025.06.22)'."""
|
|
|
|
|
|
m = re.search(
|
|
|
|
|
|
r"(\d{4}\.\d{2}\.\d{2})\s*-\s*(\d{4}\.\d{2}\.\d{2})\s*\)\s*$", period
|
|
|
|
|
|
)
|
|
|
|
|
|
if not m:
|
|
|
|
|
|
return None, None
|
|
|
|
|
|
try:
|
|
|
|
|
|
return (
|
|
|
|
|
|
datetime.strptime(m.group(1), "%Y.%m.%d"),
|
|
|
|
|
|
datetime.strptime(m.group(2), "%Y.%m.%d"),
|
|
|
|
|
|
)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
return None, None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def compute_windows(
|
|
|
|
|
|
report: Report, n: int
|
|
|
|
|
|
) -> list[dict]:
|
|
|
|
|
|
"""Split the backtest into N equal time windows and compute metrics for each.
|
|
|
|
|
|
|
|
|
|
|
|
Returns a list of dicts (one per window), each with:
|
|
|
|
|
|
window_idx, t_start (ISO date), t_end (ISO date, exclusive),
|
|
|
|
|
|
start_balance, end_balance, ...metrics
|
|
|
|
|
|
|
|
|
|
|
|
Trades are assigned to the window where their `open_time` falls.
|
|
|
|
|
|
`start_balance` is the running balance at the left edge of the
|
|
|
|
|
|
window (the balance carried over from the previous window's last
|
|
|
|
|
|
trade, or the initial deposit for window 0).
|
|
|
|
|
|
|
|
|
|
|
|
Time slicing
|
|
|
|
|
|
------------
|
|
|
|
|
|
Boundaries are at bt_start + k * (bt_end - bt_start) / N for k in
|
|
|
|
|
|
0..N. The very last window's t_end is bt_end (we use the user-given
|
|
|
|
|
|
end, not bt_start + N * step, to handle the case where bt_end is
|
|
|
|
|
|
not a whole number of step lengths from bt_start — common when
|
|
|
|
|
|
bt_end is "the last bar's date").
|
|
|
|
|
|
"""
|
|
|
|
|
|
if n < 1:
|
|
|
|
|
|
raise ValueError(f"window count must be >= 1, got {n}")
|
|
|
|
|
|
|
|
|
|
|
|
deposit = report.settings.initial_deposit
|
|
|
|
|
|
bt_start, bt_end = _parse_period_dates(report.settings.period)
|
|
|
|
|
|
if bt_start is None or bt_end is None:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"cannot parse backtest date range from period: {report.settings.period!r}"
|
|
|
|
|
|
)
|
|
|
|
|
|
if bt_end <= bt_start:
|
|
|
|
|
|
raise ValueError(f"bt_end {bt_end} <= bt_start {bt_start}")
|
|
|
|
|
|
|
|
|
|
|
|
trades = pair_trades(report.deals)
|
|
|
|
|
|
# Assign each trade to a window by its open_time
|
|
|
|
|
|
# First, build a global running balance series keyed by close_time,
|
|
|
|
|
|
# so we can look up the balance at the left edge of any window.
|
|
|
|
|
|
balance_curve: list[tuple[datetime, float]] = [(bt_start, deposit)]
|
|
|
|
|
|
bal = deposit
|
|
|
|
|
|
for t in trades:
|
|
|
|
|
|
try:
|
|
|
|
|
|
close_dt = datetime.strptime(t["close_time"], "%Y.%m.%d %H:%M:%S")
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
continue
|
|
|
|
|
|
bal += t["net"]
|
|
|
|
|
|
balance_curve.append((close_dt, bal))
|
|
|
|
|
|
|
|
|
|
|
|
def balance_at(left_edge: datetime) -> float:
|
|
|
|
|
|
"""Return the last known balance at or before `left_edge`."""
|
|
|
|
|
|
b = deposit
|
|
|
|
|
|
for ts, v in balance_curve:
|
|
|
|
|
|
if ts <= left_edge:
|
|
|
|
|
|
b = v
|
|
|
|
|
|
else:
|
|
|
|
|
|
break
|
|
|
|
|
|
return b
|
|
|
|
|
|
|
|
|
|
|
|
total_seconds = (bt_end - bt_start).total_seconds()
|
|
|
|
|
|
out = []
|
|
|
|
|
|
for k in range(n):
|
|
|
|
|
|
t_start = bt_start + timedelta(seconds=total_seconds * k / n)
|
|
|
|
|
|
t_end = bt_start + timedelta(seconds=total_seconds * (k + 1) / n) \
|
|
|
|
|
|
if k < n - 1 else bt_end
|
|
|
|
|
|
# Select trades whose open_time is in [t_start, t_end)
|
|
|
|
|
|
in_window = []
|
|
|
|
|
|
for t in trades:
|
|
|
|
|
|
try:
|
|
|
|
|
|
ot = datetime.strptime(t["open_time"], "%Y.%m.%d %H:%M:%S")
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if t_start <= ot < t_end:
|
|
|
|
|
|
in_window.append(t)
|
|
|
|
|
|
start_bal = balance_at(t_start)
|
|
|
|
|
|
end_bal = balance_at(t_end)
|
|
|
|
|
|
window_days = (t_end - t_start).total_seconds() / 86400.0
|
|
|
|
|
|
m = compute_window_metrics(in_window, start_bal, deposit, window_days)
|
|
|
|
|
|
out.append({
|
|
|
|
|
|
"window_idx": k,
|
|
|
|
|
|
"t_start": t_start.strftime("%Y.%m.%d"),
|
|
|
|
|
|
"t_end": t_end.strftime("%Y.%m.%d"),
|
|
|
|
|
|
"start_balance": round(start_bal, 2),
|
|
|
|
|
|
"end_balance": round(end_bal, 2),
|
|
|
|
|
|
**m,
|
|
|
|
|
|
})
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _mean_std(vals: list[float]) -> tuple[float, float]:
|
|
|
|
|
|
"""Return (mean, sample_std_n_minus_1) of vals. std=0 if N<2."""
|
|
|
|
|
|
n = len(vals)
|
|
|
|
|
|
if n == 0:
|
|
|
|
|
|
return 0.0, 0.0
|
|
|
|
|
|
if n == 1:
|
|
|
|
|
|
return vals[0], 0.0
|
|
|
|
|
|
mean = sum(vals) / n
|
|
|
|
|
|
var = sum((x - mean) ** 2 for x in vals) / (n - 1)
|
|
|
|
|
|
return mean, var ** 0.5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Thresholds for notable / extreme outliers in windows analysis.
|
|
|
|
|
|
# Per the user: |z| >= 2 = notable; |z| >= 5 = extreme.
|
|
|
|
|
|
SIGMA_NOTABLE = 2.0
|
|
|
|
|
|
SIGMA_EXTREME = 5.0
|
|
|
|
|
|
|
|
|
|
|
|
# All metrics included in the z-score outlier scan. Direction-agnostic
|
|
|
|
|
|
# (we use |z|); lower-better metrics (e.g. bal_dd_rel_pct) are not
|
|
|
|
|
|
# inverted — a window with very LOW DD% will get |z| > 2 and the user
|
|
|
|
|
|
# decides whether that's good or bad from context.
|
|
|
|
|
|
ALL_METRICS = [
|
|
|
|
|
|
"profit",
|
|
|
|
|
|
"expected_payoff",
|
|
|
|
|
|
"profit_factor",
|
|
|
|
|
|
"recovery_factor",
|
|
|
|
|
|
"bal_dd_rel_pct",
|
|
|
|
|
|
"trades",
|
|
|
|
|
|
"sharpe_ratio",
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def windows_comparison(windows: list[dict]) -> dict:
|
|
|
|
|
|
"""Per-window z-score outlier scan across the 7 core metrics.
|
|
|
|
|
|
|
|
|
|
|
|
For each metric, compute mean and sample std across all windows,
|
|
|
|
|
|
then for each window compute z = (val - mean) / std. Flag any
|
|
|
|
|
|
window whose |z| for any metric crosses the thresholds:
|
|
|
|
|
|
|
|
|
|
|
|
• |z| >= 2.0 -> notable outlier -- marker ▲
|
|
|
|
|
|
• |z| >= 5.0 -> extreme outlier -- marker ■
|
|
|
|
|
|
|
|
|
|
|
|
Note: this is direction-agnostic. |z| > 2 means "this window's
|
|
|
|
|
|
value is far from the rest"; whether that is good (e.g. very
|
|
|
|
|
|
high profit) or bad (e.g. very high DD%) is left to the user.
|
|
|
|
|
|
Sample std uses N-1. z=0 when std=0 (all windows identical).
|
|
|
|
|
|
|
|
|
|
|
|
Output shape:
|
|
|
|
|
|
{
|
|
|
|
|
|
"per_window": [{ "outliers": [{"metric": "profit", "z": +2.27,
|
|
|
|
|
|
"level": "notable"|"extreme"},
|
|
|
|
|
|
...],
|
|
|
|
|
|
"notable_count": int,
|
|
|
|
|
|
"extreme_count": int,
|
|
|
|
|
|
"max_abs_z": float,
|
|
|
|
|
|
"max_abs_z_metric": str}, ...],
|
|
|
|
|
|
"mean": {"profit": ..., ...},
|
|
|
|
|
|
"std": {"profit": ..., ...},
|
|
|
|
|
|
"thresholds": {"notable": 2.0, "extreme": 5.0},
|
|
|
|
|
|
"summary": {"notable_windows": int, "extreme_windows": int,
|
|
|
|
|
|
"n_windows": int},
|
|
|
|
|
|
}
|
|
|
|
|
|
"""
|
|
|
|
|
|
# Per-metric mean and std across all windows
|
|
|
|
|
|
mean_map: dict = {}
|
|
|
|
|
|
std_map: dict = {}
|
|
|
|
|
|
for m in ALL_METRICS:
|
|
|
|
|
|
vals = [w.get(m, 0.0) for w in windows]
|
|
|
|
|
|
mn, sd = _mean_std(vals)
|
|
|
|
|
|
mean_map[m] = round(mn, 4)
|
|
|
|
|
|
std_map[m] = round(sd, 4)
|
|
|
|
|
|
|
|
|
|
|
|
per_window = []
|
|
|
|
|
|
for w in windows:
|
|
|
|
|
|
outliers = []
|
|
|
|
|
|
for m in ALL_METRICS:
|
|
|
|
|
|
sd = std_map[m]
|
|
|
|
|
|
if sd == 0:
|
|
|
|
|
|
continue
|
|
|
|
|
|
z = (w.get(m, 0.0) - mean_map[m]) / sd
|
|
|
|
|
|
az = abs(z)
|
|
|
|
|
|
if az >= SIGMA_EXTREME:
|
|
|
|
|
|
outliers.append({"metric": m, "z": round(z, 2),
|
|
|
|
|
|
"level": "extreme"})
|
|
|
|
|
|
elif az >= SIGMA_NOTABLE:
|
|
|
|
|
|
outliers.append({"metric": m, "z": round(z, 2),
|
|
|
|
|
|
"level": "notable"})
|
|
|
|
|
|
# Find the single most extreme outlier for the row marker
|
|
|
|
|
|
max_abs_z = 0.0
|
|
|
|
|
|
max_metric = ""
|
|
|
|
|
|
for o in outliers:
|
|
|
|
|
|
if abs(o["z"]) > max_abs_z:
|
|
|
|
|
|
max_abs_z = abs(o["z"])
|
|
|
|
|
|
max_metric = o["metric"]
|
|
|
|
|
|
per_window.append({
|
|
|
|
|
|
"outliers": outliers,
|
|
|
|
|
|
"notable_count": sum(1 for o in outliers if o["level"] == "notable"),
|
|
|
|
|
|
"extreme_count": sum(1 for o in outliers if o["level"] == "extreme"),
|
|
|
|
|
|
"max_abs_z": round(max_abs_z, 2),
|
|
|
|
|
|
"max_abs_z_metric": max_metric,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
n_notable = sum(1 for f in per_window if f["notable_count"] > 0)
|
|
|
|
|
|
n_extreme = sum(1 for f in per_window if f["extreme_count"] > 0)
|
|
|
|
|
|
return {
|
|
|
|
|
|
"per_window": per_window,
|
|
|
|
|
|
"mean": mean_map,
|
|
|
|
|
|
"std": std_map,
|
|
|
|
|
|
"thresholds": {"notable": SIGMA_NOTABLE, "extreme": SIGMA_EXTREME},
|
|
|
|
|
|
"summary": {
|
|
|
|
|
|
"notable_windows": n_notable,
|
|
|
|
|
|
"extreme_windows": n_extreme,
|
|
|
|
|
|
"n_windows": len(windows),
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def print_windows(report: Report, windows: list[dict], comparison: dict) -> None:
|
|
|
|
|
|
"""Pretty-print the windows analysis as a text table."""
|
|
|
|
|
|
print("=" * 96)
|
|
|
|
|
|
print(f" Windows Analysis (backtest split into {len(windows)} equal time slices)")
|
|
|
|
|
|
print("=" * 96)
|
|
|
|
|
|
s = report.settings
|
|
|
|
|
|
res = report.results
|
|
|
|
|
|
print(f" Expert: {s.expert} Symbol: {s.symbol} Period: {s.period}")
|
|
|
|
|
|
print(f" Initial Deposit: {s.initial_deposit:,.2f}")
|
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
|
|
# Window boundaries
|
|
|
|
|
|
print(f" {'Win':<4} {'Start':<12} {'End':<12} {'Days':>6} "
|
|
|
|
|
|
f"{'StartBal':>10} {'EndBal':>10} {'Trades':>6}")
|
|
|
|
|
|
print(" " + "─" * 76)
|
|
|
|
|
|
total_days = 0.0
|
|
|
|
|
|
for w in windows:
|
|
|
|
|
|
t_s = datetime.strptime(w["t_start"], "%Y.%m.%d")
|
|
|
|
|
|
t_e = datetime.strptime(w["t_end"], "%Y.%m.%d")
|
|
|
|
|
|
days = (t_e - t_s).total_seconds() / 86400.0
|
|
|
|
|
|
total_days += days
|
|
|
|
|
|
print(f" {w['window_idx']:<4} {w['t_start']:<12} {w['t_end']:<12} "
|
|
|
|
|
|
f"{days:>6.1f} {w['start_balance']:>10,.2f} {w['end_balance']:>10,.2f} "
|
|
|
|
|
|
f"{w['trades']:>6}")
|
|
|
|
|
|
print(f" {'─'*76}\n")
|
|
|
|
|
|
|
|
|
|
|
|
# Metrics table
|
|
|
|
|
|
print(f" {'Win':<4} {'Profit':>10} {'EP':>8} {'PF':>6} {'RF':>6} "
|
|
|
|
|
|
f"{'BalDD%':>7} {'Trades':>6} {'Sharpe':>8} Outliers")
|
|
|
|
|
|
print(" " + "─" * 102)
|
|
|
|
|
|
for w, flags in zip(windows, comparison["per_window"]):
|
|
|
|
|
|
# Build a compact outlier marker: max level, count, and metric
|
|
|
|
|
|
if flags["extreme_count"] > 0:
|
|
|
|
|
|
level = "■EXT"
|
|
|
|
|
|
elif flags["notable_count"] > 0:
|
|
|
|
|
|
level = "▲2σ"
|
|
|
|
|
|
else:
|
|
|
|
|
|
level = " -"
|
|
|
|
|
|
if flags["outliers"]:
|
|
|
|
|
|
metrics_short = ",".join(
|
|
|
|
|
|
f"{o['metric'][:4]}({o['z']:+.1f}σ)"
|
|
|
|
|
|
for o in flags["outliers"]
|
|
|
|
|
|
)
|
|
|
|
|
|
marker = f"{level} k={flags['notable_count'] + flags['extreme_count']} {metrics_short}"
|
|
|
|
|
|
else:
|
|
|
|
|
|
marker = level
|
|
|
|
|
|
print(f" {w['window_idx']:<4} {w['profit']:>10,.2f} {w['expected_payoff']:>8.2f} "
|
|
|
|
|
|
f"{w['profit_factor']:>6.2f} {w['recovery_factor']:>6.2f} "
|
|
|
|
|
|
f"{w['bal_dd_rel_pct']:>7.2f} {w['trades']:>6} "
|
|
|
|
|
|
f"{w['sharpe_ratio']:>8.2f} {marker}")
|
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
|
|
# Mean row (the reference for z-scores). Only meaningful with N>=2.
|
|
|
|
|
|
if len(windows) >= 2:
|
|
|
|
|
|
mn = comparison["mean"]
|
|
|
|
|
|
print(f" {'MEAN':<4} {mn.get('profit', 0):>10,.2f} "
|
|
|
|
|
|
f"{mn.get('expected_payoff', 0):>8.2f} "
|
|
|
|
|
|
f"{mn.get('profit_factor', 0):>6.2f} {mn.get('recovery_factor', 0):>6.2f} "
|
|
|
|
|
|
f"{mn.get('bal_dd_rel_pct', 0):>7.2f} {mn.get('trades', 0):>6.0f} "
|
|
|
|
|
|
f"{mn.get('sharpe_ratio', 0):>8.2f}")
|
|
|
|
|
|
print(f" {'STD':<4} "
|
|
|
|
|
|
f"{comparison['std'].get('profit', 0):>10,.2f} "
|
|
|
|
|
|
f"{comparison['std'].get('expected_payoff', 0):>8.2f} "
|
|
|
|
|
|
f"{comparison['std'].get('profit_factor', 0):>6.2f} "
|
|
|
|
|
|
f"{comparison['std'].get('recovery_factor', 0):>6.2f} "
|
|
|
|
|
|
f"{comparison['std'].get('bal_dd_rel_pct', 0):>7.2f} "
|
|
|
|
|
|
f"{comparison['std'].get('trades', 0):>6.2f} "
|
|
|
|
|
|
f"{comparison['std'].get('sharpe_ratio', 0):>8.2f}")
|
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
|
|
# For N=1: cross-check computed values vs HTML report
|
|
|
|
|
|
if len(windows) == 1:
|
|
|
|
|
|
w0 = windows[0]
|
|
|
|
|
|
print(" " + "─" * 45)
|
|
|
|
|
|
print(" N=1 cross-check vs report's reported values "
|
|
|
|
|
|
"(4 of 7 exact: Profit, EP, PF, Trades; 3 documented approx):")
|
|
|
|
|
|
ref_pairs = [
|
|
|
|
|
|
("Profit", w0["profit"], res.total_net_profit),
|
|
|
|
|
|
("Expected Payoff", w0["expected_payoff"], res.expected_payoff),
|
|
|
|
|
|
("Profit Factor", w0["profit_factor"], res.profit_factor),
|
|
|
|
|
|
("Recovery Factor", w0["recovery_factor"], res.recovery_factor),
|
|
|
|
|
|
("Balance DD Rel%", w0["bal_dd_rel_pct"], res.balance_drawdown_rel_pct),
|
|
|
|
|
|
("Trades", w0["trades"], res.total_trades),
|
|
|
|
|
|
("Sharpe Ratio", w0["sharpe_ratio"], res.sharpe_ratio),
|
|
|
|
|
|
]
|
|
|
|
|
|
for name, calc, ref in ref_pairs:
|
|
|
|
|
|
diff = calc - ref
|
|
|
|
|
|
if ref != 0:
|
|
|
|
|
|
pct = abs(diff) / abs(ref) * 100
|
|
|
|
|
|
else:
|
|
|
|
|
|
pct = 0.0
|
|
|
|
|
|
mark = "✓" if pct < 0.5 else ("⚠" if pct < 5 else "✗")
|
|
|
|
|
|
print(f" {mark} {name:<18} calc={calc:>10.4f} "
|
|
|
|
|
|
f"ref={ref:>10.4f} diff={diff:>+10.4f} ({pct:5.1f}%)")
|
|
|
|
|
|
print(" Legend: ✓ = exact (rounding only), ⚠ = small drift, "
|
|
|
|
|
|
"✗ = documented approximation")
|
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
|
|
# When N > 1, also compute full-period metrics as a cross-check
|
|
|
|
|
|
# reference so the user can see how sub-window values relate to
|
|
|
|
|
|
# the full backtest (both our computation and the HTML report).
|
|
|
|
|
|
if len(windows) > 1:
|
|
|
|
|
|
# Compute full-period window (N=1) for comparison
|
|
|
|
|
|
full_wins = compute_windows(report, 1)
|
|
|
|
|
|
if full_wins:
|
|
|
|
|
|
w_full = full_wins[0]
|
|
|
|
|
|
else:
|
|
|
|
|
|
w_full = None
|
|
|
|
|
|
if w_full is not None:
|
|
|
|
|
|
print(" " + "─" * 45)
|
|
|
|
|
|
print(" Full-period reference:")
|
|
|
|
|
|
print(" (computed N=1 vs HTML report stated values)")
|
|
|
|
|
|
ref_pairs = [
|
|
|
|
|
|
("Profit", w_full["profit"], res.total_net_profit),
|
|
|
|
|
|
("Expected Payoff", w_full["expected_payoff"], res.expected_payoff),
|
|
|
|
|
|
("Profit Factor", w_full["profit_factor"], res.profit_factor),
|
|
|
|
|
|
("Recovery Factor", w_full["recovery_factor"], res.recovery_factor),
|
|
|
|
|
|
("Balance DD Rel%", w_full["bal_dd_rel_pct"], res.balance_drawdown_rel_pct),
|
|
|
|
|
|
("Trades", w_full["trades"], res.total_trades),
|
|
|
|
|
|
("Sharpe Ratio", w_full["sharpe_ratio"], res.sharpe_ratio),
|
|
|
|
|
|
]
|
|
|
|
|
|
for name, calc, ref in ref_pairs:
|
|
|
|
|
|
diff = calc - ref
|
|
|
|
|
|
if ref != 0:
|
|
|
|
|
|
pct = abs(diff) / abs(ref) * 100
|
|
|
|
|
|
else:
|
|
|
|
|
|
pct = 0.0
|
|
|
|
|
|
mark = "✓" if pct < 0.5 else ("⚠" if pct < 5 else "✗")
|
|
|
|
|
|
print(f" {mark} {name:<18} calc={calc:>10.4f} "
|
|
|
|
|
|
f"ref={ref:>10.4f} diff={diff:>+10.4f} ({pct:5.1f}%)")
|
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
|
|
# Summary
|
|
|
|
|
|
summ = comparison["summary"]
|
|
|
|
|
|
thr = comparison["thresholds"]
|
|
|
|
|
|
if len(windows) < 2:
|
|
|
|
|
|
print(" Outlier scan: skipped (need at least 2 windows to compute std).")
|
|
|
|
|
|
print(" Use --count 2 or more for the z-score outlier scan.")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print(" Outlier scan (per-metric z-score vs window mean):")
|
|
|
|
|
|
print(f" ▲2σ = |z| >= {thr['notable']:.0f} on any metric (notable)")
|
|
|
|
|
|
print(f" ■EXT = |z| >= {thr['extreme']:.0f} on any metric (extreme)")
|
|
|
|
|
|
print(f" {summ['notable_windows']}/{summ['n_windows']} windows have at least one "
|
|
|
|
|
|
f"|z|>={thr['notable']:.0f} outlier, "
|
|
|
|
|
|
f"{summ['extreme_windows']}/{summ['n_windows']} have at least one "
|
|
|
|
|
|
f"|z|>={thr['extreme']:.0f}.")
|
|
|
|
|
|
if summ["notable_windows"] > 0 or summ["extreme_windows"] > 0:
|
|
|
|
|
|
print(" Use --json to see per-metric z-scores.")
|
|
|
|
|
|
print()
|
|
|
|
|
|
if len(windows) >= 2:
|
|
|
|
|
|
print(" Interpretation:")
|
|
|
|
|
|
print(" z = (this window's value − mean across all windows) / std.")
|
|
|
|
|
|
print(" A z-score measures how far this window is from the rest.")
|
|
|
|
|
|
print(" Direction is sign-bearing (+ vs −); the marker is |z|.")
|
|
|
|
|
|
print(" For lower-is-better metrics (bal_dd_rel_pct), a negative z")
|
|
|
|
|
|
print(" means 'this window's DD is unusually low' — good if you")
|
|
|
|
|
|
print(" want safety, neutral if you just want consistency.")
|
|
|
|
|
|
print(" A single window with a strong outlier is a regime signal.")
|
|
|
|
|
|
print(" Multiple windows each with their own outliers point to a")
|
|
|
|
|
|
print(" high-variance strategy — harder to predict live performance.")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-24 13:57:49 +08:00
|
|
|
|
# ── CLI ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
2026-07-04 01:18:33 +08:00
|
|
|
|
parser = argparse.ArgumentParser(
|
|
|
|
|
|
description="Parse MT5 Strategy Tester HTML report",
|
|
|
|
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
|
|
|
|
epilog="""
|
|
|
|
|
|
Examples:
|
|
|
|
|
|
# Text report (default)
|
|
|
|
|
|
python %(prog)s report.html
|
|
|
|
|
|
|
|
|
|
|
|
# JSON dump (raw parsed data)
|
|
|
|
|
|
python %(prog)s report.html --json
|
|
|
|
|
|
|
|
|
|
|
|
# Full trade analysis (idle time, monthly breakdown, re-entry detection)
|
|
|
|
|
|
python %(prog)s report.html --analyze
|
|
|
|
|
|
|
|
|
|
|
|
# Window analysis (see "windows --help" for details)
|
|
|
|
|
|
python %(prog)s report.html windows --count 4
|
|
|
|
|
|
python %(prog)s report.html windows --count 6 --json
|
|
|
|
|
|
""",
|
|
|
|
|
|
)
|
2026-06-24 13:57:49 +08:00
|
|
|
|
parser.add_argument("report", help="Path to HTML report file")
|
|
|
|
|
|
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
2026-06-25 02:51:22 +08:00
|
|
|
|
parser.add_argument("--analyze", action="store_true",
|
|
|
|
|
|
help="Run trade analysis (pair deals, risk check, monthly breakdown)")
|
2026-07-04 01:18:33 +08:00
|
|
|
|
sub = parser.add_subparsers(dest="cmd")
|
|
|
|
|
|
|
|
|
|
|
|
p_win = sub.add_parser(
|
|
|
|
|
|
"windows",
|
|
|
|
|
|
help="Split the backtest into N equal time windows and compute "
|
|
|
|
|
|
"the 7 core metrics for each (Profit, EP, PF, RF, "
|
|
|
|
|
|
"Balance DD Rel%% (relative), Trades, Sharpe). Use to find time "
|
|
|
|
|
|
"windows that are statistical outliers vs the rest "
|
|
|
|
|
|
"(over-fitting / regime detection).",
|
|
|
|
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
|
|
|
|
epilog="""
|
|
|
|
|
|
Examples:
|
|
|
|
|
|
# N=1: validate that calculation matches the full report
|
|
|
|
|
|
python parse_tester_report.py <report.html> windows --count 1
|
|
|
|
|
|
|
|
|
|
|
|
# N=4: quarterly analysis for a 1.5y backtest
|
|
|
|
|
|
python parse_tester_report.py <report.html> windows --count 4
|
|
|
|
|
|
|
|
|
|
|
|
# N=8: finer granularity
|
|
|
|
|
|
python parse_tester_report.py <report.html> windows --count 8
|
|
|
|
|
|
|
|
|
|
|
|
# JSON output (per-window metrics + z-score outliers)
|
|
|
|
|
|
python parse_tester_report.py <report.html> windows --count 6 --json
|
|
|
|
|
|
|
|
|
|
|
|
Each window shows all 7 metrics plus an outlier flag (|z|>=2: notable,
|
|
|
|
|
|
|z|>=5: extreme). The MEAN/STD row gives the reference distribution.
|
|
|
|
|
|
""",
|
|
|
|
|
|
)
|
|
|
|
|
|
p_win.add_argument(
|
|
|
|
|
|
"--count", "-n", type=int, required=True,
|
|
|
|
|
|
help="Number of equal time windows to split the backtest into. "
|
|
|
|
|
|
"Use 1 to validate: should match the full report within tolerance.",
|
|
|
|
|
|
)
|
|
|
|
|
|
p_win.add_argument(
|
|
|
|
|
|
"--json", action="store_true", help="Output windows data as JSON",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-24 13:57:49 +08:00
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
|
|
|
|
|
|
path = Path(args.report)
|
|
|
|
|
|
if not path.exists():
|
|
|
|
|
|
print(f"Error: {path} not found", file=sys.stderr)
|
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
report = parse_report(path)
|
|
|
|
|
|
|
2026-07-04 01:18:33 +08:00
|
|
|
|
if args.cmd == "windows":
|
|
|
|
|
|
wins = compute_windows(report, args.count)
|
|
|
|
|
|
comp = windows_comparison(wins)
|
|
|
|
|
|
if getattr(args, "json", False):
|
|
|
|
|
|
out = {
|
|
|
|
|
|
"report": {
|
|
|
|
|
|
"expert": report.settings.expert,
|
|
|
|
|
|
"symbol": report.settings.symbol,
|
|
|
|
|
|
"period": report.settings.period,
|
|
|
|
|
|
"initial_deposit": report.settings.initial_deposit,
|
|
|
|
|
|
"total_net_profit": report.results.total_net_profit,
|
|
|
|
|
|
"profit_factor": report.results.profit_factor,
|
|
|
|
|
|
"expected_payoff": report.results.expected_payoff,
|
|
|
|
|
|
"recovery_factor": report.results.recovery_factor,
|
|
|
|
|
|
"sharpe_ratio": report.results.sharpe_ratio,
|
|
|
|
|
|
"bal_dd_rel_pct": report.results.balance_drawdown_rel_pct,
|
|
|
|
|
|
"total_trades": report.results.total_trades,
|
|
|
|
|
|
},
|
|
|
|
|
|
"windows": wins,
|
|
|
|
|
|
"comparison": comp,
|
|
|
|
|
|
}
|
|
|
|
|
|
print(json.dumps(out, indent=2, ensure_ascii=False))
|
|
|
|
|
|
else:
|
|
|
|
|
|
print_windows(report, wins, comp)
|
|
|
|
|
|
return
|
2026-06-29 12:20:16 +08:00
|
|
|
|
|
2026-07-04 01:18:33 +08:00
|
|
|
|
# Default behaviour: text report (with analyze) or JSON
|
|
|
|
|
|
analyze_data = analyze_report(report)
|
2026-06-25 02:51:22 +08:00
|
|
|
|
if args.analyze:
|
|
|
|
|
|
report_dict = asdict(report)
|
2026-06-29 12:20:16 +08:00
|
|
|
|
report_dict["analyze"] = analyze_data
|
2026-06-25 02:51:22 +08:00
|
|
|
|
print(json.dumps(report_dict, indent=2, ensure_ascii=False))
|
|
|
|
|
|
elif args.json:
|
2026-06-24 13:57:49 +08:00
|
|
|
|
print(json.dumps(asdict(report), indent=2, ensure_ascii=False))
|
|
|
|
|
|
else:
|
2026-06-29 12:20:16 +08:00
|
|
|
|
print_report(report, analyze_data)
|
2026-06-24 13:57:49 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
main()
|