Files
LEGSTECH Optimizer 7a3e13a734 Initial commit: MT5 EA Optimizer v1.0
Full optimization system for LEGSTECH_EA_V2:
- Flask + SocketIO live dashboard (dark premium UI)
- MT5 process control (auto-kill, clean launch, retry)
- HTML report parser (UTF-16 LE, 597 trades, metrics)
- Pre-run validation and actionable error messages
- Analysis engines: Reversal, TimePerfomance, EntryExit, EquityCurve
- Composite scoring (Calmar-primary)
- Mutation engine with knowledge_base.yaml
- Validation gate: IS + Walk-Forward
- Reports folder with HTML/CSV per run
- Double-click launcher batch file
2026-04-13 02:28:09 +00:00

175 lines
7.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
scoring/composite.py
Composite score function for LEGSTECH_EA_V2 / XAUUSD optimization.
Primary: Calmar Ratio | Secondary: PF, MFE capture, session stability, recovery.
"""
from __future__ import annotations
from typing import Optional
import numpy as np
import yaml
from loguru import logger
from data.models import RunMetrics
def _clip_normalize(value: float, lo: float, hi: float) -> float:
"""Normalize value to [0, 1] by clipping to [lo, hi]."""
if hi <= lo:
return 0.0
return float(np.clip((value - lo) / (hi - lo), 0.0, 1.0))
def _session_stability(session_stats: dict) -> float:
"""
Compute session stability as 1 - (std of per-session Calmar / mean Calmar).
Returns 1.0 (perfectly stable) if only one session or no variance.
session_stats: {session_name: {"calmar": float, "trade_count": int}}
"""
calmars = [
v["calmar"] for v in session_stats.values()
if v.get("trade_count", 0) >= 10 and "calmar" in v
]
if len(calmars) < 2:
return 1.0 # not enough sessions to measure stability
mean_c = np.mean(calmars)
std_c = np.std(calmars)
if abs(mean_c) < 0.01:
return 0.0
cv = std_c / abs(mean_c) # coefficient of variation
return float(np.clip(1.0 - cv, 0.0, 1.0))
class CompositeScorer:
"""
Weighted composite score calculator.
All sub-scores are independently normalized to [0, 1].
Two multiplicative penalties are applied after weighting:
- Significance penalty: reduces weight for low trade counts
- Reversal penalty : reduces score if reversal rate is high
"""
DEFAULT_WEIGHTS = {
"calmar": 0.35,
"profit_factor": 0.20,
"mfe_capture": 0.20,
"session_stability": 0.15,
"recovery_factor": 0.10,
}
DEFAULT_NORMALIZATION = {
"calmar": {"lo": 0.0, "hi": 4.0},
"profit_factor": {"lo": 1.0, "hi": 3.5},
"mfe_capture": {"lo": 0.0, "hi": 1.0},
"session_stability": {"lo": 0.0, "hi": 1.0},
"recovery_factor": {"lo": 0.0, "hi": 6.0},
}
def __init__(self, config_path: str = "config.yaml"):
try:
with open(config_path) as f:
cfg = yaml.safe_load(f)
scoring_cfg = cfg.get("scoring", {})
self.weights = scoring_cfg.get("weights", self.DEFAULT_WEIGHTS)
norm_cfg = scoring_cfg.get("normalization", {})
self.norm = {
k: norm_cfg.get(k, self.DEFAULT_NORMALIZATION.get(k, {"lo": 0, "hi": 1}))
for k in self.DEFAULT_WEIGHTS
}
self.min_trades = cfg["thresholds"]["min_trades"]
self.significance_at = cfg["scoring"].get("significance_trades", 150)
except Exception as e:
logger.warning(f"Could not load scoring config: {e}. Using defaults.")
self.weights = self.DEFAULT_WEIGHTS
self.norm = self.DEFAULT_NORMALIZATION
self.min_trades = 50
self.significance_at = 150
def score(
self,
metrics: RunMetrics,
session_stats: Optional[dict] = None,
) -> float:
"""
Compute composite score for a RunMetrics object.
Returns 0.0 if minimum trade count is not met.
Args:
metrics: RunMetrics from the parsed backtest report
session_stats: Optional {session: {calmar, trade_count, ...}} for stability
"""
if metrics.total_trades < self.min_trades:
logger.debug(
f"Score=0 (trades {metrics.total_trades} < min {self.min_trades})"
)
return 0.0
# ── Sub-scores ────────────────────────────────────────────────────────
calmar_score = _clip_normalize(
metrics.calmar_ratio,
**self.norm["calmar"]
)
pf_score = _clip_normalize(
metrics.profit_factor,
**self.norm["profit_factor"]
)
capture_score = _clip_normalize(
metrics.avg_mfe_capture if metrics.avg_mfe_capture is not None else 0.5,
**self.norm["mfe_capture"]
)
stab_score = _clip_normalize(
_session_stability(session_stats or {}),
**self.norm["session_stability"]
)
recovery_score = _clip_normalize(
metrics.recovery_factor,
**self.norm["recovery_factor"]
)
# ── Weighted sum ──────────────────────────────────────────────────────
raw = (
self.weights["calmar"] * calmar_score +
self.weights["profit_factor"] * pf_score +
self.weights["mfe_capture"] * capture_score +
self.weights["session_stability"] * stab_score +
self.weights["recovery_factor"] * recovery_score
)
# ── Penalties ─────────────────────────────────────────────────────────
# Significance: scale up to 1.0 as trade count reaches significance_at
significance = min(1.0, metrics.total_trades / self.significance_at)
# Reversal penalty: each 10% reversal rate removes 20% of score
reversal_penalty = 1.0
if metrics.reversal_rate is not None:
reversal_penalty = max(0.0, 1.0 - metrics.reversal_rate * 2.0)
final = raw * significance * reversal_penalty
logger.debug(
f"Score breakdown: calmar={calmar_score:.3f} pf={pf_score:.3f} "
f"capture={capture_score:.3f} stab={stab_score:.3f} "
f"recovery={recovery_score:.3f} → raw={raw:.3f} "
f"× sig={significance:.3f} × rev={reversal_penalty:.3f} = {final:.4f}"
)
return round(final, 6)
def breakdown(
self,
metrics: RunMetrics,
session_stats: Optional[dict] = None,
) -> dict[str, float]:
"""Return the full breakdown of sub-scores for display."""
return {
"calmar_score": _clip_normalize(metrics.calmar_ratio, **self.norm["calmar"]),
"pf_score": _clip_normalize(metrics.profit_factor, **self.norm["profit_factor"]),
"capture_score": _clip_normalize(
metrics.avg_mfe_capture or 0.5, **self.norm["mfe_capture"]),
"stability_score": _clip_normalize(
_session_stability(session_stats or {}), **self.norm["session_stability"]),
"recovery_score": _clip_normalize(metrics.recovery_factor, **self.norm["recovery_factor"]),
"significance": min(1.0, metrics.total_trades / self.significance_at),
"reversal_penalty":max(0.0, 1.0 - (metrics.reversal_rate or 0) * 2),
"final": self.score(metrics, session_stats),
}