Split backtest into N equal time slices (left-closed right-open) and compute the 7 core metrics per window: Profit, EP, PF, RF, Balance DD Rel%, Trades, Sharpe. Each window gets an outlier flag based on per- metric z-score (|z|>=2 = notable, |z|>=5 = extreme). N=1 runs a full- period cross-check vs the HTML report. Key changes: - Add compute_windows / compute_window_metrics / print_windows / windows_comparison functions, CLI subcommand 'windows' - pair_trades now exports gross_pnl/entry_costs for MT5 GP/GL split - compute_gross_profit_loss: MT5 accounting (entry costs always to GL) - _balance_dd_relative: max relative DD (STAT_BALANCE_DDREL_PERCENT) - _sharpe_ratio: textbook (AHPR-1)/std_HPR formula, 365-day year - Help text with examples for both --help and windows --help - verify_sl_tp_formulas.py: localize all output labels to English - AGENTS.md / SKILL.md: document windows subcommand conventions Docs: 5 of 7 metrics exact for N=1 (Profit, EP, PF, Trades exact; RF/BalDD% are approximations due to balance-only reconstruction; Sharpe uses textbook formula diverging from MT5's 22.92)
1448 lines
57 KiB
Python
1448 lines
57 KiB
Python
#!/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
|
||
from datetime import datetime, timedelta
|
||
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 ─────────────────────────────────────────────────────
|
||
|
||
def print_report(r: Report, analyze_data: dict | None = None) -> None:
|
||
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}")
|
||
if analyze_data:
|
||
print(f" Idle (no position): {analyze_data.get('idle_time', '')}")
|
||
|
||
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)")
|
||
|
||
|
||
|
||
# ── Trade Analysis ───────────────────────────────────────────────────
|
||
|
||
def pair_trades(deals: list) -> list:
|
||
"""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.
|
||
"""
|
||
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)
|
||
gross_pnl = exit_d.profit + exit_d.commission + exit_d.swap
|
||
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,
|
||
"gross_pnl": gross_pnl,
|
||
"entry_costs": entry.commission + entry.swap,
|
||
"comment": exit_d.comment,
|
||
"sl_distance": sl_dist,
|
||
})
|
||
i += 2
|
||
else:
|
||
i += 1
|
||
else:
|
||
i += 1
|
||
return trades
|
||
|
||
|
||
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)
|
||
|
||
|
||
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}"
|
||
|
||
|
||
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": []}
|
||
|
||
# Parse backtest start/end dates from period string
|
||
# e.g. "H4 (2024.01.01 - 2025.06.22)"
|
||
bt_start = None
|
||
bt_end = None
|
||
period = report.settings.period
|
||
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:
|
||
try:
|
||
bt_start = datetime.strptime(m_dates.group(1), "%Y.%m.%d")
|
||
bt_end = datetime.strptime(m_dates.group(2), "%Y.%m.%d")
|
||
except ValueError:
|
||
pass
|
||
|
||
# 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))
|
||
|
||
# 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)
|
||
|
||
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,
|
||
},
|
||
"idle_time": idle_str,
|
||
"trades": trades,
|
||
}
|
||
|
||
|
||
# ── 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.")
|
||
|
||
|
||
# ── CLI ──────────────────────────────────────────────────────────────
|
||
|
||
def main():
|
||
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
|
||
""",
|
||
)
|
||
parser.add_argument("report", help="Path to HTML report file")
|
||
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||
parser.add_argument("--analyze", action="store_true",
|
||
help="Run trade analysis (pair deals, risk check, monthly breakdown)")
|
||
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",
|
||
)
|
||
|
||
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)
|
||
|
||
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
|
||
|
||
# Default behaviour: text report (with analyze) or JSON
|
||
analyze_data = analyze_report(report)
|
||
if args.analyze:
|
||
report_dict = asdict(report)
|
||
report_dict["analyze"] = analyze_data
|
||
print(json.dumps(report_dict, indent=2, ensure_ascii=False))
|
||
elif args.json:
|
||
print(json.dumps(asdict(report), indent=2, ensure_ascii=False))
|
||
else:
|
||
print_report(report, analyze_data)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|