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:
@@ -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:
|
||||
|
||||
@@ -7,5 +7,6 @@ license = "MIT"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"beautifulsoup4>=4.15.0",
|
||||
"pandas>=3.0.3",
|
||||
"requests>=2.34.2",
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user