feat: EA report parser and performance evaluation guide
- Add scripts/parse_tester_report.py: parses MT5 Strategy Tester HTML reports (UTF-16LE). Extracts settings, EA parameters, 44 P&L metrics, orders (192), deals (193), stop-out detection. Supports --json output. - Add Report Analysis subsection to SKILL.md Section 6: 10 evaluation dimensions (data quality, profitability, drawdown, trade distribution, consecutive losses, holding time, MFE/MAE, stop-out, bias, commission).
This commit is contained in:
@@ -540,6 +540,124 @@ EA Development Cycle:
|
||||
Monitor → Collect Data → Refine → Repeat
|
||||
```
|
||||
|
||||
### Report Analysis — Interpreting Tester Results
|
||||
|
||||
After each backtest, MT5 exports an HTML report. Use
|
||||
`scripts/parse_tester_report.py` to extract structured data, or read the
|
||||
HTML directly. Key areas to evaluate:
|
||||
|
||||
#### 1. Data Quality Gate
|
||||
|
||||
**Always check first.** If history quality is poor, all metrics are suspect.
|
||||
|
||||
| Metric | Acceptable | Action if Failed |
|
||||
|--------|-----------|-----------------|
|
||||
| History Quality | ≥ 95% real ticks | Re-download tick data or use different broker |
|
||||
| Bars | Enough for strategy (e.g. 1000+ for H4) | Extend test period |
|
||||
| Modelling quality | Every tick or Every tick based on real ticks | Never trust "Open prices only" for final eval |
|
||||
|
||||
#### 2. Profitability Metrics
|
||||
|
||||
| Metric | Good | Warning | Bad |
|
||||
|--------|------|---------|-----|
|
||||
| Net Profit | > 0 | ≈ 0 | < 0 |
|
||||
| Profit Factor | > 1.5 | 1.0–1.5 | < 1.0 |
|
||||
| Expected Payoff | > 0 | ≈ 0 | < 0 |
|
||||
| Recovery Factor | > 2.0 | 1.0–2.0 | < 1.0 |
|
||||
|
||||
**Profit Factor < 1.0** = guaranteed loss. The EA loses more than it wins.
|
||||
No amount of parameter tuning will fix a fundamentally negative PF — the
|
||||
strategy logic itself needs rethinking.
|
||||
|
||||
#### 3. Drawdown Analysis
|
||||
|
||||
Drawdown is the real killer. A 100% drawdown means account wiped.
|
||||
|
||||
| Metric | Safe | Risky | Dangerous |
|
||||
|--------|------|-------|-----------|
|
||||
| Max DD% | < 20% | 20–50% | > 50% |
|
||||
| DD Absolute / Deposit | < 0.5x | 0.5–1x | > 1x (blown) |
|
||||
|
||||
**Check both Balance DD and Equity DD.** Equity DD captures floating
|
||||
losses that haven't realized yet — often much worse than balance DD.
|
||||
|
||||
If `Balance DD Max% ≈ 100%`, the account was wiped. Look at the balance
|
||||
curve: did it recover or flatline at zero?
|
||||
|
||||
#### 4. Trade Distribution
|
||||
|
||||
| Metric | Healthy | Concerning |
|
||||
|--------|---------|------------|
|
||||
| Win Rate | 40–60% | < 30% or > 70% |
|
||||
| Avg Win / Avg Loss | > 1.5 | < 1.0 |
|
||||
| Profit Trades % | > 40% | < 30% |
|
||||
| Largest Loss / Avg Loss | < 3x | > 5x (outlier risk) |
|
||||
|
||||
Low win rate is fine if avg win >> avg loss (trend following).
|
||||
High win rate is fine if avg loss << avg win (mean reversion).
|
||||
**Red flag**: low win rate AND small avg win = guaranteed bleed.
|
||||
|
||||
#### 5. Consecutive Losses
|
||||
|
||||
| Metric | Tolerable | Stressed |
|
||||
|--------|-----------|----------|
|
||||
| Max Consecutive Losses | < 5 | > 8 |
|
||||
| Max Consecutive Loss $ | < 2x deposit | > deposit |
|
||||
|
||||
More than 8 consecutive losses suggests the strategy has long anti-trend
|
||||
periods. With martingale or grid sizing, consecutive losses compound
|
||||
catastrophically.
|
||||
|
||||
#### 6. Holding Time
|
||||
|
||||
| Pattern | Meaning | Risk |
|
||||
|---------|---------|------|
|
||||
| Very short avg (< 1 min) | Scalping / arbitrage | Spread/slippage sensitive |
|
||||
| Very long avg (> 100 hrs) | Swing / position trading | Gap/overnight risk |
|
||||
| Huge variance (min vs max) | Mixed strategy | Hard to predict behavior |
|
||||
|
||||
#### 7. MFE/MAE Analysis
|
||||
|
||||
- **MFE (Most Favorable Excursion)**: how far price went in your favor
|
||||
before exit. High MFE + low profit = premature exit (tight TP).
|
||||
- **MAE (Most Adverse Excursion)**: how far price went against you.
|
||||
High MAE + small loss = lucky exit (SL barely held).
|
||||
- **Correlation (Profits, MAE)**: high positive = losses come from
|
||||
large adverse moves (SL too loose or absent).
|
||||
- **Correlation (MFE, MAE)**: negative = when price moves far in one
|
||||
direction, it doesn't retrace (good for trend following).
|
||||
|
||||
#### 8. Stop-Out Detection
|
||||
|
||||
Stop-outs (comment contains `so`) mean margin was insufficient — the
|
||||
broker force-closed before SL was reached. This is always a critical bug:
|
||||
|
||||
```
|
||||
Root causes:
|
||||
1. SL too far from entry → floating loss exceeds available margin
|
||||
2. Lot size too large for account balance
|
||||
3. Risk per trade exceeds account capacity
|
||||
4. Multiple concurrent positions drain margin
|
||||
```
|
||||
|
||||
Fix: reduce lot size, tighten SL, or reduce concurrent positions.
|
||||
|
||||
#### 9. Short vs Long Bias
|
||||
|
||||
Compare `Short Trades (won%)` vs `Long Trades (won%)`:
|
||||
|
||||
- Heavily skewed (e.g. 91 long / 5 short) → EA only trades one direction
|
||||
- Check if this is intentional (bullish filter) or a bug
|
||||
- In trending markets, one-direction bias can mask poor signal quality
|
||||
|
||||
#### 10. Commission & Swap Impact
|
||||
|
||||
In the Deals table, check `Commission` and `Swap` columns:
|
||||
|
||||
- Commission should be consistent per deal (proportional to volume)
|
||||
- Swap accumulates on overnight positions — can turn winners into losers
|
||||
- `Profit = Price P&L + Commission + Swap` — verify this sums correctly
|
||||
|
||||
## 7. Event Handlers Reference
|
||||
|
||||
| Handler | When Called | Use Case |
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
#!/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) -> 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}")
|
||||
|
||||
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)")
|
||||
|
||||
|
||||
# ── 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")
|
||||
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.json:
|
||||
print(json.dumps(asdict(report), indent=2, ensure_ascii=False))
|
||||
else:
|
||||
print_report(report)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user