From 061e879638c0c4b3004489bafc4e4a876cc2df2d Mon Sep 17 00:00:00 2001 From: ZhijuCen Date: Sat, 4 Jul 2026 01:18:33 +0800 Subject: [PATCH] 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) --- AGENTS.md | 81 ++ skills/mql5/SKILL.md | 56 +- skills/mql5/scripts/parse_tester_report.py | 731 ++++++++++++++++++- skills/mql5/scripts/verify_sl_tp_formulas.py | 34 +- 4 files changed, 879 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8e60960..e65de28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,6 +94,87 @@ python skills/mql5/scripts/parse_tester_report.py --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 windows --count 1 + +# N=4: typical analysis (quarterly for a 1.5y backtest) +python skills/mql5/scripts/parse_tester_report.py windows --count 4 + +# JSON output for further processing +python skills/mql5/scripts/parse_tester_report.py 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 diff --git a/skills/mql5/SKILL.md b/skills/mql5/SKILL.md index 5fec4cf..ad1e48d 100644 --- a/skills/mql5/SKILL.md +++ b/skills/mql5/SKILL.md @@ -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 windows --count 4 +python skills/mql5/scripts/parse_tester_report.py 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; diff --git a/skills/mql5/scripts/parse_tester_report.py b/skills/mql5/scripts/parse_tester_report.py index 1619c81..53a874d 100644 --- a/skills/mql5/scripts/parse_tester_report.py +++ b/skills/mql5/scripts/parse_tester_report.py @@ -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 windows --count 1 + + # N=4: quarterly analysis for a 1.5y backtest + python parse_tester_report.py windows --count 4 + + # N=8: finer granularity + python parse_tester_report.py windows --count 8 + + # JSON output (per-window metrics + z-score outliers) + python parse_tester_report.py 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 diff --git a/skills/mql5/scripts/verify_sl_tp_formulas.py b/skills/mql5/scripts/verify_sl_tp_formulas.py index 68b82c3..baba29f 100644 --- a/skills/mql5/scripts/verify_sl_tp_formulas.py +++ b/skills/mql5/scripts/verify_sl_tp_formulas.py @@ -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__":