Files
ZhijuCen 061e879638 feat(parse_tester_report): add 'windows' subcommand for time-window outlier analysis
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)
2026-07-04 01:18:33 +08:00

1448 lines
57 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()