feat(parse_optimizer_report): add --sort priority for outliers subcommand

Introduce a configurable sort priority for Set A and Set B in the
`outliers` subcommand, replacing the single-criterion "Result desc".

New CLI flag:
  --sort ABBR_LIST   comma-separated metric abbreviations
                     (default: R,EP,PF,RF,SR,P,DD,C,T)

Abbreviations: R=Result, P=Profit, EP=Expected Payoff, PF=Profit Factor,
RF=Recovery Factor, SR=Sharpe Ratio, C=Custom, DD=Equity DD %, T=Trades.

Equity DD % sorts ascending (lower is better); all others descending.
Unmentioned abbreviations are appended at default order.
This commit is contained in:
ZhijuCen
2026-07-07 11:37:07 +08:00
parent d751b4af14
commit 100d62bf2a
3 changed files with 251 additions and 37 deletions
+33 -6
View File
@@ -185,6 +185,7 @@ 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
python skills/mql5/scripts/parse_optimizer_report.py <report.xml> outliers [--sigma K] [--top-outliers N] [--top-normal M] [--sort ABBR_LIST] [--json]
```
Reads `<DocumentProperties>` for the strategy environment card
@@ -216,23 +217,30 @@ Sharpe Ratio, Custom, Equity DD %). Two disjoint sets:
- **Set A** — passes with AT LEAST ONE performance metric crossing
±σ in the favourable direction (higher-is-better metrics: z >= +σ;
Equity DD % uses z <= -σ because low DD is good). Sorted by
`Result` desc, top `--top-outliers` (default 10) shown.
the configured priority chain (default: `R↓, EP↓, PF↓, RF↓, SR↓, …`),
top `--top-outliers` (default 10) shown.
- **Set B** — passes with NO performance-metric outlier. Sorted by
`Result` desc, top `--top-normal` (default 5) shown.
the same priority chain, top `--top-normal` (default 5) shown.
Both sets EXCLUDE passes whose `Trades` count is itself a low-side
outlier (z <= -σ) — those have too few trades to trust, and the
excluded list is shown separately.
Output: header card + per-metric reference table (mean, std, ±σ
threshold) + Set A records (each split into Metrics group and Params
group, with the outlier σ values annotated) + Set B records + the
Excluded list.
Output: header card (includes `Sort priority: …` line) + per-metric
reference table (mean, std, ±σ threshold) + Set A records (each split
into Metrics group and Params group, with the outlier σ values
annotated) + Set B records + the Excluded list.
```
# Default (σ=2, top 10 outliers, top 5 normal)
python skills/mql5/scripts/parse_optimizer_report.py report.xml outliers
# Custom sort priority (abbreviations: R P EP PF RF SR C DD T)
python skills/mql5/scripts/parse_optimizer_report.py report.xml outliers --sort EP,RF,R,P
# Single priority metric; rest in default order
python skills/mql5/scripts/parse_optimizer_report.py report.xml outliers --sort DD
# Tighter threshold + custom counts
python skills/mql5/scripts/parse_optimizer_report.py report.xml outliers --sigma 2.5 --top-outliers 5 --top-normal 3
@@ -240,6 +248,25 @@ python skills/mql5/scripts/parse_optimizer_report.py report.xml outliers --sigma
python skills/mql5/scripts/parse_optimizer_report.py report.xml outliers --json
```
**Sort abbreviations:**
| Code | Full metric | Direction |
|------|--------------------|-----------|
| R | Result | ↓ (desc) |
| P | Profit | ↓ (desc) |
| EP | Expected Payoff | ↓ (desc) |
| PF | Profit Factor | ↓ (desc) |
| RF | Recovery Factor | ↓ (desc) |
| SR | Sharpe Ratio | ↓ (desc) |
| C | Custom | ↓ (desc) |
| DD | Equity DD % | ↑ (asc) |
| T | Trades | ↓ (desc) |
Default order: `R↓, EP↓, PF↓, RF↓, SR↓, P↓, DD↑, C↓, T↓`.
`DD↑` sorts ascending (lower drawdown is better); everything else
descending (higher values rank first). Supply `--sort ABBR_LIST` as a
comma-separated list to reorder; unmentioned metrics append at the
end in their default positional order.
**EA-agnostic by design**: the script does not hardcode any input
parameter name. Type inference reads `<Data ss:Type="String">` from
the SpreadsheetML header (boolean Inp* rendered as "true"/"false"
+41 -4
View File
@@ -849,13 +849,16 @@ python skills/mql5/scripts/parse_optimizer_report.py ReportOptimizer-*.xml --ana
Plus a subcommand:
```bash
python skills/mql5/scripts/parse_optimizer_report.py ReportOptimizer-*.xml outliers [--sigma 2] [--top-outliers 10] [--top-normal 5] [--json]
python skills/mql5/scripts/parse_optimizer_report.py ReportOptimizer-*.xml outliers [--sigma 2] [--top-outliers 10] [--top-normal 5] [--sort ABBR_LIST] [--json]
```
The `outliers` subcommand does a per-pass z-score scan on the 8
performance metrics and splits passes into "strongly-strong" (Set A,
at least one metric `|z| >= σ` in the favourable direction) vs
"no-outlier" (Set B), sorted by `Result` desc. See §10 below.
"no-outlier" (Set B), sorted by a configurable priority chain.
Default: `R↓, EP↓, PF↓, RF↓, SR↓, P↓, DD↑, C↓, T↓` (≈ by `Result`,
then `Expected Payoff`, … `Trades`; `↓` = descending, `↑` = ascending).
See §10 below.
The `Title` field in `<DocumentProperties>` encodes the strategy
environment on one line: `<EA> <SYMBOL>,<PERIOD> <YYYY.MM.DD>-<YYYY.MM.DD>`.
@@ -1006,6 +1009,12 @@ performance metrics.
# Default: σ=2.0, top 10 outlier passes, top 5 normal passes
python skills/mql5/scripts/parse_optimizer_report.py ReportOptimizer-*.xml outliers
# Custom sort: priority by ExpectedPayoff, RecoveryFactor, Result, Profit
python skills/mql5/scripts/parse_optimizer_report.py ReportOptimizer-*.xml outliers --sort EP,RF,R,P
# Single priority metric; rest in default order
python skills/mql5/scripts/parse_optimizer_report.py ReportOptimizer-*.xml outliers --sort DD
# Tighter threshold + custom top-N
python skills/mql5/scripts/parse_optimizer_report.py ReportOptimizer-*.xml outliers --sigma 2.5 --top-outliers 5
@@ -1027,8 +1036,36 @@ filter (see below).
good; high DD is bad but is not a "strong" outlier — those passes
are simply average)
**Two disjoint sets** (both sorted by `Result` desc, both excluding
low-Trades passes):
**Sort priority (`--sort`)** — control the ranking of passes within
Set A and Set B. Default priority (in abbreviation form):
```
R↓, EP↓, PF↓, RF↓, SR↓, P↓, DD↑, C↓, T↓
```
Abbreviations:
| Code | Full metric | Direction |
|------|--------------------|-----------|
| R | Result | ↓ (desc) |
| P | Profit | ↓ (desc) |
| EP | Expected Payoff | ↓ (desc) |
| PF | Profit Factor | ↓ (desc) |
| RF | Recovery Factor | ↓ (desc) |
| SR | Sharpe Ratio | ↓ (desc) |
| C | Custom | ↓ (desc) |
| DD | Equity DD % | ↑ (asc) |
| T | Trades | ↓ (desc) |
`DD↑` sorts ascending (lower drawdown first, because lower-is-better).
All other metrics sort descending (higher values first). Supply a
comma-separated list of abbreviations to reorder — e.g.
`--sort EP,RF,R,P` puts Expected Payoff first, then Recovery Factor,
then Result, then Profit, with the remaining metrics (`PF, SR, DD, C, T`)
appended in their default order at the end. Unmentioned abbreviations
are automatically appended in their default positional order.
**Two disjoint sets** (both sorted by the configured priority, both
excluding low-Trades passes):
- **Set A — passes with at least one perf-metric outlier.** These are
candidates for closer inspection: a pass posting `z > +2` on Profit
+177 -27
View File
@@ -26,7 +26,7 @@ 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
python skills/mql5/scripts/parse_optimizer_report.py <report.xml> outliers [--top-outliers N] [--top-normal M] [--sigma K] [--json]
python skills/mql5/scripts/parse_optimizer_report.py <report.xml> outliers [--top-outliers N] [--top-normal M] [--sigma K] [--sort ABBR_LIST] [--json]
"""
from __future__ import annotations
@@ -81,6 +81,34 @@ METRIC_COLS = [
# Heuristic: every column that is not "Inp*" and not "Pass" is a metric.
# InpUseNewsFilter is a boolean string, all other Inp* are numbers.
# ── Sort abbreviations (for the `outliers` --sort / --priority argument) ──
# Abbreviation → full metric name lookup for the --sort argument.
_SORT_ABBR_TO_FULL = {
"R": "Result",
"P": "Profit",
"EP": "Expected Payoff",
"PF": "Profit Factor",
"RF": "Recovery Factor",
"SR": "Sharpe Ratio",
"C": "Custom",
"DD": "Equity DD %",
"T": "Trades",
}
# Sort direction: True = ascending, False = descending.
# Result / Profit / Expected Payoff / Profit Factor / Recovery Factor /
# Sharpe Ratio / Custom / Trades all descend (higher-is-better).
# Equity DD % ascends (lower drawdown is better).
_SORT_ASCENDING: set[str] = {"Equity DD %"}
# Default sort priority (used when --sort is omitted).
_DEFAULT_SORT_PRIORITY: list[str] = [
"Result", "Expected Payoff", "Profit Factor",
"Recovery Factor", "Sharpe Ratio", "Profit",
"Equity DD %", "Custom", "Trades",
]
# ── Parsing ──────────────────────────────────────────────────────────
@@ -487,11 +515,85 @@ def _outlier_metrics_for_row(
return flags
def _parse_sort_priority(expr: str | None) -> list[str]:
"""Parse --sort expression into ordered list of full metric names.
Each comma-separated token is an abbreviation from _SORT_ABBR_TO_FULL.
Unmentioned metrics are appended at the end in _DEFAULT_SORT_PRIORITY order.
Examples:
"EP,RF,R,P" -> Expected Payoff, Recovery Factor, Result, Profit, ...
"EP" -> Expected Payoff, Result, Profit Factor, ...
None -> _DEFAULT_SORT_PRIORITY (full 9-metric chain)
"""
if not expr:
return list(_DEFAULT_SORT_PRIORITY)
mentioned: list[str] = []
for token in expr.split(","):
token = token.strip().upper()
if token in _SORT_ABBR_TO_FULL:
mentioned.append(_SORT_ABBR_TO_FULL[token])
else:
print(
f"Warning: unknown sort abbreviation '{token}', ignoring",
file=sys.stderr,
)
seen = set(mentioned)
for m in _DEFAULT_SORT_PRIORITY:
if m not in seen:
mentioned.append(m)
return mentioned
def _sort_key(rec: dict, priority: list[str]) -> tuple:
"""Multi-key sort key for a pass record.
Each metric in ``priority`` contributes one sort level. The returned
flat tuple feeds Python's stable ``list.sort()``.
None values sort after all present values (sentinel ``(1, 0.0)``
vs ``(0, signed_value)``).
Parameters
----------
rec : dict
A pass record with ``Result``, ``Trades`` (top-level keys) and
``perf_metrics`` (inner dict for the remaining metrics).
priority : list[str]
Ordered list of full metric names (output of _parse_sort_priority).
Returns
-------
tuple
Flat tuple suitable for use as ``key`` in ``sorted()``.
Each level is ``(is_none, signed_value)``.
"""
parts: list[tuple[int, float]] = []
for name in priority:
if name == "Trades":
v = rec.get("Trades")
elif name == "Result":
v = rec.get("Result")
else:
v = rec.get("perf_metrics", {}).get(name)
asc = name not in _SORT_ASCENDING # everything desc except DD
if v is None:
parts.append((1, 0.0)) # push to the very end
else:
# desc -> negative value; asc -> positive value
parts.append((0, v if asc else -v))
return tuple(parts)
def find_outliers(
df: pd.DataFrame,
sigma: float = 2.0,
top_outliers: int = 10,
top_normal: int = 5,
sort_priority: list[str] | None = None,
) -> dict:
"""Per-pass z-score outlier scan across the 8 performance metrics.
@@ -500,10 +602,10 @@ def find_outliers(
set_outliers — passes with AT LEAST ONE performance metric whose
z-score crosses ±sigma in the "strongly-strong" direction
(higher-is-better: z >= +sigma; lower-is-better Equity DD %:
z <= -sigma). Sorted by Result desc; top `top_outliers` shown.
z <= -sigma). Sorted by ``sort_priority``; top ``top_outliers`` shown.
set_normal — passes with NO performance-metric outlier. Sorted by
Result desc; top `top_normal` shown.
``sort_priority``; top ``top_normal`` shown.
Both sets EXCLUDE passes whose Trades count is itself a low-side
outlier (z <= -sigma). Such passes have too few trades to trust
@@ -513,22 +615,28 @@ def find_outliers(
Also returns the per-metric mean / std / sigma threshold table so
the user can judge whether the outlier counts are meaningful.
Output shape:
{
"per_metric": {"profit": {"mean":..., "std":..., "threshold_+sigma":...,
"threshold_-sigma":..., "direction": "higher"|"lower"},
...},
"trades": {"mean":..., "std":..., "low_outlier_z": ...},
"sigma": float,
"n_passes": int,
"excluded": [{"Pass":..., "Trades":..., "z": ...}, ...], # low-Trades outliers
"set_outliers":[{Pass, Result, profit, ..., outliers: [{metric, z}, ...],
params: {InpSLPips: ..., ...}}, ...],
"set_normal": [...],
"counts": {"outliers": int, "normal": int, "excluded": int},
}
Parameters
----------
df : pd.DataFrame
Parsed optimization report.
sigma : float
z-score threshold for outlier detection (default 2.0).
top_outliers : int
Max passes to return in set_outliers (default 10).
top_normal : int
Max passes to return in set_normal (default 5).
sort_priority : list[str] | None
Ordered list of full metric names for the multi-key sort.
None (= default) uses ``_DEFAULT_SORT_PRIORITY``.
Returns
-------
dict
See output shape below.
"""
out: dict = {"sigma": sigma, "n_passes": int(len(df))}
if sort_priority is None:
sort_priority = _parse_sort_priority(None)
out: dict = {"sigma": sigma, "n_passes": int(len(df)), "sort_priority": list(sort_priority)}
if df.empty:
out["error"] = "No pass rows"
@@ -596,8 +704,8 @@ def find_outliers(
for r in excluded
]
# Sort eligible passes by Result desc
eligible.sort(key=lambda r: (r["Result"] is None, -(r["Result"] or 0.0)))
# Sort eligible passes by the configured priority chain
eligible.sort(key=lambda r: _sort_key(r, sort_priority))
# Set A: at least one performance-metric outlier
set_a = [r for r in eligible if r["outliers"]]
@@ -687,6 +795,22 @@ def _fmt_outlier_row(rec: dict, pcols: list[str]) -> str:
return "\n".join(out_bits)
def _fmt_sort_priority(priority: list[str]) -> str:
"""Compact sort-priority string for display.
Example output: ``R↓, EP↓, PF↓, RF↓, …``
"""
rev_lookup = {full: abbr for abbr, full in _SORT_ABBR_TO_FULL.items()}
parts: list[str] = []
for name in priority:
abbr = rev_lookup.get(name, name)
arrow = "" if name in _SORT_ASCENDING else ""
parts.append(f"{abbr}{arrow}")
if len(parts) > 5:
parts = parts[:5] + [""]
return ", ".join(parts)
def print_outliers(env: Env, df: pd.DataFrame, out: dict) -> None:
"""Pretty-print the outlier scan as a text report.
@@ -728,8 +852,11 @@ def print_outliers(env: Env, df: pd.DataFrame, out: dict) -> None:
# Counts
c = out["counts"]
print(f" Set A (>=1 perf outlier, sorted by Result desc): {c['outliers']} passes")
print(f" Set B (no perf outlier, sorted by Result desc): {c['normal']} passes")
sort_priority = out.get("sort_priority", list(_DEFAULT_SORT_PRIORITY))
sort_fmt = _fmt_sort_priority(sort_priority)
print(f" Sort priority: {sort_fmt}")
print(f" Set A (>=1 perf outlier): {c['outliers']} passes")
print(f" Set B (no perf outlier): {c['normal']} passes")
if c["excluded_low_trades"]:
print(f" Excluded (Trades z<=-σ, too few trades to trust): {c['excluded_low_trades']} passes")
print()
@@ -740,8 +867,8 @@ def print_outliers(env: Env, df: pd.DataFrame, out: dict) -> None:
if out["set_outliers"]:
print("=" * 78)
print(f" SET A — Passes with at least one performance-metric outlier")
print(f" (top {len(out['set_outliers'])} by Result; "
f"{c['outliers']} total eligible)")
print(f" (top {len(out['set_outliers'])}; "
f"sort: {sort_fmt}; {c['outliers']} total eligible)")
print("=" * 78)
for rec in out["set_outliers"]:
print(_fmt_outlier_row(rec, pcols))
@@ -753,7 +880,8 @@ def print_outliers(env: Env, df: pd.DataFrame, out: dict) -> None:
# Set B
if out["set_normal"]:
print("=" * 78)
print(f" SET B — Passes with no performance-metric outlier (top {len(out['set_normal'])} by Result)")
print(f" SET B — Passes with no performance-metric outlier "
f"(top {len(out['set_normal'])}; sort: {sort_fmt})")
print("=" * 78)
for rec in out["set_normal"]:
print(_fmt_outlier_row(rec, pcols))
@@ -998,14 +1126,21 @@ Examples:
"outliers",
help="Per-pass z-score outlier scan on the 8 performance metrics. "
"Splits passes into a 'strongly-strong' set (at least one metric with |z|>=σ "
"in the favourable direction) and a 'no-outlier' set, both sorted by Result "
"desc and printed with the metrics group + the input-parameter group.",
"in the favourable direction) and a 'no-outlier' set, both sorted by the "
"configured priority (default: R↓, EP↓, PF↓, RF↓, …) and printed with the "
"metrics group + the input-parameter group.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Default: sigma=2, top 10 outlier passes, top 5 normal passes
python parse_optimizer_report.py ReportOptimizer-*.xml outliers
# Custom sort: priority by EP, then RF, then R, then P; rest in default order
python parse_optimizer_report.py ReportOptimizer-*.xml outliers --sort EP,RF,R,P
# Single priority metric; all others follow in default order
python parse_optimizer_report.py ReportOptimizer-*.xml outliers --sort EP
# Tighter sigma threshold and custom top-N
python parse_optimizer_report.py ReportOptimizer-*.xml outliers --sigma 3 --top-outliers 5
@@ -1017,6 +1152,12 @@ Sharpe Ratio, Expected Payoff, Custom, Result), an outlier is z >= +σ.
For lower-is-better (Equity DD %), an outlier is z <= -σ. Passes whose
Trades count is itself a low-side outlier (z <= -σ) are excluded
first — they have too few trades to trust.
Sort abbreviations (for --sort):
R=Result P=Profit EP=Expected Payoff PF=Profit Factor RF=Recovery Factor
SR=Sharpe Ratio C=Custom DD=Equity DD % T=Trades
Default priority: R↓, EP↓, PF↓, RF↓, SR↓, P↓, DD↑, C↓, T↓
(↓ = descending, ↑ = ascending; Equity DD % ascends — lower is better)
""",
)
p_out.add_argument(
@@ -1031,6 +1172,14 @@ first — they have too few trades to trust.
"--top-normal", type=int, default=5,
help="Number of passes to show from the 'no outlier' set (default: 5)",
)
p_out.add_argument(
"--sort", type=str, default=None,
help="Comma-separated sort priority using metric abbreviations "
"(e.g. 'EP,RF,R,P'). Default: full chain R↓,EP↓,PF↓,RF↓,SR↓,P↓,DD↑,C↓,T↓. "
"Unmentioned metrics append in default order. "
"Abbreviations: R=Result P=Profit EP=Expected Payoff PF=Profit Factor "
"RF=Recovery Factor SR=Sharpe Ratio C=Custom DD=Equity DD %% T=Trades",
)
p_out.add_argument(
"--json", action="store_true", help="Output outliers data as JSON",
)
@@ -1051,6 +1200,7 @@ first — they have too few trades to trust.
sigma=args.sigma,
top_outliers=args.top_outliers,
top_normal=args.top_normal,
sort_priority=_parse_sort_priority(args.sort),
)
if getattr(args, "json", False):
payload = {