feat(parse_optimizer_report): analyze MT5 optimization XML reports

New script skills/mql5/scripts/parse_optimizer_report.py reads the
SpreadsheetML export from MT5 Strategy Tester optimization (one row per
parameter pass, plus a <DocumentProperties> environment card).

Companion to parse_tester_report.py. --analyze adds:

- Strategy environment card (EA / Symbol / Period / date range from
  Title; deposit / leverage / server / MT5 build from DocumentProperties)
- Orthogonality check (actual passes vs expected cartesian product)
- Parameter effect ranking (effect_ratio = spread / global std) and
  dead-parameter detection (per-group mean range < 1% of max)
- Dead boolean parameter detection (bit-for-bit identical true/false
  groups across all key metrics)
- Duplicate metric vector counting (>30% usually means a dead param)
- Top-5 best passes by Profit / Profit Factor / Recovery Factor / Custom
- Trade count distribution with daily rate and correlations vs profit
  and drawdown (overtrading / undertrading detection)

SKILL.md adds a new 'Optimization Report Analysis' subsection under
the Backtesting chapter covering environment card, orthogonality,
dead parameter / boolean parameter / duplicate detection, multi-
criteria best-pass selection, parameter effect ranking, trade count
diagnostics, and a reporting template.

Validated against jobs/246753/ReportOptimizer-*.xml (OneShotGold,
XAUUSD H4, 432 passes): correctly identifies InpUseNewsFilter as a
dead boolean parameter (true/false identical on Profit/PF/RF/Trades,
189 of 432 metric-vector duplicate groups), reports the trade/profit
overtrading signal (corr -0.56), and ranks best passes.

Adds pandas dependency (used for groupby/aggregate/corr on the pass
table; the script falls back to dict-list output if needed).
This commit is contained in:
ZhijuCen
2026-07-02 16:38:24 +08:00
parent d88094c06b
commit 15c76d97b3
5 changed files with 882 additions and 0 deletions
+33
View File
@@ -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 <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`.
### 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 <ReportOptimizer-*.xml>
python skills/mql5/scripts/parse_optimizer_report.py <report.xml> --json
python skills/mql5/scripts/parse_optimizer_report.py <report.xml> --analyze
```
Reads `<DocumentProperties>` 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:
+1
View File
@@ -7,5 +7,6 @@ license = "MIT"
requires-python = ">=3.14"
dependencies = [
"beautifulsoup4>=4.15.0",
"pandas>=3.0.3",
"requests>=2.34.2",
]
+151
View File
@@ -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 `<DocumentProperties>` encodes the strategy
environment on one line: `<EA> <SYMBOL>,<PERIOD> <YYYY.MM.DD>-<YYYY.MM.DD>`.
`<DocumentProperties>` 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 |
@@ -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 <DocumentProperties>
block with run metadata (EA, symbol, period, date range, deposit, leverage,
broker, MT5 build).
This script extracts:
- <DocumentProperties>: 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 <report.xml>
python skills/mql5/scripts/parse_optimizer_report.py <report.xml> --json
python skills/mql5/scripts/parse_optimizer_report.py <report.xml> --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 <DocumentProperties>."""
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 <DocumentProperties> 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): "<EA> <SYMBOL>,<PERIOD> <YYYY.MM.DD>-<YYYY.MM.DD>"
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. <Data ss:Type="String"> 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()
Generated
+95
View File
@@ -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"