Files
mql5-skills/skills/mql5/scripts/parse_tester_report.py
T
ZhijuCen aa0909764f feat(parse_tester_report): replace gap_days_to_end with idle_time (HH:MM:SS)
- Remove gap_days_to_end and last_trade_close from analyze_report output
- Add idle_time: total backtest duration minus all position holding times
  (includes flat time before first trade and after last trade)
- Add format_duration() helper for timedelta -> HH:MM:SS formatting
- Parse both start/end dates from period string for accurate calculation
- Update print_report() to display idle_time in Holding Times section
- main() now always computes analyze data for text report
- Update AGENTS.md: add scripts docs, jobs/resources dirs, verification method
2026-06-29 12:20:16 +08:00

727 lines
28 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 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."""
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)
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,
"comment": exit_d.comment,
"sl_distance": sl_dist,
})
i += 2
else:
i += 1
else:
i += 1
return trades
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."""
from datetime import datetime, timedelta
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,
}
# ── CLI ──────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="Parse MT5 Strategy Tester HTML report")
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)")
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)
# Always compute analyze data (needed for idle_time in text report)
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()