2026-07-02 16:38:24 +08:00
|
|
|
|
#!/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
|
2026-07-07 11:37:07 +08:00
|
|
|
|
python skills/mql5/scripts/parse_optimizer_report.py <report.xml> outliers [--top-outliers N] [--top-normal M] [--sigma K] [--sort ABBR_LIST] [--json]
|
2026-07-02 16:38:24 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
|
2026-07-07 11:37:07 +08:00
|
|
|
|
# ── 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",
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-07-02 16:38:24 +08:00
|
|
|
|
|
|
|
|
|
|
# ── 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.
|
|
|
|
|
|
|
2026-07-04 20:13:12 +08:00
|
|
|
|
Column layout is determined from the SpreadsheetML header row: any
|
|
|
|
|
|
<Data ss:Type="String"> cell in the header marks a string column
|
|
|
|
|
|
(typically a boolean Inp* parameter rendered as "true"/"false");
|
|
|
|
|
|
everything else is coerced to numeric. This is EA-agnostic — no
|
|
|
|
|
|
hardcoded Inp* parameter names.
|
|
|
|
|
|
|
|
|
|
|
|
The Pass column is always forced to nullable Int64 (it's a row id
|
|
|
|
|
|
even when declared as Number in the SpreadsheetML). All other
|
|
|
|
|
|
Number columns become float64 (or Int64 when every value is a whole
|
|
|
|
|
|
number — useful for InpSLPips-style integer parameters).
|
2026-07-02 16:38:24 +08:00
|
|
|
|
"""
|
|
|
|
|
|
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()
|
|
|
|
|
|
|
2026-07-04 20:13:12 +08:00
|
|
|
|
# Header row: just extract column names. Header cells are always
|
|
|
|
|
|
# ss:Type="String" (they hold text labels), so we can't use them
|
|
|
|
|
|
# to decide which body columns are strings.
|
|
|
|
|
|
header_cells = rows[0].find_all("cell")
|
|
|
|
|
|
headers: list[str] = [c.get_text(strip=True) for c in header_cells]
|
2026-07-02 16:38:24 +08:00
|
|
|
|
|
2026-07-04 20:13:12 +08:00
|
|
|
|
# First body row: inspect each cell's <Data ss:Type="..."> to learn
|
|
|
|
|
|
# which columns hold strings (e.g. boolean Inp* as "true"/"false").
|
|
|
|
|
|
# Number-typed cells declare ss:Type="Number" (sometimes absent).
|
|
|
|
|
|
first_body_cells = rows[1].find_all("cell")
|
|
|
|
|
|
string_cols: set[int] = set()
|
|
|
|
|
|
for i, c in enumerate(first_body_cells):
|
|
|
|
|
|
data_tag = c.find("data")
|
|
|
|
|
|
if data_tag is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
ss_type = (data_tag.get("ss:type") or "").lower()
|
|
|
|
|
|
if ss_type == "string":
|
|
|
|
|
|
string_cols.add(i)
|
|
|
|
|
|
|
|
|
|
|
|
# Body rows: pull text. We don't trust per-row ss:Type for numeric
|
|
|
|
|
|
# columns because MT5 sometimes leaves it off; pandas' to_numeric
|
|
|
|
|
|
# is the authoritative check.
|
|
|
|
|
|
body: list[list[str]] = []
|
2026-07-02 16:38:24 +08:00
|
|
|
|
for r in rows[1:]:
|
|
|
|
|
|
cells = r.find_all("cell")
|
2026-07-04 20:13:12 +08:00
|
|
|
|
body.append([c.get_text(strip=True) for c in cells])
|
2026-07-02 16:38:24 +08:00
|
|
|
|
|
2026-07-04 20:13:12 +08:00
|
|
|
|
df = pd.DataFrame(body, columns=headers)
|
2026-07-02 16:38:24 +08:00
|
|
|
|
|
2026-07-04 20:13:12 +08:00
|
|
|
|
# Type inference driven by the first body row's ss:Type.
|
|
|
|
|
|
for i, col in enumerate(df.columns):
|
2026-07-02 16:38:24 +08:00
|
|
|
|
if col == "Pass":
|
2026-07-04 20:13:12 +08:00
|
|
|
|
# Pass is always an integer row id
|
2026-07-02 16:38:24 +08:00
|
|
|
|
df[col] = pd.to_numeric(df[col], errors="coerce").astype("Int64")
|
|
|
|
|
|
continue
|
2026-07-04 20:13:12 +08:00
|
|
|
|
if i in string_cols:
|
|
|
|
|
|
# ss:Type="String" — keep as raw text (e.g. "true"/"false").
|
2026-07-02 16:38:24 +08:00
|
|
|
|
df[col] = df[col].astype(str)
|
|
|
|
|
|
continue
|
2026-07-04 20:13:12 +08:00
|
|
|
|
# Numeric: try Int64 if every value is a whole number, else float64
|
|
|
|
|
|
coerced = pd.to_numeric(df[col], errors="coerce")
|
|
|
|
|
|
if pd.notna(coerced).all() and (coerced.dropna() % 1 == 0).all():
|
|
|
|
|
|
df[col] = coerced.astype("Int64")
|
|
|
|
|
|
else:
|
|
|
|
|
|
df[col] = coerced.astype("float64")
|
2026-07-02 16:38:24 +08:00
|
|
|
|
|
|
|
|
|
|
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]:
|
2026-07-04 20:13:12 +08:00
|
|
|
|
"""Input parameter columns the EA declared for optimization.
|
|
|
|
|
|
|
|
|
|
|
|
The MT5 export puts Pass + the 9 standard metrics first, then
|
|
|
|
|
|
every optimization parameter afterwards. We rely on column
|
|
|
|
|
|
position (everything after ``Trades``), NOT on a name prefix —
|
|
|
|
|
|
parameter names don't always start with ``Inp*`` in real exports.
|
|
|
|
|
|
"""
|
|
|
|
|
|
cols = list(df.columns)
|
|
|
|
|
|
if "Trades" in cols:
|
|
|
|
|
|
idx = cols.index("Trades")
|
|
|
|
|
|
return cols[idx + 1:]
|
|
|
|
|
|
# Fallback: no Trades column — assume nothing is a parameter
|
|
|
|
|
|
return []
|
2026-07-02 16:38:24 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-04 20:13:12 +08:00
|
|
|
|
# ── Outlier analysis (per-pass z-score over optimization passes) ─────
|
|
|
|
|
|
|
|
|
|
|
|
# Per-metric direction: True = higher-is-better (we look at z >= +sigma),
|
|
|
|
|
|
# False = lower-is-better (we look at z <= -sigma). Equity DD % is the
|
|
|
|
|
|
# only lower-is-better metric — low DD means the strategy is safer.
|
|
|
|
|
|
_PERF_METRICS_HIGHER_IS_BETTER = {
|
|
|
|
|
|
"Result": True,
|
|
|
|
|
|
"Profit": True,
|
|
|
|
|
|
"Expected Payoff": True,
|
|
|
|
|
|
"Profit Factor": True,
|
|
|
|
|
|
"Recovery Factor": True,
|
|
|
|
|
|
"Sharpe Ratio": True,
|
|
|
|
|
|
"Custom": True,
|
|
|
|
|
|
"Equity DD %": False,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _mean_std(values: list[float]) -> tuple[float, float]:
|
|
|
|
|
|
"""Return (mean, sample std N-1) of `values`. std=0 if N<2."""
|
|
|
|
|
|
n = len(values)
|
|
|
|
|
|
if n == 0:
|
|
|
|
|
|
return 0.0, 0.0
|
|
|
|
|
|
if n == 1:
|
|
|
|
|
|
return values[0], 0.0
|
|
|
|
|
|
mean = sum(values) / n
|
|
|
|
|
|
var = sum((x - mean) ** 2 for x in values) / (n - 1)
|
|
|
|
|
|
return mean, var ** 0.5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _z_scores(values: list[float]) -> list[float]:
|
|
|
|
|
|
"""Sample-std z-scores for `values`. z=0 when std=0."""
|
|
|
|
|
|
mean, std = _mean_std(values)
|
|
|
|
|
|
if std == 0:
|
|
|
|
|
|
return [0.0] * len(values)
|
|
|
|
|
|
return [(v - mean) / std for v in values]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _outlier_metrics_for_row(
|
|
|
|
|
|
z_by_metric: dict[str, float],
|
|
|
|
|
|
sigma: float,
|
|
|
|
|
|
) -> list[dict]:
|
|
|
|
|
|
"""Per-row outlier flags, in the "performance-strongly-strong" sense.
|
|
|
|
|
|
|
|
|
|
|
|
For each metric, the row is "strong" when its z is on the GOOD side
|
|
|
|
|
|
of the mean by at least `sigma` standard deviations:
|
|
|
|
|
|
|
|
|
|
|
|
- higher-is-better metric -> z >= sigma
|
|
|
|
|
|
- lower-is-better metric -> z <= -sigma (e.g. low DD is good)
|
|
|
|
|
|
|
|
|
|
|
|
Returns a list of {"metric": ..., "z": ...} entries, one per metric
|
|
|
|
|
|
that qualifies. Empty list means no performance metric is a
|
|
|
|
|
|
"strongly-strong" outlier.
|
|
|
|
|
|
"""
|
|
|
|
|
|
flags = []
|
|
|
|
|
|
for m, z in z_by_metric.items():
|
|
|
|
|
|
higher = _PERF_METRICS_HIGHER_IS_BETTER.get(m, True)
|
|
|
|
|
|
if higher and z >= sigma:
|
|
|
|
|
|
flags.append({"metric": m, "z": round(z, 2)})
|
|
|
|
|
|
elif (not higher) and z <= -sigma:
|
|
|
|
|
|
flags.append({"metric": m, "z": round(z, 2)})
|
|
|
|
|
|
return flags
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-07 11:37:07 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-04 20:13:12 +08:00
|
|
|
|
def find_outliers(
|
|
|
|
|
|
df: pd.DataFrame,
|
|
|
|
|
|
sigma: float = 2.0,
|
|
|
|
|
|
top_outliers: int = 10,
|
|
|
|
|
|
top_normal: int = 5,
|
2026-07-07 11:37:07 +08:00
|
|
|
|
sort_priority: list[str] | None = None,
|
2026-07-04 20:13:12 +08:00
|
|
|
|
) -> dict:
|
|
|
|
|
|
"""Per-pass z-score outlier scan across the 8 performance metrics.
|
|
|
|
|
|
|
|
|
|
|
|
Two disjoint pass sets are returned:
|
|
|
|
|
|
|
|
|
|
|
|
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 %:
|
2026-07-07 11:37:07 +08:00
|
|
|
|
z <= -sigma). Sorted by ``sort_priority``; top ``top_outliers`` shown.
|
2026-07-04 20:13:12 +08:00
|
|
|
|
|
|
|
|
|
|
set_normal — passes with NO performance-metric outlier. Sorted by
|
2026-07-07 11:37:07 +08:00
|
|
|
|
``sort_priority``; top ``top_normal`` shown.
|
2026-07-04 20:13:12 +08:00
|
|
|
|
|
|
|
|
|
|
Both sets EXCLUDE passes whose Trades count is itself a low-side
|
|
|
|
|
|
outlier (z <= -sigma). Such passes have too few trades to trust
|
|
|
|
|
|
their performance numbers — including them would inflate the
|
|
|
|
|
|
outlier set with cheap-pass artifacts.
|
|
|
|
|
|
|
|
|
|
|
|
Also returns the per-metric mean / std / sigma threshold table so
|
|
|
|
|
|
the user can judge whether the outlier counts are meaningful.
|
|
|
|
|
|
|
2026-07-07 11:37:07 +08:00
|
|
|
|
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.
|
2026-07-04 20:13:12 +08:00
|
|
|
|
"""
|
2026-07-07 11:37:07 +08:00
|
|
|
|
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)}
|
2026-07-04 20:13:12 +08:00
|
|
|
|
|
|
|
|
|
|
if df.empty:
|
|
|
|
|
|
out["error"] = "No pass rows"
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
# The 8 performance metrics (everything in METRIC_COLS except Trades)
|
|
|
|
|
|
perf_metrics = [c for c in METRIC_COLS if c in df.columns and c != "Trades"]
|
|
|
|
|
|
out["per_metric"] = {}
|
|
|
|
|
|
z_perf_by_row: list[dict[str, float]] = []
|
|
|
|
|
|
for m in perf_metrics:
|
|
|
|
|
|
vals = [float(v) for v in df[m].tolist()]
|
|
|
|
|
|
mean, std = _mean_std(vals)
|
|
|
|
|
|
zs = _z_scores(vals)
|
|
|
|
|
|
higher = _PERF_METRICS_HIGHER_IS_BETTER.get(m, True)
|
|
|
|
|
|
out["per_metric"][m] = {
|
|
|
|
|
|
"mean": round(mean, 4),
|
|
|
|
|
|
"std": round(std, 4),
|
|
|
|
|
|
"threshold_+sigma": round(mean + sigma * std, 4),
|
|
|
|
|
|
"threshold_-sigma": round(mean - sigma * std, 4),
|
|
|
|
|
|
"direction": "higher" if higher else "lower",
|
|
|
|
|
|
}
|
|
|
|
|
|
# Pad / align z rows with column index (defensive: pad if cols differ)
|
|
|
|
|
|
for i, z in enumerate(zs):
|
|
|
|
|
|
if i >= len(z_perf_by_row):
|
|
|
|
|
|
z_perf_by_row.append({})
|
|
|
|
|
|
z_perf_by_row[i][m] = z
|
|
|
|
|
|
|
|
|
|
|
|
# Trades: only used for the low-Trades exclusion filter
|
|
|
|
|
|
if "Trades" in df.columns:
|
|
|
|
|
|
trades_vals = [float(v) for v in df["Trades"].tolist()]
|
|
|
|
|
|
mean, std = _mean_std(trades_vals)
|
|
|
|
|
|
out["trades"] = {
|
|
|
|
|
|
"mean": round(mean, 2),
|
|
|
|
|
|
"std": round(std, 2),
|
|
|
|
|
|
"low_outlier_threshold": round(mean - sigma * std, 2),
|
|
|
|
|
|
}
|
|
|
|
|
|
trades_z = _z_scores(trades_vals)
|
|
|
|
|
|
else:
|
|
|
|
|
|
trades_z = [0.0] * len(df)
|
|
|
|
|
|
out["trades"] = None
|
|
|
|
|
|
|
|
|
|
|
|
pcols = _param_cols(df)
|
|
|
|
|
|
|
|
|
|
|
|
# Build per-row records; carry Pass / Result / metric values / z / params
|
|
|
|
|
|
rows = []
|
|
|
|
|
|
for idx, row in df.iterrows():
|
|
|
|
|
|
z_map = z_perf_by_row[idx] if idx < len(z_perf_by_row) else {}
|
|
|
|
|
|
rec = {
|
|
|
|
|
|
"Pass": int(row["Pass"]) if "Pass" in df.columns and pd.notna(row["Pass"]) else None,
|
|
|
|
|
|
"Result": float(row["Result"]) if "Result" in df.columns and pd.notna(row["Result"]) else None,
|
|
|
|
|
|
"perf_metrics": {m: float(row[m]) for m in perf_metrics if pd.notna(row[m])},
|
|
|
|
|
|
"z_metrics": {m: round(z_map.get(m, 0.0), 2) for m in perf_metrics},
|
|
|
|
|
|
"Trades": int(row["Trades"]) if "Trades" in df.columns and pd.notna(row["Trades"]) else None,
|
|
|
|
|
|
"Trades_z": round(trades_z[idx], 2) if idx < len(trades_z) else 0.0,
|
|
|
|
|
|
"params": {p: (row[p] if pd.notna(row[p]) else None) for p in pcols},
|
|
|
|
|
|
}
|
|
|
|
|
|
rec["outliers"] = _outlier_metrics_for_row(z_map, sigma)
|
|
|
|
|
|
rows.append(rec)
|
|
|
|
|
|
|
|
|
|
|
|
# Exclusion: passes with Trades z <= -sigma — too few trades to trust.
|
|
|
|
|
|
excluded = [r for r in rows if r["Trades_z"] <= -sigma]
|
|
|
|
|
|
eligible = [r for r in rows if r["Trades_z"] > -sigma]
|
|
|
|
|
|
out["excluded"] = [
|
|
|
|
|
|
{"Pass": r["Pass"], "Trades": r["Trades"], "Trades_z": r["Trades_z"]}
|
|
|
|
|
|
for r in excluded
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-07-07 11:37:07 +08:00
|
|
|
|
# Sort eligible passes by the configured priority chain
|
|
|
|
|
|
eligible.sort(key=lambda r: _sort_key(r, sort_priority))
|
2026-07-04 20:13:12 +08:00
|
|
|
|
|
|
|
|
|
|
# Set A: at least one performance-metric outlier
|
|
|
|
|
|
set_a = [r for r in eligible if r["outliers"]]
|
|
|
|
|
|
# Set B: no performance-metric outlier
|
|
|
|
|
|
set_b = [r for r in eligible if not r["outliers"]]
|
|
|
|
|
|
|
|
|
|
|
|
out["set_outliers"] = set_a[:top_outliers]
|
|
|
|
|
|
out["set_normal"] = set_b[:top_normal]
|
|
|
|
|
|
out["counts"] = {
|
|
|
|
|
|
"outliers": len(set_a),
|
|
|
|
|
|
"normal": len(set_b),
|
|
|
|
|
|
"excluded_low_trades": len(excluded),
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fmt_outlier_row(rec: dict, pcols: list[str]) -> str:
|
|
|
|
|
|
"""One-pass outlier record as two compact lines (metrics, params).
|
|
|
|
|
|
|
|
|
|
|
|
Layout:
|
|
|
|
|
|
Pass 42 | Result=2.31 | Profit=800.10 EP=4.55 PF=1.61 RF=4.55 ... | z: Profit(+2.4σ), Sharpe(+2.1σ)
|
|
|
|
|
|
params: InpSLPips=30, InpTPPips=180, InpUseNewsFilter=true
|
|
|
|
|
|
"""
|
|
|
|
|
|
out_bits = []
|
|
|
|
|
|
pass_str = f"Pass {rec['Pass']}" if rec["Pass"] is not None else "Pass ?"
|
|
|
|
|
|
res_str = f"Result={rec['Result']:.2f}" if rec["Result"] is not None else "Result=?"
|
|
|
|
|
|
out_bits.append(f" {pass_str:<10} {res_str}")
|
|
|
|
|
|
|
|
|
|
|
|
# Metric values (in canonical METRIC_COLS order minus Trades)
|
|
|
|
|
|
metric_bits = []
|
|
|
|
|
|
for m in METRIC_COLS:
|
|
|
|
|
|
if m == "Trades":
|
|
|
|
|
|
continue
|
|
|
|
|
|
if m not in rec["perf_metrics"]:
|
|
|
|
|
|
continue
|
|
|
|
|
|
v = rec["perf_metrics"][m]
|
|
|
|
|
|
# Short labels for readability
|
|
|
|
|
|
short = {
|
|
|
|
|
|
"Result": "Res",
|
|
|
|
|
|
"Profit": "Prof",
|
|
|
|
|
|
"Expected Payoff": "EP",
|
|
|
|
|
|
"Profit Factor": "PF",
|
|
|
|
|
|
"Recovery Factor": "RF",
|
|
|
|
|
|
"Sharpe Ratio": "Sharpe",
|
|
|
|
|
|
"Custom": "Cust",
|
|
|
|
|
|
"Equity DD %": "EqDD%",
|
|
|
|
|
|
}.get(m, m)
|
|
|
|
|
|
metric_bits.append(f"{short}={v:.2f}")
|
|
|
|
|
|
trades_part = f" Trades={rec['Trades']}" if rec["Trades"] is not None else ""
|
|
|
|
|
|
out_bits.append(" Metrics: " + " ".join(metric_bits) + trades_part)
|
|
|
|
|
|
|
|
|
|
|
|
# Outlier annotations: only the metrics that crossed the threshold
|
|
|
|
|
|
if rec["outliers"]:
|
|
|
|
|
|
annot = ", ".join(
|
|
|
|
|
|
f"{o['metric']}({o['z']:+.2f}σ)" for o in rec["outliers"]
|
|
|
|
|
|
)
|
|
|
|
|
|
out_bits.append(f" Outliers: {annot}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
out_bits.append(" Outliers: (none)")
|
|
|
|
|
|
|
|
|
|
|
|
# Input parameters — keep all of them, one per line if many
|
|
|
|
|
|
if pcols:
|
|
|
|
|
|
param_parts = []
|
|
|
|
|
|
for p in pcols:
|
|
|
|
|
|
val = rec["params"].get(p)
|
|
|
|
|
|
if isinstance(val, float) and val.is_integer():
|
|
|
|
|
|
val = int(val)
|
|
|
|
|
|
param_parts.append(f"{p}={val}")
|
|
|
|
|
|
# Wrap to <=4 params per line for readability
|
|
|
|
|
|
lines = []
|
|
|
|
|
|
cur = []
|
|
|
|
|
|
cur_len = 0
|
|
|
|
|
|
for part in param_parts:
|
|
|
|
|
|
if cur and cur_len + len(part) + 2 > 70:
|
|
|
|
|
|
lines.append(", ".join(cur))
|
|
|
|
|
|
cur = [part]
|
|
|
|
|
|
cur_len = len(part)
|
|
|
|
|
|
else:
|
|
|
|
|
|
cur.append(part)
|
|
|
|
|
|
cur_len += len(part) + 2
|
|
|
|
|
|
if cur:
|
|
|
|
|
|
lines.append(", ".join(cur))
|
|
|
|
|
|
out_bits.append(" Params: " + lines[0])
|
|
|
|
|
|
for extra in lines[1:]:
|
|
|
|
|
|
out_bits.append(" " + extra)
|
|
|
|
|
|
|
|
|
|
|
|
return "\n".join(out_bits)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-07 11:37:07 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-04 20:13:12 +08:00
|
|
|
|
def print_outliers(env: Env, df: pd.DataFrame, out: dict) -> None:
|
|
|
|
|
|
"""Pretty-print the outlier scan as a text report.
|
|
|
|
|
|
|
|
|
|
|
|
Layout follows the windows subcommand style: header card, then
|
|
|
|
|
|
a per-metric reference table (mean / std / sigma threshold),
|
|
|
|
|
|
then the two pass sets grouped by [Metrics] and [Params].
|
|
|
|
|
|
"""
|
|
|
|
|
|
print("=" * 78)
|
|
|
|
|
|
print(f" Optimization Outlier Scan (per-pass z-score across {out.get('n_passes', '?')} passes)")
|
|
|
|
|
|
print("=" * 78)
|
|
|
|
|
|
print(f" EA: {env.ea_name or '(unknown)'} Symbol: {env.symbol or '(unknown)'} "
|
|
|
|
|
|
f"Period: {env.period or '(unknown)'}")
|
|
|
|
|
|
print(f" Date range: {env.date_from} → {env.date_to}")
|
|
|
|
|
|
print(f" Sigma threshold: {out['sigma']:.1f} "
|
|
|
|
|
|
f"(direction: higher-is-better uses z>=+σ; Equity DD % uses z<=-σ)")
|
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
|
|
if "error" in out:
|
|
|
|
|
|
print(f" ERROR: {out['error']}")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
# Per-metric reference table
|
|
|
|
|
|
print(f" {'Metric':<22} {'Dir':>5} {'Mean':>12} {'Std':>10} "
|
|
|
|
|
|
f"{'+σ threshold':>14} {'-σ threshold':>14}")
|
|
|
|
|
|
print(" " + "─" * 80)
|
|
|
|
|
|
for m in METRIC_COLS:
|
|
|
|
|
|
if m == "Trades" or m not in out["per_metric"]:
|
|
|
|
|
|
continue
|
|
|
|
|
|
info = out["per_metric"][m]
|
|
|
|
|
|
print(f" {m:<22} {info['direction']:>5} "
|
|
|
|
|
|
f"{info['mean']:>12.4f} {info['std']:>10.4f} "
|
|
|
|
|
|
f"{info['threshold_+sigma']:>14.4f} {info['threshold_-sigma']:>14.4f}")
|
|
|
|
|
|
if out.get("trades"):
|
|
|
|
|
|
t = out["trades"]
|
|
|
|
|
|
print(f" {'Trades (excluded)':<22} {'low':>5} "
|
|
|
|
|
|
f"{t['mean']:>12.2f} {t['std']:>10.2f} "
|
|
|
|
|
|
f"{'--':>14} {t['low_outlier_threshold']:>14.2f}")
|
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
|
|
# Counts
|
|
|
|
|
|
c = out["counts"]
|
2026-07-07 11:37:07 +08:00
|
|
|
|
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")
|
2026-07-04 20:13:12 +08:00
|
|
|
|
if c["excluded_low_trades"]:
|
|
|
|
|
|
print(f" Excluded (Trades z<=-σ, too few trades to trust): {c['excluded_low_trades']} passes")
|
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
|
|
pcols = _param_cols(df)
|
|
|
|
|
|
|
|
|
|
|
|
# Set A
|
|
|
|
|
|
if out["set_outliers"]:
|
|
|
|
|
|
print("=" * 78)
|
|
|
|
|
|
print(f" SET A — Passes with at least one performance-metric outlier")
|
2026-07-07 11:37:07 +08:00
|
|
|
|
print(f" (top {len(out['set_outliers'])}; "
|
|
|
|
|
|
f"sort: {sort_fmt}; {c['outliers']} total eligible)")
|
2026-07-04 20:13:12 +08:00
|
|
|
|
print("=" * 78)
|
|
|
|
|
|
for rec in out["set_outliers"]:
|
|
|
|
|
|
print(_fmt_outlier_row(rec, pcols))
|
|
|
|
|
|
print()
|
|
|
|
|
|
else:
|
|
|
|
|
|
print(" SET A: empty (no eligible passes have a performance-metric outlier)")
|
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
|
|
# Set B
|
|
|
|
|
|
if out["set_normal"]:
|
|
|
|
|
|
print("=" * 78)
|
2026-07-07 11:37:07 +08:00
|
|
|
|
print(f" SET B — Passes with no performance-metric outlier "
|
|
|
|
|
|
f"(top {len(out['set_normal'])}; sort: {sort_fmt})")
|
2026-07-04 20:13:12 +08:00
|
|
|
|
print("=" * 78)
|
|
|
|
|
|
for rec in out["set_normal"]:
|
|
|
|
|
|
print(_fmt_outlier_row(rec, pcols))
|
|
|
|
|
|
print()
|
|
|
|
|
|
else:
|
|
|
|
|
|
print(" SET B: empty (every eligible pass has at least one performance-metric outlier)")
|
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
|
|
# Excluded (low-Trades)
|
|
|
|
|
|
if out["excluded"]:
|
|
|
|
|
|
print("=" * 78)
|
|
|
|
|
|
print(f" EXCLUDED — Passes with Trades z <= -{out['sigma']:.0f}σ (too few trades to trust)")
|
|
|
|
|
|
print("=" * 78)
|
|
|
|
|
|
for e in out["excluded"]:
|
|
|
|
|
|
print(f" Pass {e['Pass']:<6} Trades={e['Trades']:<5} Trades_z={e['Trades_z']:+.2f}σ")
|
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
|
|
# Interpretation
|
|
|
|
|
|
print(" Interpretation:")
|
|
|
|
|
|
print(f" z = (this pass's value − mean across all passes) / std.")
|
|
|
|
|
|
print(f" For higher-is-better metrics, the outlier criterion is z >= +{out['sigma']:.0f}.")
|
|
|
|
|
|
print(f" For lower-is-better (Equity DD %), the criterion is z <= -{out['sigma']:.0f}.")
|
|
|
|
|
|
print(f" The Trades exclusion guards against over-fitting: a pass with")
|
|
|
|
|
|
print(f" very few trades can post an extreme metric value purely by")
|
|
|
|
|
|
print(f" luck, so we drop those before ranking the rest.")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-02 16:38:24 +08:00
|
|
|
|
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(
|
2026-07-04 20:13:12 +08:00
|
|
|
|
description="Parse MT5 Strategy Tester Optimization XML report",
|
|
|
|
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
|
|
|
|
epilog="""
|
|
|
|
|
|
Examples:
|
|
|
|
|
|
# Text report (default)
|
|
|
|
|
|
python %(prog)s ReportOptimizer-*.xml
|
|
|
|
|
|
|
|
|
|
|
|
# JSON dump (raw parsed data)
|
|
|
|
|
|
python %(prog)s ReportOptimizer-*.xml --json
|
|
|
|
|
|
|
|
|
|
|
|
# Full optimization analysis (parameter effect, duplicates, best passes)
|
|
|
|
|
|
python %(prog)s ReportOptimizer-*.xml --analyze
|
|
|
|
|
|
|
|
|
|
|
|
# Outlier scan: per-pass z-score on the 8 perf metrics,
|
|
|
|
|
|
# split into "has at least one strong outlier" vs "no outlier"
|
|
|
|
|
|
python %(prog)s ReportOptimizer-*.xml outliers
|
|
|
|
|
|
python %(prog)s ReportOptimizer-*.xml outliers --top-outliers 5 --top-normal 5 --sigma 2
|
|
|
|
|
|
python %(prog)s ReportOptimizer-*.xml outliers --json
|
|
|
|
|
|
""",
|
2026-07-02 16:38:24 +08:00
|
|
|
|
)
|
|
|
|
|
|
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)",
|
|
|
|
|
|
)
|
2026-07-04 20:13:12 +08:00
|
|
|
|
sub = parser.add_subparsers(dest="cmd")
|
|
|
|
|
|
|
|
|
|
|
|
p_out = sub.add_parser(
|
|
|
|
|
|
"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|>=σ "
|
2026-07-07 11:37:07 +08:00
|
|
|
|
"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.",
|
2026-07-04 20:13:12 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-07 11:37:07 +08:00
|
|
|
|
# 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
|
|
|
|
|
|
|
2026-07-04 20:13:12 +08:00
|
|
|
|
# Tighter sigma threshold and custom top-N
|
|
|
|
|
|
python parse_optimizer_report.py ReportOptimizer-*.xml outliers --sigma 3 --top-outliers 5
|
|
|
|
|
|
|
|
|
|
|
|
# JSON output for further processing
|
|
|
|
|
|
python parse_optimizer_report.py ReportOptimizer-*.xml outliers --json
|
|
|
|
|
|
|
|
|
|
|
|
For higher-is-better metrics (Profit, Profit Factor, Recovery Factor,
|
|
|
|
|
|
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.
|
2026-07-07 11:37:07 +08:00
|
|
|
|
|
|
|
|
|
|
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)
|
2026-07-04 20:13:12 +08:00
|
|
|
|
""",
|
|
|
|
|
|
)
|
|
|
|
|
|
p_out.add_argument(
|
|
|
|
|
|
"--sigma", "-s", type=float, default=2.0,
|
|
|
|
|
|
help="z-score threshold for the outlier scan (default: 2.0)",
|
|
|
|
|
|
)
|
|
|
|
|
|
p_out.add_argument(
|
|
|
|
|
|
"--top-outliers", type=int, default=10,
|
|
|
|
|
|
help="Number of passes to show from the 'has at least one outlier' set (default: 10)",
|
|
|
|
|
|
)
|
|
|
|
|
|
p_out.add_argument(
|
|
|
|
|
|
"--top-normal", type=int, default=5,
|
|
|
|
|
|
help="Number of passes to show from the 'no outlier' set (default: 5)",
|
|
|
|
|
|
)
|
2026-07-07 11:37:07 +08:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
2026-07-04 20:13:12 +08:00
|
|
|
|
p_out.add_argument(
|
|
|
|
|
|
"--json", action="store_true", help="Output outliers data as JSON",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-02 16:38:24 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-04 20:13:12 +08:00
|
|
|
|
if args.cmd == "outliers":
|
|
|
|
|
|
out_data = find_outliers(
|
|
|
|
|
|
df,
|
|
|
|
|
|
sigma=args.sigma,
|
|
|
|
|
|
top_outliers=args.top_outliers,
|
|
|
|
|
|
top_normal=args.top_normal,
|
2026-07-07 11:37:07 +08:00
|
|
|
|
sort_priority=_parse_sort_priority(args.sort),
|
2026-07-04 20:13:12 +08:00
|
|
|
|
)
|
|
|
|
|
|
if getattr(args, "json", False):
|
|
|
|
|
|
payload = {
|
|
|
|
|
|
"env": asdict(env),
|
|
|
|
|
|
"pass_count": int(len(df)),
|
|
|
|
|
|
"columns": list(df.columns),
|
|
|
|
|
|
"outliers": out_data,
|
|
|
|
|
|
}
|
|
|
|
|
|
print(json.dumps(payload, indent=2, ensure_ascii=False, default=str))
|
|
|
|
|
|
else:
|
|
|
|
|
|
print_outliers(env, df, out_data)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-07-02 16:38:24 +08:00
|
|
|
|
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()
|