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)
This commit is contained in:
ZhijuCen
2026-07-04 01:18:33 +08:00
parent 835186faa9
commit 061e879638
4 changed files with 879 additions and 23 deletions
+81
View File
@@ -94,6 +94,87 @@ python skills/mql5/scripts/parse_tester_report.py <report.html> --analyze
Key analysis fields: `idle_time` (HH:MM:SS flat duration across backtest period),
`win_loss_ratio`, `breakeven_win_rate`, `monthly`, `reentries`, `lot_pattern`.
#### `windows` subcommand
Splits a backtest into N equal time windows and computes the same 7
core metrics (Profit, Expected Payoff, Profit Factor, Recovery Factor,
Balance DD Rel%, Trades, Sharpe Ratio) per window. Use to detect
over-fitting / regime change.
```
# N=1: validation — should match the full report within tolerance
python skills/mql5/scripts/parse_tester_report.py <report.html> windows --count 1
# N=4: typical analysis (quarterly for a 1.5y backtest)
python skills/mql5/scripts/parse_tester_report.py <report.html> windows --count 4
# JSON output for further processing
python skills/mql5/scripts/parse_tester_report.py <report.html> windows --count 6 --json
```
**Conventions:**
- Time boundaries are 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).
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.
- Balance DD Rel% computes MT5's STAT_BALANCE_DDREL_PERCENT (maximum
relative drawdown, i.e. the largest (peak - trough) / peak % across
the balance curve). For the full report this matches the HTML's
Balance Drawdown Relative field exactly (44.60% vs 44.63% on
246753; the 0.03% gap is from intra-trade floating P&L not in HTML).
Per the user's rule "如 Equity DD % 不可用,则以 Balance DD % 代替",
the script's `bal_dd_rel_pct` field is this balance-based relative
DD.
- Gross Profit / Gross Loss use MT5's split: each trade's exit-leg
P&L goes to GP if positive or GL if non-positive; entry costs always
go to GL. Matches the report exactly for window=1.
- Sharpe Ratio uses the standard per-trade HPR formula
(mean/std × sqrt(N_per_year)). MT5's reported value uses a
different (undocumented) annualization; the value differs from the
report for window=1, but the formula is consistent across all
sub-windows, so the relative ranking is still meaningful.
**Outlier flags per window (z-score vs window mean):**
- `▲2σ` — at least one metric has |z| ≥ 2 (值得关注 — this
window's value is far from the rest of the windows).
- `■EXT` — at least one metric has |z| ≥ 5 (极端 — extreme outlier).
- The marker is followed by `k=N` (count of outlier metrics) and
the metric abbreviations with their signed z (e.g.
`prof(+2.3σ),reco(+2.3σ)`).
z = (this window's value mean across all windows) / std.
Direction is sign-bearing (+/-); the threshold is on |z|.
Lower-is-better metrics (bal_dd_rel_pct) are NOT inverted — a
negative z means "this window's DD is unusually low" (good for
safety, neutral for consistency), a positive z means "unusually
high DD" (a red flag). For all other metrics, the natural sign
applies (high profit, high PF, etc. = good).
A single window with a strong outlier is a regime signal.
Multiple windows each with their own outliers point to a
high-variance strategy — harder to predict live performance.
**Tolerances (N=1 vs report):**
- 4 of 7 metrics are **exact**: Profit, Expected Payoff, Profit
Factor, Trades.
- 3 are documented approximations:
- **Balance DD Rel%** — MT5's STAT_BALANCE_DDREL_PERCENT (max
relative drawdown). Our value matches the HTML's Balance
Drawdown Relative field within 0.03%. Per the user's rule
"如 Equity DD % 不可用,则以 Balance DD % 代替" — this is what
we do.
- **Recovery Factor** — downstream of bal_dd_rel_abs.
- **Sharpe Ratio** — MT5's reported value is inconsistent with the
textbook formula `(AHPR - 1) / std_HPR × sqrt(N/year)` that the
MQL5 community reverse-engineers agree on (forum thread 337071).
For 246753 the textbook formula gives 2.49 vs the report's
22.92 — a 9.2× gap. MT5 does not publish the actual computation.
The per-trade Sharpe (`sharpe_ratio_raw` in the JSON output) is
still a useful per-window signal.
### parse_optimizer_report.py
Parses MT5 Strategy Tester Optimization XML reports (SpreadsheetML format
+55 -1
View File
@@ -777,6 +777,61 @@ detection, streak analysis). For raw data, use `--json` instead.
6. **Monthly breakdown**: Group trades by month, compute win rate and net P&L
per month. Identify worst months and correlate with market conditions.
#### 13. Time-Window Stability (Over-Fitting Detection)
Aggregate metrics over the full backtest period can hide **regime
change** — the strategy might be profitable in H2 2025 and
catastrophic in H1 2025, with the two cancelling out to a "good"
net profit. To detect this, use the `windows` subcommand to slice
the backtest into N equal time windows and recompute the same 7
metrics per window:
```bash
python skills/mql5/scripts/parse_tester_report.py <report.html> windows --count 4
python skills/mql5/scripts/parse_tester_report.py <report.html> windows --count 6 --json
```
Each window is flagged `▲2σ`, `■EXT`, or `-` based on the per-metric
z-score (this window's value mean across all windows) / std:
- `▲2σ` — at least one metric has |z| ≥ 2 (notable — this window's
value is far from the rest of the windows). The marker is followed
by `k=N` (count of outlier metrics) and the metric abbreviations
with their signed z (e.g. `prof(+2.3σ),reco(+2.3σ)`).
- `■EXT` — at least one metric has |z| ≥ 5 (extreme — extreme outlier).
Same `k=N` breakdown.
- `-` — no metric crosses the thresholds.
z is sign-bearing (+/-); the threshold is on |z|. Lower-is-better
metrics (bal_dd_rel_pct) are NOT inverted — a negative z means
"unusually low DD" (good for safety, neutral for consistency), a
positive z means "unusually high DD" (a red flag). For all other
metrics, the natural sign applies (high profit, high PF, etc. = good).
**Red flags (over-fitting / regime change):**
- A single window with a `■EXT` (|z|≥5) outlier on any metric — a
window whose value is 5 standard deviations from the rest is
almost certainly a different regime (or a metric that is
unstable across the backtest).
- Several windows each with their own `▲2σ` outliers on different
metrics — high cross-metric variance.
- A monotonic gradient in the raw values (e.g. profit rising from
win 0 to win N-1) — the strategy performs better late in the
backtest; could be a regime change or could be selection bias.
|- Adjacent windows have very different `bal_dd_rel_pct` (e.g. 5% vs
35%) — the strategy behaves inconsistently across sub-regimes.
**Use `--count 1` first** to verify the calculation matches the full
report within tolerance. Of 7 metrics, 4 are exact (Profit, EP, PF,
Trades); 3 are documented approximations: Recovery Factor (downstream
of bal_dd_rel_abs), Balance DD Rel% (maximum relative drawdown from
the balance curve — matches HTML's Balance Drawdown Relative within
0.03% on 246753), and Sharpe Ratio (MT5's reported value is
inconsistent with the textbook formula the MQL5 community
reverse-engineers derive — see the cross-check table printed at the
end of the N=1 output).
### Optimization Report Analysis
Optimization exports a different artifact: a single-worksheet XML-tagged
@@ -1128,7 +1183,6 @@ void OnTick() {
// trade.Buy(lots, _Symbol, 0, sl, 0, "EA Signal");
}
//+------------------------------------------------------------------+
double OnTester() {
double trades = TesterStatistics(STAT_TRADES);
if (trades < 30) return 0;
+726 -5
View File
@@ -16,6 +16,7 @@ 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
@@ -524,7 +525,23 @@ def print_report(r: Report, analyze_data: dict | None = None) -> None:
# ── Trade Analysis ───────────────────────────────────────────────────
def pair_trades(deals: list) -> list:
"""Pair entry/exit deals into complete trades."""
"""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 = []
@@ -536,6 +553,7 @@ def pair_trades(deals: list) -> list:
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)
@@ -550,6 +568,8 @@ def pair_trades(deals: list) -> list:
"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,
})
@@ -561,6 +581,28 @@ def pair_trades(deals: list) -> list:
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())
@@ -573,7 +615,6 @@ def format_duration(td) -> str:
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)
@@ -692,14 +733,669 @@ def analyze_report(report: Report) -> dict:
}
# ── 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")
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)
@@ -709,9 +1405,34 @@ def main():
report = parse_report(path)
# Always compute analyze data (needed for idle_time in text report)
analyze_data = analyze_report(report)
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
+17 -17
View File
@@ -213,10 +213,10 @@ def run_tests(
sl_buy = round(bid - sl_dist_price, spec.digits)
loss_buy = calc_profit(spec, lots, bid, sl_buy)
print(f" SL距离={sl_distance_pts:>5} pts "
f"→ 价格距离={sl_dist_price} "
print(f" SL dist={sl_distance_pts:>5} pts "
f"-> price dist={sl_dist_price} "
f"BUY SL={fmt(sl_buy, spec.digits)} "
f"亏损={fmt(loss_buy, spec.digits)} {pc}")
f"loss={fmt(loss_buy, spec.digits)} {pc}")
# Also verify with Ask = Bid + spread
spread_pts = 12 if spec.name == "XAUUSD" else 3
@@ -239,9 +239,9 @@ def run_tests(
print(f" Risk={risk_pct}% {direction:4s} "
f"SL={fmt(sl, spec.digits)} "
f"目标亏损={fmt(ml, 2)} {pc} "
f"实际亏损={fmt(actual_loss, 2)} {pc} "
f"={fmt(abs(actual_loss) - ml, 6)}")
f"target loss={fmt(ml, 2)} {pc} "
f"actual loss={fmt(actual_loss, 2)} {pc} "
f"diff={fmt(abs(actual_loss) - ml, 6)}")
print()
# ─────────────────────────────────────────────────────────────────
@@ -261,11 +261,11 @@ def run_tests(
else:
actual_loss = 0.0
print(f" SL距离={sl_distance_pts:>5} pts "
print(f" SL dist={sl_distance_pts:>5} pts "
f"SL={fmt(sl_buy, spec.digits)} "
f"计算手数={lots_calc:.4f} "
f"实际亏损={fmt(actual_loss, 2)} {pc} "
f"={fmt(abs(actual_loss) - ml, 6)}")
f"lots calc={lots_calc:.4f} "
f"actual loss={fmt(actual_loss, 2)} {pc} "
f"diff={fmt(abs(actual_loss) - ml, 6)}")
print()
# ─────────────────────────────────────────────────────────────────
@@ -295,7 +295,7 @@ def run_tests(
# Reverse loss check
loss_rev = calc_profit(spec, lots_rev, bid, sl_buy)
print(f" Risk={risk_pct}% SL距离={sl_pts}pts budget={fmt(ml, 2)} {pc}")
print(f" Risk={risk_pct}% SL dist={sl_pts}pts budget={fmt(ml, 2)} {pc}")
print(f" Forward: SL={fmt(sl_fwd, spec.digits)} lots={lots_fwd:.2f} "
f"loss={fmt(loss_fwd, 2)} {pc} (budget={fmt(ml, 2)})")
print(f" Reverse: lots={lots_rev:.4f} "
@@ -358,7 +358,7 @@ def main():
print(f" Step 2: convert to JPY = {fmt(target_usd, 2)} × {jpy_rate} = {fmt(target_jpy, 2)} JPY")
print(f" Step 3: points = budget / (PointValue × Lots)")
print(f" = {fmt(target_jpy, 2)} / ({point_value(jpy_spec):.1f} × {lots}) = {target_jpy / (point_value(jpy_spec) * lots):.1f} pts")
print(f" SL距离 = {target_jpy / (point_value(jpy_spec) * lots):.1f} × {jpy_spec.point} = "
print(f" SL dist = {target_jpy / (point_value(jpy_spec) * lots):.1f} x {jpy_spec.point} = "
f"{target_jpy / (point_value(jpy_spec) * lots) * jpy_spec.point:.4f} price")
print(f" SL = {bids['USDJPY']} - {target_jpy / (point_value(jpy_spec) * lots) * jpy_spec.point:.4f} = "
f"{fmt(sl_fwd, jpy_spec.digits)}")
@@ -367,8 +367,8 @@ def main():
print(f" Step 5: loss in USD = {fmt(loss_jpy, 2)} / {jpy_rate} = {fmt(loss_usd, 2)} USD")
print()
print(f" Result: target {fmt(target_usd, 2)} USD ≈ actual {fmt(loss_usd, 2)} USD "
f"(={fmt(abs(loss_usd) - target_usd, 4)} USD, "
f"来自 NormalizeDouble 四舍五入)")
f"(diff={fmt(abs(loss_usd) - target_usd, 4)} USD, "
f"from NormalizeDouble rounding)")
print()
# ─────────────────────────────────────────────────────────────────
@@ -390,10 +390,10 @@ def main():
actual_loss = calc_profit(xau, lots, bids["XAUUSD"], sl)
print(f" Lots={lots:>5.2f} "
f"SL距离={sl_dist_pts:>6} pts ({sl_dist_price:.2f} price) "
f"SL dist={sl_dist_pts:>6} pts ({sl_dist_price:.2f} price) "
f"SL={fmt(sl, xau.digits)} "
f"亏损={fmt(actual_loss, 2)} USD "
f"={fmt(abs(actual_loss) - ml_usd, 6)}")
f"loss={fmt(actual_loss, 2)} USD "
f"diff={fmt(abs(actual_loss) - ml_usd, 6)}")
if __name__ == "__main__":