diff --git a/AGENTS.md b/AGENTS.md index 0df4fba..8e60960 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ mql5-skills/ │ ├── scripts/ │ │ ├── mql5_helper.py # Compile/deploy/status via Wine │ │ ├── parse_tester_report.py # Backtest report parser + analysis +│ │ ├── parse_optimizer_report.py # Optimization report parser + analysis │ │ └── verify_sl_tp_formulas.py # SL/TP risk formula verification │ └── references/ │ ├── book/ # Programming book markdown (from sitemap_book_en.xml) @@ -93,6 +94,38 @@ 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`. +### parse_optimizer_report.py + +Parses MT5 Strategy Tester Optimization XML reports (SpreadsheetML format +— XML-tagged Excel workbook, also openable in LibreOffice Calc). Companion +to `parse_tester_report.py`; same three output modes: + +``` +python skills/mql5/scripts/parse_optimizer_report.py +python skills/mql5/scripts/parse_optimizer_report.py --json +python skills/mql5/scripts/parse_optimizer_report.py --analyze +``` + +Reads `` for the strategy environment card +(EA / Symbol / Period / Date range from `Title`, plus Deposit / Leverage / +Server / MT5 build / run timestamp) and the single "Tester Optimizator +Results" worksheet for one row per parameter pass. `--analyze` adds: + +- **Orthogonality**: actual pass count vs expected cartesian product +- **Parameter effect**: which Inp* parameters actually move the result + (vs. dead parameters that should be removed from optimization) +- **Dead boolean parameters**: bit-for-bit identical true/false groups + on key metrics — cleanest signal of a parameter not wired into the EA +- **Duplicate metric vectors**: high count (>30%) usually points to a + dead parameter +- **Best passes** by Profit / Profit Factor / Recovery Factor / Custom +- **Trade count distribution** with daily rate and correlations vs + profit / drawdown (overtrading detection) + +Use alongside `parse_tester_report.py` for the same EA: the latter +explains *why* a specific pass performs, the former explains *which* +pass performs and *which* parameters are even worth tuning. + ### mql5_helper.py MT5 development helper for compile/deploy/status via Wine: diff --git a/pyproject.toml b/pyproject.toml index c1ecc7f..8f202b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,5 +7,6 @@ license = "MIT" requires-python = ">=3.14" dependencies = [ "beautifulsoup4>=4.15.0", + "pandas>=3.0.3", "requests>=2.34.2", ] diff --git a/skills/mql5/SKILL.md b/skills/mql5/SKILL.md index bc3f44a..5fec4cf 100644 --- a/skills/mql5/SKILL.md +++ b/skills/mql5/SKILL.md @@ -517,6 +517,7 @@ must be performed through the MT5 Strategy Tester GUI. | Run backtest | ❌ | GUI only: Strategy Tester | | Run optimization | ❌ | GUI only: Strategy Tester | | Parse test report | ✅ | `scripts/parse_tester_report.py` | +| Parse optimization report | ✅ | `scripts/parse_optimizer_report.py` | MetaEditor CLI syntax (Linux/Wine, from MT5 base directory): ``` @@ -776,6 +777,156 @@ 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. +### Optimization Report Analysis + +Optimization exports a different artifact: a single-worksheet XML-tagged +Excel workbook (`ReportOptimizer-*.xml`, also openable in LibreOffice Calc). +Each row is one parameter pass; the first worksheet name is +`Tester Optimizator Results`. Use `scripts/parse_optimizer_report.py` to +extract and analyze it. The script has three modes: + +```bash +python skills/mql5/scripts/parse_optimizer_report.py ReportOptimizer-*.xml +python skills/mql5/scripts/parse_optimizer_report.py ReportOptimizer-*.xml --json +python skills/mql5/scripts/parse_optimizer_report.py ReportOptimizer-*.xml --analyze +``` + +The `Title` field in `` encodes the strategy +environment on one line: ` , -`. +`` also carries `Deposit`, `Leverage`, `Server`, +MT5 `Version`/`Build`, and the run timestamp — use these to verify the +backtest ran on the intended setup (wrong demo server, wrong leverage, +or stale build all invalidate the run). + +#### 1. Strategy Environment Card + +Read the parsed `env_card` first. Confirm before evaluating any pass: + +- **EA / Symbol / Period / Date range** match the spec +- **Deposit × Leverage** match the broker account class +- **Server** is the intended broker (demo vs live, broker name) +- **MT5 build** is current (5.00 / build 5000+ as of 2025) +- **Date range** covers the regime you want to test (≥ 1 year for swing, + ≥ 3 years for trend) + +If the date range is shorter than the strategy's intended holding period, +the optimization is structurally biased. + +#### 2. Orthogonality Check + +Compare `orthogonality.actual` vs `orthogonality.expected_cartesian` (product +of parameter cardinalities). Mismatch means the Strategy Tester skipped +passes (e.g. due to errors) — the table is incomplete and per-parameter +means will be biased. Re-run with longer timeout or fix the EA so every +pass completes. + +#### 3. Dead Parameter Detection + +The single highest-value analysis step. A "dead" parameter is one whose +value has no measurable effect on any output metric. The script flags two +patterns: + +- **`parameter_effect[X].dead_param == true`**: the per-group mean + Profit range is < 1% of the maximum group mean. This parameter is not + doing anything; remove it from optimization to halve the search space. +- **`dead_boolean_params[X]`**: for a boolean Inp* (e.g. NewsFilter), all + metrics (Profit, PF, RF, Trades) are bit-for-bit identical between + `true` and `false` groups. This is the cleanest dead-parameter signal: + the parameter is either never read in the EA, or it toggles a code path + that never triggers on this backtest. + +When you see dead boolean parameters, check the EA's logic for that input: +is the toggle actually wired? `if(InpUseNewsFilter) { ... }` requires +genuine news data to take effect — if the EA cannot load news (wrong +calendar URL, demo server, off-hours), the filter silently no-ops. + +#### 4. Duplicate Metric Vectors + +`duplicates.groups_with_dupes` counts rows with identical metric vectors +(Profit, PF, RF, Trades, ...). A high count (>30% of total) almost always +points to a dead parameter — the duplicated rows differ only in the dead +parameter's value. Example: 432 passes with a binary dead parameter will +collapse to 216 unique metric vectors, producing 216 duplicate pairs. + +#### 5. Best Pass Selection — Use Multiple Criteria + +The script reports top-5 by four criteria. They usually agree on the top +few but diverge on the tail. Read them together: + +| Criterion | Favors | Watch out | +|-----------|--------|-----------| +| **Profit** | Total return | Can hide low win rate with lucky runs | +| **Profit Factor** | Edge per unit of risk | Trade-count blind (low n) | +| **Recovery Factor** | Return per unit of max DD | Inflated by small DD, not big wins | +| **Custom (OnTester)** | Whatever your `OnTester()` returns | If OnTester only counts profit, equivalent to Profit | + +For a robust pick, find the pass that appears in multiple top-5 lists AND +has a `Trades` count near the median (statistical significance). A +pass with 161 trades near the min is barely significant; a pass with 175 +trades is the most reliable signal. + +#### 6. Parameter Effect Ranking + +`parameter_effect` orders parameters by `effect_ratio_spread_over_std` +(= spread between best and worst group means, divided by the global +Profit std). This is a quick "how much does each parameter matter" view: + +- `effect_ratio > 1.0` — dominant driver, focus tuning here +- `0.3 < ratio < 1.0` — meaningful but secondary +- `ratio < 0.3` — weak; many values perform similarly + +Combined with the per-group means, this tells you the gradient direction: +if `InpSLPips=30` mean is 269 and `InpSLPips=50` mean is 108, SL=30 wins +by 161. But beware counterintuitive results (e.g. tighter SL winning on +mean Profit) — they often mean SL is rarely hit and the "edge" is just +trade-count noise. + +#### 7. Trade Count Distribution + +`trades.deciles` and `trades.corr_trades_vs_*` expose overtrading and +under-trading patterns. Watch for: + +- **`corr_trades_vs_profit < -0.3`**: more trades → less profit. + Strategy degrades as it scales; common with mean-reversion or + re-entry on loss. +- **`corr_trades_vs_equity_dd > 0.3`**: more trades → more drawdown. + Overtuning costs both ways. +- **`trades_per_day_median`**: convert the trade count to a rate against + the backtest days. < 0.1/day for H4 = fine, > 1/day on H4 = scalper + regime (spread-sensitive). + +The trade-count RANGE itself is diagnostic. Range of 14 (161-175) on 432 +passes means parameters only changed entry/exit timing slightly, not the +core signal. Range of 50+ means a parameter is blocking trades entirely. + +#### 8. Cross-Analysis: `param_cross` + +`param_cross[X]` is the per-X mean Profit/PF/Trades table — the most +direct view of each parameter's gradient. To decide whether to widen +or narrow the optimization range, check the edges: are the best and +worst values at the boundaries of your range? If yes, the optimum may lie +outside — re-run with a wider range. + +#### 9. Optimization Reporting Template + +When reporting optimization results, include: + +1. **Environment card** (EA, symbol, period, date range, deposit, server) +2. **Pass count** vs expected cartesian (orthogonality status) +3. **Dead parameters** (if any) — these are bugs to fix, not "remove from + optimization" wins +4. **Top-3 passes by Profit**, with their parameter vector +5. **Top-3 by Recovery Factor** (more important than raw profit for live + trading) +6. **Trade count distribution** (median, range, correlation with profit) +7. **Per-parameter gradient** (which direction to push next iteration) +8. **Recommended next pass** (extend ranges if any edge is the optimum) + +Skip the "best PF" and "best recovery factor" sections only when they +identify the same pass as "best profit" — otherwise the disagreement is +the most interesting finding (it means there's a regime-specific trade-off +you should investigate, not average away). + ## 7. Event Handlers Reference | Handler | When Called | Use Case | diff --git a/skills/mql5/scripts/parse_optimizer_report.py b/skills/mql5/scripts/parse_optimizer_report.py new file mode 100644 index 0000000..573b842 --- /dev/null +++ b/skills/mql5/scripts/parse_optimizer_report.py @@ -0,0 +1,602 @@ +#!/usr/bin/env python3 +""" +Parse MT5 Strategy Tester Optimization XML report (SpreadsheetML format). + +Strategy Tester exports optimization results as an XML-tagged Excel workbook +(also readable by LibreOffice Calc). The first worksheet "Tester Optimizator +Results" contains one row per parameter pass, plus a +block with run metadata (EA, symbol, period, date range, deposit, leverage, +broker, MT5 build). + +This script extracts: + - : strategy environment (title, deposit, leverage, + server, MT5 build, run timestamp) + - Worksheet: parameter columns + per-pass result metrics + (Pass, Result, Profit, Expected Payoff, Profit + Factor, Recovery Factor, Sharpe Ratio, Custom, + Equity DD %, Trades) + - Analysis (--analyze): parameter orthogonality, parameter effect (does a + parameter actually influence output?), best passes + by multiple criteria, trade-count distribution, + duplicates (passes with identical metric vectors + usually mean a parameter is dead), correlation + between trade count and result. + +Usage: + python skills/mql5/scripts/parse_optimizer_report.py + python skills/mql5/scripts/parse_optimizer_report.py --json + python skills/mql5/scripts/parse_optimizer_report.py --analyze +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import warnings +from dataclasses import dataclass, field, asdict +from datetime import datetime +from pathlib import Path + +import pandas as pd +from bs4 import BeautifulSoup, XMLParsedAsHTMLWarning + +# Silences "It looks like you're using an HTML parser to parse an XML document" +# from the html.parser default. We intentionally use html.parser (no lxml +# dependency). +warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning) + + +# ── Data classes ───────────────────────────────────────────────────── + +@dataclass +class Env: + """Strategy environment extracted from .""" + title: str = "" + author: str = "" + revision: str = "" + created: str = "" + company: str = "" + mt5_version: str = "" + mt5_build: str = "" + server: str = "" + deposit: str = "" + leverage: str = "" + condition: str = "" + # Derived (parsed from title) + ea_name: str = "" + symbol: str = "" + period: str = "" + date_from: str = "" + date_to: str = "" + + +# Column groups (stable, from MT5 export) +METRIC_COLS = [ + "Result", "Profit", "Expected Payoff", "Profit Factor", + "Recovery Factor", "Sharpe Ratio", "Custom", "Equity DD %", "Trades", +] +# Heuristic: every column that is not "Inp*" and not "Pass" is a metric. +# InpUseNewsFilter is a boolean string, all other Inp* are numbers. + + +# ── Parsing ────────────────────────────────────────────────────────── + +def parse_env(soup: BeautifulSoup) -> Env: + """Pull into an Env dataclass.""" + env = Env() + dp = soup.find("documentproperties") + if not dp: + return env + + # BeautifulSoup lower-cases tag names; keys are already lowercase. + for child in dp.find_all(): + key = child.name + val = child.get_text(strip=True) + if key == "title": + env.title = val + elif key == "author": + env.author = val + elif key == "revision": + env.revision = val + elif key == "created": + env.created = val + elif key == "company": + env.company = val + elif key == "version": + env.mt5_version = val + elif key == "build": + env.mt5_build = val + elif key == "server": + env.server = val + elif key == "deposit": + env.deposit = val + elif key == "leverage": + env.leverage = val + elif key == "condition": + env.condition = val + + # Title pattern (observed): " , -" + m = re.match( + r"(\S+)\s+(\w+),(\w+)\s+(\d{4}\.\d{2}\.\d{2})-(\d{4}\.\d{2}\.\d{2})", + env.title, + ) + if m: + env.ea_name = m.group(1) + env.symbol = m.group(2) + env.period = m.group(3) + env.date_from = m.group(4) + env.date_to = m.group(5) + return env + + +def parse_passes(soup: BeautifulSoup) -> pd.DataFrame: + """Pull the first worksheet into a typed DataFrame. + + Assumes the well-known MT5 column layout: 1 Pass col + 9 metric cols + + N Inp* param cols. cells become strings + (covers InpUseNewsFilter 'true'/'false'); everything else is coerced + to numeric. + """ + ws = soup.find("worksheet", attrs={"ss:name": "Tester Optimizator Results"}) + if not ws: + # Fallback: first worksheet regardless of name + ws = soup.find("worksheet") + if not ws: + return pd.DataFrame() + + table = ws.find("table") + if not table: + return pd.DataFrame() + + rows = table.find_all("row") + if len(rows) < 2: + return pd.DataFrame() + + headers = [c.get_text(strip=True) for c in rows[0].find_all("cell")] + + data = [] + for r in rows[1:]: + cells = r.find_all("cell") + data.append([c.get_text(strip=True) for c in cells]) + + df = pd.DataFrame(data, columns=headers) + + # Type inference + for col in df.columns: + if col == "Pass": + df[col] = pd.to_numeric(df[col], errors="coerce").astype("Int64") + continue + if col == "InpUseNewsFilter": + # Stay as string "true"/"false" — typed comparison matters + df[col] = df[col].astype(str) + continue + # Numeric metric or numeric parameter + df[col] = pd.to_numeric(df[col], errors="coerce") + + return df + + +def parse_report(path: Path) -> tuple[Env, pd.DataFrame]: + """Convenience wrapper: file → (Env, DataFrame).""" + with open(path, encoding="utf-8") as f: + raw = f.read() + soup = BeautifulSoup(raw, "html.parser") + return parse_env(soup), parse_passes(soup) + + +# ── Analysis ───────────────────────────────────────────────────────── + +def _param_cols(df: pd.DataFrame) -> list[str]: + """Inp* columns that the EA declared for optimization.""" + return [c for c in df.columns if c.startswith("Inp")] + + +def _backtest_days(env: Env) -> int | None: + if not (env.date_from and env.date_to): + return None + try: + d0 = datetime.strptime(env.date_from, "%Y.%m.%d") + d1 = datetime.strptime(env.date_to, "%Y.%m.%d") + return (d1 - d0).days + except ValueError: + return None + + +def analyze(df: pd.DataFrame, env: Env) -> dict: + """Run optimization analysis. Returns a dict with sections. + + Sections: + - orthogonality: full pass count vs expected cartesian product + - parameter_cardinality: how many distinct values each Inp* took + - parameter_effect: is the parameter actually doing anything? + (true = metric groups are statistically + distinguishable; false = the param is dead) + - duplicates: passes with identical metric vector ⇒ a + parameter is not influencing output + - best_passes: top-N by Profit / Profit Factor / Recovery + Factor / Custom + - trades: distribution, daily rate, correlations + - param_cross: per-parameter mean Profit / Profit Factor + """ + out: dict = {"env_card": _env_card(env), "pass_count": int(len(df))} + + if df.empty: + out["error"] = "No pass rows found" + return out + + pcols = _param_cols(df) + + # 1. Orthogonality — did the optimizer cover the full cartesian product? + n = len(df) + expected = 1 + cardinalities = {} + for c in pcols: + u = df[c].nunique(dropna=True) + cardinalities[c] = int(u) + expected *= int(u) if u else 1 + out["orthogonality"] = { + "actual": n, + "expected_cartesian": expected, + "complete": n == expected, + "missing": expected - n if expected > n else 0, + } + out["parameter_cardinality"] = cardinalities + + # 2. Parameter effect — for each parameter, does changing its value + # produce statistically distinguishable Profit groups? + effects = {} + for c in pcols: + groups = df.groupby(c, observed=True)["Profit"] + # Two signals: (a) range / std ratio; (b) min==max (suggests dead) + per_group_stats = groups.agg(["count", "mean", "median", "std", "min", "max"]) + max_mean = per_group_stats["mean"].max() + min_mean = per_group_stats["mean"].min() + max_min = per_group_stats["max"].max() + min_min = per_group_stats["min"].min() + + # Aggregate std across all passes (baseline noise) + global_std = df["Profit"].std() or 0.0 + spread = max_mean - min_mean + + # Effect = spread / global std. >1 = meaningful, <0.3 = negligible. + effect_ratio = round(spread / global_std, 2) if global_std > 0 else 0.0 + + # If max group min == min group max ⇒ groups don't overlap ⇒ strong + # effect. If they fully overlap ⇒ no separation. + no_overlap = max_min <= min_mean # best group's worst is worse than worst group's best + + # Also check: the per-group means are within 1% of each other (truly dead) + if max_mean > 0: + relative_spread = spread / abs(max_mean) + else: + relative_spread = 0.0 + + effects[c] = { + "n_unique": int(df[c].nunique()), + "values": sorted(df[c].unique().tolist(), key=str), + "global_mean_profit": round(float(df["Profit"].mean()), 2), + "per_group_mean": { + str(k): round(float(v), 2) + for k, v in per_group_stats["mean"].items() + }, + "spread_max_minus_min": round(float(spread), 2), + "effect_ratio_spread_over_std": effect_ratio, + "relative_spread_pct": round(relative_spread * 100, 1), + "groups_separated": bool(no_overlap), + "dead_param": bool(relative_spread < 0.01), # <1% spread = dead + } + out["parameter_effect"] = effects + + # 3. Duplicates — passes with identical metric vector ⇒ that parameter + # combination is not actually influencing output. + metric_cols_present = [c for c in METRIC_COLS if c in df.columns] + dupes = ( + df.groupby(metric_cols_present, dropna=False) + .size() + .reset_index(name="count") + ) + dupes = dupes[dupes["count"] > 1].sort_values("count", ascending=False) + out["duplicates"] = { + "groups_with_dupes": int(len(dupes)), + "total_dup_rows": int(dupes["count"].sum() - len(dupes)) if len(dupes) else 0, + "examples": dupes.head(5).to_dict("records"), + } + + # 4. Best passes by multiple criteria + out["best_passes"] = { + "by_profit": _top(df, "Profit", 5), + "by_profit_factor": _top(df, "Profit Factor", 5), + "by_recovery_factor": _top(df, "Recovery Factor", 5), + "by_custom": _top(df, "Custom", 5), + } + + # 5. Trade-count distribution + if "Trades" in df.columns: + tr = df["Trades"] + out["trades"] = { + "min": int(tr.min()), + "max": int(tr.max()), + "mean": round(float(tr.mean()), 1), + "median": float(tr.median()), + "stdev": round(float(tr.std()), 2), + "deciles": [int(tr.quantile(q / 10)) for q in range(0, 11)], + } + # Trades/day: helps detect overtrading relative to backtest length + days = _backtest_days(env) + if days and days > 0: + median_trades = float(tr.median()) + out["trades"]["backtest_days"] = days + out["trades"]["trades_per_day_median"] = round(median_trades / days, 3) + out["trades"]["trades_per_day_max"] = round(float(tr.max()) / days, 3) + # Correlations: overtrading usually correlates negatively with PF + if "Profit Factor" in df.columns: + out["trades"]["corr_trades_vs_profit_factor"] = round( + float(tr.corr(df["Profit Factor"])), 3 + ) + if "Profit" in df.columns: + out["trades"]["corr_trades_vs_profit"] = round( + float(tr.corr(df["Profit"])), 3 + ) + if "Equity DD %" in df.columns: + out["trades"]["corr_trades_vs_equity_dd"] = round( + float(tr.corr(df["Equity DD %"])), 3 + ) + + # 6. Cross-analysis: each parameter's effect on key metrics + cross = {} + metric_targets = [c for c in ["Profit", "Profit Factor", "Trades"] if c in df.columns] + for p in pcols: + agg_dict = {m: ["mean", "median", "min", "max"] for m in metric_targets} + agg_dict["Pass"] = "count" + grp = df.groupby(p, observed=True).agg(agg_dict) + grp.columns = [f"{m}_{stat}" for m, stat in grp.columns] + cross[p] = ( + grp.reset_index() + .rename(columns={"Pass_count": "n_passes"}) + .to_dict("records") + ) + out["param_cross"] = cross + + # 7. Boolean parameter symmetry check + # For boolean Inp* (e.g. InpUseNewsFilter), a 1:1 identical outcome + # between true/false groups is the cleanest "dead parameter" signal. + bool_params = {} + for p in pcols: + if df[p].nunique() == 2 and set(df[p].unique()) <= {"true", "false"}: + for m in ["Profit", "Profit Factor", "Trades", "Recovery Factor"]: + if m not in df.columns: + continue + t = df.loc[df[p] == "true", m].mean() + f = df.loc[df[p] == "false", m].mean() + if abs(t - f) < 1e-6: + bool_params.setdefault(p, []).append(m) + if bool_params: + out["dead_boolean_params"] = { + p: sorted(set(metrics)) + for p, metrics in bool_params.items() + } + + return out + + +def _env_card(env: Env) -> dict: + """Compact strategy-environment summary for JSON / text output.""" + days = _backtest_days(env) + return { + "ea_name": env.ea_name, + "symbol": env.symbol, + "period": env.period, + "date_from": env.date_from, + "date_to": env.date_to, + "backtest_days": days, + "deposit": env.deposit, + "leverage": env.leverage, + "server": env.server, + "mt5_version": env.mt5_version, + "mt5_build": env.mt5_build, + "run_created": env.created, + } + + +def _top(df: pd.DataFrame, col: str, n: int) -> list[dict]: + """Top-N rows by `col`, with the criterion col + parameters preserved.""" + if col not in df.columns or df.empty: + return [] + pcols = _param_cols(df) + # Criterion col is the first metric; avoid duplicating it in the tail list + tail = ["Pass", "Profit", "Profit Factor", "Recovery Factor", "Trades"] + show = [c for c in [col] + tail if c in df.columns and c not in (col,)] + # Ensure col itself is first + show = [col] + [c for c in show if c != col] + # Pass is always useful + if "Pass" in df.columns and "Pass" not in show: + show = ["Pass"] + show + show = show + [c for c in pcols if c in df.columns and c not in show] + return df.nlargest(n, col)[show].to_dict("records") + + +# ── Text output ────────────────────────────────────────────────────── + +def _fmt_param_row(row: dict, pcols: list[str]) -> str: + """Format one best-pass row for the text report.""" + bits = [f"Pass {row.get('Pass', '?'):>3}"] + for k in ("Profit", "Profit Factor", "Recovery Factor", "Trades"): + if k in row: + v = row[k] + if isinstance(v, float): + if k == "Trades": + bits.append(f"{k}={int(v)}") + else: + bits.append(f"{k}={v:.3f}") + else: + bits.append(f"{k}={v}") + for p in pcols: + if p in row: + bits.append(f"{p}={row[p]}") + return " ".join(bits) + + +def print_report(env: Env, df: pd.DataFrame) -> None: + print("=" * 70) + print("STRATEGY TESTER OPTIMIZATION REPORT") + print("=" * 70) + print(f" EA: {env.ea_name or '(unknown)'}") + print(f" Symbol: {env.symbol or '(unknown)'}") + print(f" Period: {env.period or '(unknown)'}") + print(f" Date range: {env.date_from} → {env.date_to}") + days = _backtest_days(env) + if days: + print(f" Backtest days: {days}") + print(f" Deposit: {env.deposit}") + print(f" Leverage: {env.leverage}") + print(f" Server: {env.server}") + print(f" MT5: {env.mt5_version} (build {env.mt5_build})") + print(f" Run created: {env.created}") + print() + if df.empty: + print(" (no pass rows found)") + return + + pcols = _param_cols(df) + print(f" Passes: {len(df)}") + print(f" Parameters: {', '.join(pcols) or '(none)'}") + print() + print(" Parameter cardinalities:") + for p in pcols: + u = df[p].nunique() + print(f" {p}: {u} unique value(s)") + print() + print(" Use --analyze for full parameter-effect / duplicate / best-pass analysis.") + print(" Use --json for raw parsed data.") + + +def print_analyze(env: Env, df: pd.DataFrame, an: dict) -> None: + print_report(env, df) + if "error" in an: + print(f"\nERROR: {an['error']}") + return + pcols = _param_cols(df) + + print() + print("=" * 70) + print("ORTHOGONALITY") + print("=" * 70) + ortho = an["orthogonality"] + print(f" Passes: {ortho['actual']}") + print(f" Expected (cart.): {ortho['expected_cartesian']}") + print(f" Complete: {ortho['complete']}") + if not ortho["complete"]: + print(f" Missing: {ortho['missing']}") + + print() + print("=" * 70) + print("PARAMETER EFFECT (does the parameter change the result?)") + print("=" * 70) + for p, info in an["parameter_effect"].items(): + flag = "DEAD" if info["dead_param"] else ("weak" if info["effect_ratio_spread_over_std"] < 0.5 else "active") + print(f"\n {p} [{flag}] " + f"spread={info['spread_max_minus_min']:.1f} " + f"rel_spread={info['relative_spread_pct']:.1f}% " + f"effect_ratio={info['effect_ratio_spread_over_std']}") + for val, mean in info["per_group_mean"].items(): + print(f" {p}={val!s:>8} mean Profit = {mean}") + + if an.get("dead_boolean_params"): + print() + print(" ⚠️ Boolean parameters with identical metric means on true/false:") + for p, metrics in an["dead_boolean_params"].items(): + print(f" {p}: identical on {', '.join(metrics)} — this parameter did not influence the backtest") + + print() + print("=" * 70) + print("DUPLICATES (passes with identical metric vectors → dead parameter)") + print("=" * 70) + dup = an["duplicates"] + print(f" Groups with duplicates: {dup['groups_with_dupes']}") + print(f" Total extra duplicate rows: {dup['total_dup_rows']}") + if dup["examples"]: + print(" Examples (top 5 by count):") + for ex in dup["examples"][:5]: + print(f" count={ex['count']} " + f"Profit={ex.get('Profit')} PF={ex.get('Profit Factor')} " + f"RF={ex.get('Recovery Factor')} Trades={ex.get('Trades')}") + + print() + print("=" * 70) + print("BEST PASSES") + print("=" * 70) + for criterion, rows in an["best_passes"].items(): + print(f"\n Top 5 by {criterion}:") + for r in rows: + print(f" {_fmt_param_row(r, pcols)}") + + if "trades" in an: + tr = an["trades"] + print() + print("=" * 70) + print("TRADE COUNT DISTRIBUTION") + print("=" * 70) + print(f" Range: {tr['min']} – {tr['max']}") + print(f" Mean: {tr['mean']}") + print(f" Median: {tr['median']}") + print(f" Stdev: {tr['stdev']}") + print(f" Deciles: {tr['deciles']}") + if "backtest_days" in tr: + print(f" Trades/day (median over {tr['backtest_days']} days): {tr['trades_per_day_median']}") + print(f" Trades/day (max): {tr['trades_per_day_max']}") + for k in ( + "corr_trades_vs_profit_factor", + "corr_trades_vs_profit", + "corr_trades_vs_equity_dd", + ): + if k in tr: + hint = "" + v = tr[k] + if k == "corr_trades_vs_profit_factor" and v < -0.3: + hint = " ← more trades → worse PF (overtrading signal)" + elif k == "corr_trades_vs_equity_dd" and v > 0.3: + hint = " ← more trades → higher drawdown" + print(f" {k}: {v}{hint}") + + +# ── CLI ────────────────────────────────────────────────────────────── + +def main() -> None: + parser = argparse.ArgumentParser( + description="Parse MT5 Strategy Tester Optimization XML report" + ) + parser.add_argument("report", help="Path to ReportOptimizer-*.xml file") + parser.add_argument("--json", action="store_true", help="Output raw parsed data as JSON") + parser.add_argument( + "--analyze", + action="store_true", + help="Run optimization analysis (parameter effect, duplicates, best passes, trade distribution)", + ) + args = parser.parse_args() + + path = Path(args.report) + if not path.exists(): + print(f"Error: {path} not found", file=sys.stderr) + sys.exit(1) + + env, df = parse_report(path) + analyze_data = analyze(df, env) if (args.analyze or args.json) else None + + if args.json or args.analyze: + payload = { + "env": asdict(env), + "pass_count": int(len(df)), + "columns": list(df.columns), + "passes": df.where(pd.notnull(df), None).to_dict("records"), + } + if analyze_data is not None: + payload["analyze"] = analyze_data + print(json.dumps(payload, indent=2, ensure_ascii=False, default=str)) + else: + print_report(env, df) + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock index 3b71219..bcabb79 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,11 @@ version = 1 revision = 3 requires-python = ">=3.14" +resolution-markers = [ + "sys_platform == 'win32'", + "sys_platform == 'emscripten'", + "sys_platform != 'emscripten' and sys_platform != 'win32'", +] [[package]] name = "beautifulsoup4" @@ -80,15 +85,87 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "beautifulsoup4" }, + { name = "pandas" }, { name = "requests" }, ] [package.metadata] requires-dist = [ { name = "beautifulsoup4", specifier = ">=4.15.0" }, + { name = "pandas", specifier = ">=3.0.3" }, { name = "requests", specifier = ">=2.34.2" }, ] +[[package]] +name = "numpy" +version = "2.5.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e7/05/3d27272d30698dc0ecb7fdfaa41ad70303b444f81722bb99bce1d818638a/numpy-2.5.0.tar.gz", hash = "sha256:5a129578019311b6e56bdd714250f19b518f7dceeeb8d1af5490f4942d3f891c" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/86/ad/abc44aaceaf7b17ee1edde2bbb4458da591bc79574cffff50c4bb35f00d1/numpy-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f27582c55ba4c750b7c58c8faf021d2cd9324a662b466229db8a417b41368af9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5d/39/b72e168daf9c00fb20c9fc996d00437ccecdef3102387775d29d7a62576d/numpy-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:28e7137057d551e4a83c4ae414e3451f50568409db7569aacc7f9811ee06a446" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f7/a0/8400a9c0e3625182347593f5e1f57da9a617a534794805c8df5518154ddc/numpy-2.5.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e1da54b53e75cd9fcfc23efcc7edab2c6aecf97b6037566d8a0fe804af8ec57c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f6/8c/0d104deaa0401c93395a629ec902891618a2eff76d19229139cb5a887bfc/numpy-2.5.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:694d8f74e156f7fd01179f1aa8faa2f648ab6ae0f70b6c3fe57a03249aea2303" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6a/d9/4a4a628c812750363786afc3d33492709a5cd64b215469c16b0f6c7bb811/numpy-2.5.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a7569a7b53c77716f036bb28cb1c91f166a26ec7d9502cd1e4bdfe502fdec22" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/5e/2a902317d7fc4aa93236e80c932662dadfc459b323d758329e01775125e1/numpy-2.5.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39a0433bd4086ebd462960cf375e19195bb07b53dc1d87dd5fcf47ad78576f03" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e9/a0/a0090e6329f4ca5992c07847bb579c5259a19953dc57255bb08793142ffb/numpy-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:929f0c79ac38bcbd7154fe631dc907abfeddbcc5027a896bd1f7767323271e7a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5e/7d/6caf27734c42b65837e7461ed0dbbd6b6fc835060c9714ec59d673bb383a/numpy-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cc4f247a47bbf070bfd70be53ccdcf47b800af563535e7bbe172322197c30e21" }, + { url = "https://mirrors.aliyun.com/pypi/packages/13/dc/26edadbd812536769a82c2e9e002234e33feb5da43061d47a044f6d309b7/numpy-2.5.0-cp314-cp314-win32.whl", hash = "sha256:5dc71423499fab3f46f7a7201155ade1669ea101f2f429d332df9e72f8161731" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f2/9e/4dd1459282229a72d92dece2ae9138e5cac94a72263a7ceb48f37434c925/numpy-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ebb81d9d5443e0309d6c54894c3fbed74ad7da0714352a67b6d773cd189eae73" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/a7/6bc6384c080b86c7f6c85c5bc5b540b24f4f679cd144791d99574e90d462/numpy-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3b94d0d0deceebfad3e67ae5c0e5eb87371e8f7a0581cd04a779928c2450cf1e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/6b/4a2b71d66ada5608ae02b63f150dfad520f6940721cb7f029ad270befc0e/numpy-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:22f3d43e362d650bc39db1f17851302874a148ca95ba6981c1dfb5fa6862f35b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/b2/d365eb40a20efb49d67e9feb90494ed8511282ee1f5fa16006675c65397d/numpy-2.5.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:243563efb4cd7528a264567e9fd206c87826457322521d06206a00bfa316c927" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fa/5e/e9c03188de5f9b767e46a8fe988bcfd3efad066a4a3fda8b9cb11a93f895/numpy-2.5.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:84881d825ca75249b189bbee875fcfe3238aa5c479e6100893cda566e8e86826" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/1d/68c186a38a5027bae2c4ddd5ea681fdaf8b4d30fb7301def6d8ad270390f/numpy-2.5.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cda12aa4779d42b8771180aba759c96f527d43446d8f380ab59e2b35e8489efd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/67/73f67b7c7e20635baae9c4c3ead4ae7326a005900297a6110971abd62eb5/numpy-2.5.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c0121101093d2bd74981b10f8837d78e794a8ff57834eb27179f49e1ba11ac6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/eb/05/d4c1fb0c46d02a27d6b2b8b319a78c90937acec8631c1641874670b31e6f/numpy-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d371c92cfa09da00022f501ab67fafaea813d752eb30ac44336d45b1e5b0268a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/1d/771c797d50fa26e4888989cccf1d50ee51f530d4e455ad2692dcb64fa711/numpy-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9990713e9c38154c6861e7547f1e3fc7a87e75ff09bab24ef1cc81d81c2835e9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e8/46/52fc0d2a68d7643f0f149eeea5a5d8ea2a3507056ac8afa83c9212606e8b/numpy-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:edadfbd4794b1086c0d822f81863e8a68fc129d132fd0bb9e31e955d7fbbbdb7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2a/be/6c8d1118b5f13b2881dc095d5b345de19c6638b8959c17409b6eff84c8aa/numpy-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f7e5fa4382967ae6548bd2f174219afb908e294b0d5f625af01166edd5f7d9aa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/6a/d3a169aaf8536cf228d56a09e04bcb713a2fe4410d4e2105b9419b5a9c89/numpy-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:016623417bb330d719d579daf2d6b9a01ddc52e41a9ed61a47f39fde46dcd865" }, +] + +[[package]] +name = "pandas" +version = "3.0.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76" }, + { url = "https://mirrors.aliyun.com/pypi/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04" }, + { url = "https://mirrors.aliyun.com/pypi/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028" }, + { url = "https://mirrors.aliyun.com/pypi/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -104,6 +181,15 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274" }, +] + [[package]] name = "soupsieve" version = "2.8.4" @@ -122,6 +208,15 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" }, ] +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7" }, +] + [[package]] name = "urllib3" version = "2.7.0"