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
This commit is contained in:
LEGSTECH Optimizer
2026-04-13 02:28:09 +00:00
commit 7a3e13a734
40 changed files with 7182 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
# ── Python ────────────────────────────────────────────────────────────────────
__pycache__/
*.py[cod]
*.pyd
*.pyo
*.egg-info/
*.egg
.eggs/
dist/
build/
*.spec
# ── Virtual Environments ──────────────────────────────────────────────────────
venv/
env/
.env
.venv/
# ── MT5 Optimizer Runtime Files ───────────────────────────────────────────────
# Don't commit generated run data or reports (can be large)
runs/
Reports/
# Keep the optimizer database (optional — remove this line to commit it)
optimizer.db
# ── Sensitive Config (DO NOT COMMIT BROKER CREDENTIALS) ──────────────────────
# config.yaml contains MT5 paths — safe to commit but exclude if it had passwords
# config.secret.yaml
# ── Logs ──────────────────────────────────────────────────────────────────────
*.log
logs/
# ── OS Files ──────────────────────────────────────────────────────────────────
.DS_Store
Thumbs.db
desktop.ini
# ── IDE ───────────────────────────────────────────────────────────────────────
.vscode/
.idea/
*.swp
# ── PyInstaller ───────────────────────────────────────────────────────────────
*.exe
*.zip
dist/
# ── Data files ────────────────────────────────────────────────────────────────
*.parquet
*.csv
+23
View File
@@ -0,0 +1,23 @@
@echo off
REM ── MT5 EA Optimizer Launcher ──────────────────────────────────────────────
REM Double-click this file to start the optimizer app.
REM Browser will open automatically.
title MT5 EA Optimizer
SET PY=C:\Users\DELL\AppData\Local\Programs\Python\Python311\python.exe
SET APP=%~dp0app.py
echo.
echo =====================================================
echo MT5 EA Optimizer — Starting...
echo Your browser will open in a few seconds.
echo Do NOT close this window while optimizer is running.
echo =====================================================
echo.
"%PY%" "%APP%"
echo.
echo Optimizer stopped. Press any key to close.
pause > nul
+349
View File
@@ -0,0 +1,349 @@
# MT5 EA Optimizer — Full Project Handoff Document
**Last Updated:** 2026-04-13
**Status:** Phase 1 complete (backend engine). Phase 2 (GUI app) = NEXT STEP
**Primary EA:** LEGSTECH_EA_V2 | Symbol: XAUUSD | Timeframe: H1
**Broker Timezone:** UTC+2
---
## 🎯 Project Goal
Build an automated, iterative backtesting and analysis system for MT5 Expert Advisors.
It is NOT a trading bot. It is an **optimization engine** that:
- Runs MT5 strategy tester automatically with different parameter sets
- Extracts rich trade-level data (MAE/MFE) after each run
- Analyzes results to find failure patterns
- Proposes and tests parameter mutations
- Validates improvements before accepting them
- Prevents overfitting via Walk-Forward + Out-of-Sample testing
**End user:** Traders (non-technical). Must be a double-click app, not a terminal tool.
---
## 🏗️ Architecture Overview
```
MT5 Terminal (GUI)
└── Strategy Tester (automated via .ini files)
└── LEGSTECH_EA_V2.ex5 (compiled EA with TradeLogger)
└── Writes: LEGSTECH_EA_V2_XAUUSD_TradeLog.csv
Python Optimizer (backend engine)
├── MT5 Runner → launch terminal, wait for report, collect files
├── Report Parser → parse MT5 XML/HTML report into metrics + trades
├── Log Reader → merge TradeLogger CSV (MAE/MFE) into trade objects
├── Analysis Engine → 4 modules detecting failure patterns
│ ├── ReversalAnalyzer → trades that went in profit then reversed
│ ├── TimePerformanceAnalyzer → bad sessions/hours/days
│ ├── EntryExitQualityAnalyzer → MAE/MFE quality scores
│ └── EquityCurveAnalyzer → flatness, loss clusters, R²
├── Composite Scorer → Calmar-primary weighted score (0-1)
├── Mutation Engine → findings → parameter hypotheses (13 KB rules)
├── Validation Gate → IS check → Walk-Forward → OOS
└── Data Store → SQLite (metadata) + Parquet (trade arrays)
Web App (NEXT TO BUILD — Phase 2)
├── Flask backend → serves UI, runs optimization loop
├── WebSocket → pushes live updates to browser
├── HTML/JS frontend → live dashboard, charts, findings
└── Reports folder → HTML + CSV reports per run
```
---
## 📁 File Structure (Current State)
```
C:\Users\DELL\Desktop\MT5_Optimizer\
├── main.py ← CLI entry point (terminal-based, to be replaced by app.py)
├── config.yaml ← ALL settings (MT5 paths, symbols, scoring weights, thresholds)
├── requirements.txt ← Python dependencies
├── PROJECT_HANDOFF.md ← THIS DOCUMENT
├── mql5/
│ └── TradeLogger.mqh ← MQL5 include file (ALREADY DEPLOYED to MT5)
├── data/
│ ├── models.py ← Pydantic v2 models: Trade, RunMetrics, Run, Finding, Hypothesis, Candidate
│ └── store.py ← DataStore: SQLite + Parquet storage layer
├── mt5/
│ ├── ini_builder.py ← Builds MT5 tester .ini files from param dicts
│ ├── runner.py ← Launches MT5 subprocess, waits for report
│ ├── report_parser.py ← Parses MT5 XML/HTML report → RunMetrics + list[Trade]
│ └── log_reader.py ← Merges TradeLogger CSV, computes sessions/quality scores
├── analysis/
│ ├── base.py ← BaseAnalyzer ABC + statistical helpers
│ ├── reversal.py ← ReversalAnalyzer
│ ├── time_performance.py ← TimePerformanceAnalyzer
│ ├── entry_exit_quality.py ← EntryExitQualityAnalyzer
│ └── equity_curve.py ← EquityCurveAnalyzer
├── scoring/
│ └── composite.py ← CompositeScorer (Calmar + PF + MFE capture + stability + recovery)
├── mutation/
│ ├── engine.py ← MutationEngine: findings → Hypothesis objects
│ ├── knowledge_base.yaml ← 13 rules mapping findings to param changes
│ └── param_manifest.yaml ← Full EA parameter space with types/bounds/defaults
├── validation/
│ └── gate.py ← IS check + Walk-Forward + OOS + sensitivity check
└── tests/
└── test_analyzers.py ← 10 unit tests (all passing ✅)
```
---
## ⚙️ Configuration (config.yaml) — Key Values
```yaml
ea:
name: "LEGSTECH_EA_V2"
symbol: "XAUUSD"
timeframe: "H1"
periods:
train_start: "2022.01.01"
train_end: "2023.12.31"
validate_start: "2024.01.01"
validate_end: "2024.06.30"
oos_start: "2024.07.01" # LOCKED — never touch during optimization
oos_end: "2024.12.31"
mt5:
terminal_exe: "C:/Program Files/MetaTrader 5/terminal64.exe"
appdata_path: "C:/Users/DELL/AppData/Roaming/MetaQuotes/Terminal/D0E8209F77C8CF37AD8BF550E51FF075"
mql5_files_path: "C:/Users/DELL/AppData/Roaming/MetaQuotes/Tester/D0E8209F77C8CF37AD8BF550E51FF075/Agent-127.0.0.1-3000/MQL5/Files"
broker:
timezone_offset_hours: 2 # UTC+2
scoring:
weights:
calmar: 0.35
profit_factor: 0.20
mfe_capture: 0.20
session_stability: 0.15
recovery_factor: 0.10
```
---
## ✅ What is Done (Phase 1 — Backend Engine)
| Component | Status | Notes |
|---|---|---|
| TradeLogger.mqh | ✅ Complete + deployed | In MT5 Include folder, tested, CSV confirmed working |
| data/models.py | ✅ Complete | All Pydantic v2 models |
| data/store.py | ✅ Complete | SQLite + Parquet CRUD |
| mt5/ini_builder.py | ✅ Complete | Generates valid MT5 tester INI files |
| mt5/runner.py | ✅ Complete | Process launch + polling + timeout |
| mt5/report_parser.py | ✅ Complete | XML + HTML dual-format parser |
| mt5/log_reader.py | ✅ Complete | MAE/MFE merge + session classification |
| analysis/reversal.py | ✅ Complete | Permutation-tested |
| analysis/time_performance.py | ✅ Complete | Hour/Session/Day analysis |
| analysis/entry_exit_quality.py | ✅ Complete | 4-case diagnosis matrix |
| analysis/equity_curve.py | ✅ Complete | Flatness + R² + loss clusters |
| scoring/composite.py | ✅ Complete | Calmar-primary weighted scorer |
| mutation/engine.py | ✅ Complete | 13 KB rules, dedup, cascade |
| validation/gate.py | ✅ Complete | IS + WFV + OOS + sensitivity |
| main.py | ✅ Complete | Terminal CLI (will be replaced by app) |
| tests/ | ✅ 10/10 passing | Pure Python, no MT5 needed |
| Unit tests verified | ✅ Working | Python 3.11 required (see below) |
---
## 🔴 What is NOT Done Yet (Phase 2 — GUI App)
### The Big Next Step: Web App with Live Dashboard
**Goal:** Replace `main.py` terminal interface with a beautiful browser-based app that:
1. **Double-click `MT5_Optimizer.exe`** → browser opens automatically at `http://localhost:5000`
2. **Dashboard shows live:**
- Iteration counter + current phase badge
- Score history line chart (Calmar + composite over time)
- Live MT5 run status with elapsed timer
- Current parameters being tested
3. **Analysis panel shows** findings in plain English after each run
4. **Parameter changes panel** shows before → after with reason
5. **Results folder** `MT5_Optimizer\Reports\` gets:
- `run_001\summary.html` — full backtest result in readable format
- `run_001\trades.csv` — all trades with MAE/MFE
- `run_001\findings.csv` — analysis findings
- `run_001\parameters.json` — params used
6. **Controls:** Start, Pause, Skip Hypothesis, View Report buttons
7. **PyInstaller** bundles into single `MT5_Optimizer.exe`
### Tech Stack for Phase 2
```
Flask + Flask-SocketIO → backend API + WebSocket push
Chart.js or Plotly.js → charts in browser
Bootstrap 5 (dark theme) → UI framework
Jinja2 → HTML report templates
PyInstaller → package to .exe
```
### Files to create in Phase 2
```
app.py ← Flask app entry point (replaces main.py)
ui/
templates/
index.html ← Main dashboard
report.html ← Per-run report template
findings.html ← Findings detail page
static/
css/style.css
js/dashboard.js ← WebSocket + Chart.js logic
reports/ ← All run outputs go here (user-visible)
MT5_Optimizer.spec ← PyInstaller spec file
build.bat ← One-click build to .exe
```
---
## 🔧 Environment & Dependencies
**Python version:** 3.11 (NOT 3.13 — use `C:\Users\DELL\AppData\Local\Programs\Python\Python311\python.exe`)
**Install command:**
```powershell
C:\Users\DELL\AppData\Local\Programs\Python\Python311\python.exe -m pip install -r requirements.txt
```
**Run tests:**
```powershell
cd C:\Users\DELL\Desktop\MT5_Optimizer
C:\Users\DELL\AppData\Local\Programs\Python\Python311\python.exe -m pytest tests/ -v
```
**Key dependency versions:**
```
pydantic>=2.5, pandas>=2.1, pyarrow>=14.0, lxml>=4.9
loguru>=0.7, rich>=13.0, scipy>=1.11, pyyaml>=6.0
```
**Additional needed for Phase 2:**
```
flask, flask-socketio, eventlet, jinja2, pyinstaller
```
---
## 🧠 Key Design Decisions (Important Context)
1. **Hypothesis-driven, not brute-force** — We don't grid-search all params. We detect failure patterns, hypothesize a fix, test it, validate it.
2. **Calmar ratio is primary score metric** — Return / MaxDrawdown is most relevant for live trading.
3. **Three validation gates** — IS threshold → Walk-Forward (2 folds) → OOS (locked period). No candidate is promoted unless it passes all three.
4. **MFE/MAE is critical** — Without the TradeLogger, the analyzer still works but is less powerful. Always confirm CSV is being generated.
5. **OOS is sacred**`2024.07.01 → 2024.12.31` is NEVER used during optimization. Only tested as final confirmation.
6. **Broker timezone = UTC+2** — All session analysis normalizes to UTC internally. Sessions: London=07-16 UTC, NY=13-22 UTC.
7. **Python 3.11 is required** — The system Python on this machine is 3.13 which doesn't have the packages. Always use the 3.11 path.
8. **MQL5 reference syntax fix** — MQL5 does not support C++ `&` references to array elements. `TradeLogger.mqh` was patched to use direct `TL_Positions[idx].field` access.
---
## 📍 Current Machine Paths (This User's System)
```
MT5 Terminal EXE: C:\Program Files\MetaTrader 5\terminal64.exe
MT5 Terminal Data: C:\Users\DELL\AppData\Roaming\MetaQuotes\Terminal\D0E8209F77C8CF37AD8BF550E51FF075\
MT5 Tester Files: C:\Users\DELL\AppData\Roaming\MetaQuotes\Tester\D0E8209F77C8CF37AD8BF550E51FF075\Agent-127.0.0.1-3000\MQL5\Files\
EA Source File: C:\Users\DELL\AppData\Roaming\MetaQuotes\Terminal\D0E8209F77C8CF37AD8BF550E51FF075\MQL5\Experts\LEGSTECH_EA_V2.mq5
TradeLogger (deployed): ...Terminal\...\MQL5\Include\TradeLogger.mqh
Project Folder: C:\Users\DELL\Desktop\MT5_Optimizer\
Python 3.11: C:\Users\DELL\AppData\Local\Programs\Python\Python311\python.exe
```
---
## 🚀 Phase 2 Build Instructions (For Next AI Session)
When continuing this project, build Phase 2 in this order:
### Step 1 — Flask app skeleton
Create `app.py` with:
- Flask app + SocketIO
- Route: `GET /` → serve dashboard
- Route: `GET /api/status` → current run status JSON
- Route: `POST /api/start` → start optimization loop in background thread
- Route: `POST /api/pause` → pause loop
- SocketIO event: emit `run_update` after each test completes
### Step 2 — Dashboard HTML
Create `ui/templates/index.html`:
- Dark theme (Bootstrap 5 dark)
- Left panel: score history chart (Chart.js line chart)
- Right panel: current run metrics card
- Bottom panel: scrollable findings feed
- Top bar: Start/Pause button, iteration counter, phase badge, timer
### Step 3 — Report template
Create `ui/templates/report.html`:
- Full metrics table
- Findings list in plain English
- Parameter delta table (old → new → why)
- Trade table with MAE/MFE columns
### Step 4 — Reports folder writer
Create `reports/writer.py`:
- `write_run_report(run_id, metrics, trades_df, findings)` → writes HTML + CSV
- All outputs go to `MT5_Optimizer\Reports\run_XXX\`
### Step 5 — PyInstaller packaging
Create `build.bat`:
```bat
pyinstaller --onefile --noconsole --name MT5_Optimizer app.py
```
Create `MT5_Optimizer.spec` with proper hidden imports for Flask, SocketIO, etc.
### Step 6 — Test end-to-end
- Double-click `MT5_Optimizer.exe`
- Browser opens at localhost:5000
- Click Start
- Watch live updates
- Check Reports folder
---
## 💡 Future Enhancements (v2)
- Multi-symbol optimization (EURUSD, GBPUSD alongside XAUUSD)
- Full rolling window WFV (currently 2-fold MVP)
- Portfolio-level Calmar (across symbols)
- Monte Carlo simulation for robustness testing
- Email/Telegram notification when a candidate is promoted
- Parameter sensitivity heatmap visualization
- Automatic .set file export for validated candidates
---
## ⚠️ Known Issues / Watch Points
1. **MT5 tester model** — Currently config uses `tester_model: 0` (Every Tick). For XAUUSD this is slow. Use model `4` (OHLC M1) for faster iteration during development.
2. **ShutdownTerminal=1** — The INI closes MT5 after each test. If MT5 is also being used for live trading, this will interrupt it. Separate terminal instances are recommended.
3. **Agent path** — The Tester Agent path (`Agent-127.0.0.1-3000`) may change if the MT5 tester port changes. If CSV is not found, check the Tester folder.
4. **WFV fold count** — Currently hardcoded to 2 folds in `validation/gate.py`. Easy to increase.
5. **INI Period code** — H1 = 16385 in MT5 (ENUM_TIMEFRAMES). This is hardcoded in `config.yaml` as `mt5_period_code: 16385`. Do not change unless changing timeframe.
---
*This document was generated by Antigravity AI on 2026-04-13.*
*Continue building from Phase 2 — GUI App.*
View File
+86
View File
@@ -0,0 +1,86 @@
"""
analysis/base.py
Abstract base class for all analyzer modules.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Optional
import numpy as np
import pandas as pd
from data.models import Finding, RunMetrics
class BaseAnalyzer(ABC):
"""
Every analyzer receives a trades DataFrame and RunMetrics,
and returns a list of Finding objects sorted by confidence descending.
"""
name: str = "base"
min_trades: int = 30 # refuse to analyze below this count
run_id: str = ""
def run(
self,
trades: pd.DataFrame,
metrics: RunMetrics,
run_id: str,
) -> list[Finding]:
"""Entry point — enforces minimum trade count gate."""
self.run_id = run_id
if len(trades) < self.min_trades:
return []
return self.analyze(trades, metrics)
@abstractmethod
def analyze(self, trades: pd.DataFrame, metrics: RunMetrics) -> list[Finding]:
"""Implement analysis logic. Return list of findings."""
...
# ── Statistical helpers ────────────────────────────────────────────────────
def _confidence_from_z(self, z: float) -> float:
"""Map a Z-score to a [0,1] confidence value using normal CDF."""
from scipy.stats import norm
return float(min(1.0, max(0.0, 2 * norm.cdf(abs(z)) - 1)))
def _permutation_pvalue(
self,
group_values: np.ndarray,
all_values: np.ndarray,
n_permutations: int = 500,
alternative: str = "less", # 'less' = testing if group mean < overall mean
) -> float:
"""
Non-parametric permutation test.
Returns p-value: probability that observed group mean is due to chance.
Lower p-value = more statistically significant.
"""
if len(group_values) == 0 or len(all_values) == 0:
return 1.0
observed_stat = np.mean(group_values)
n_group = len(group_values)
count_extreme = 0
rng = np.random.default_rng(seed=42) # deterministic
for _ in range(n_permutations):
sample = rng.choice(all_values, size=n_group, replace=False)
sample_stat = np.mean(sample)
if alternative == "less" and sample_stat <= observed_stat:
count_extreme += 1
elif alternative == "greater" and sample_stat >= observed_stat:
count_extreme += 1
return count_extreme / n_permutations
def _severity(self, confidence: float, impact_pnl: float, total_pnl: float) -> str:
"""Derive severity from confidence and relative $ impact."""
impact_fraction = abs(impact_pnl) / max(abs(total_pnl), 1)
if confidence >= 0.80 or impact_fraction >= 0.15:
return "high"
elif confidence >= 0.60 or impact_fraction >= 0.07:
return "medium"
return "low"
+176
View File
@@ -0,0 +1,176 @@
"""
analysis/entry_exit_quality.py
Scores trade entries and exits using MAE/MFE ratios.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from data.models import Finding, RunMetrics
from analysis.base import BaseAnalyzer
class EntryExitQualityAnalyzer(BaseAnalyzer):
"""
Entry/Exit Quality Scorer.
Uses MAE and MFE to independently assess:
- Entry quality: how much did price move against us before moving our way?
- Exit quality : what fraction of the available move did we capture?
Diagnosis matrix:
┌─────────────┬──────────────┬──────────────────────────────────────┐
│entry_quality│ exit_quality │ Diagnosis │
├─────────────┼──────────────┼──────────────────────────────────────┤
│ High │ High │ Healthy — no action │
│ High │ Low │ Good entries, poor exits → trail/TP │
│ Low │ High │ Bad entries, recovering → entry filt │
│ Low │ Low │ Systematic issue → both sides broken │
└─────────────┴──────────────┴──────────────────────────────────────┘
"""
name = "entry_exit_quality"
min_trades = 20
def __init__(
self,
poor_exit_threshold: float = 0.55,
poor_entry_threshold: float = 0.40,
good_entry_threshold: float = 0.65,
):
self.poor_exit = poor_exit_threshold
self.poor_entry = poor_entry_threshold
self.good_entry = good_entry_threshold
def analyze(self, trades: pd.DataFrame, metrics: RunMetrics) -> list[Finding]:
if "mfe_pips" not in trades.columns or trades["mfe_pips"].isna().all():
return []
df = trades[trades["mfe_pips"].notna() & (trades["mfe_pips"] > 0)].copy()
if len(df) < self.min_trades:
return []
# Compute quality scores if not already present
if "exit_quality" not in df.columns or df["exit_quality"].isna().all():
df["exit_quality"] = (df["net_pips"] / df["mfe_pips"].clip(lower=0.01)).clip(0, 1)
if "entry_quality" not in df.columns or df["entry_quality"].isna().all():
denom = df["mfe_pips"] + df["mae_pips"].fillna(0) + 0.01
df["entry_quality"] = (1 - df["mae_pips"].fillna(0) / denom).clip(0, 1)
mean_exit = float(df["exit_quality"].mean())
mean_entry = float(df["entry_quality"].mean())
findings = []
# ── Case 1: Good entries, poor exits ──────────────────────────────
if mean_exit < self.poor_exit and mean_entry >= self.good_entry:
z = (self.poor_exit - mean_exit) / max(0.01, df["exit_quality"].std())
confidence = min(0.95, self._confidence_from_z(z))
potential = float((df["mfe_pips"] - df["net_pips"].clip(lower=0)).clip(lower=0).mean())
impact = potential * len(df) * 0.1 # rough dollar estimate
findings.append(Finding(
run_id=self.run_id,
analyzer=self.name,
description=(
f"Good entries (quality={mean_entry:.2f}) but poor exits "
f"(quality={mean_exit:.2f}). "
f"Capturing only {mean_exit*100:.0f}% of available MFE. "
f"Consider trailing stop or tighter TP."
),
severity=self._severity(confidence, impact, metrics.net_profit),
confidence=confidence,
impact_estimate_pnl=impact,
suggested_params={
"InpUseTrailing": True,
"InpTrailStartPips": round(float(df["mfe_pips"].quantile(0.30)), 1),
"InpTrailStepPips": 10.0,
},
evidence={
"mean_entry_quality": round(mean_entry, 4),
"mean_exit_quality": round(mean_exit, 4),
"diagnosis": "good_entry_poor_exit",
"sample_size": len(df),
},
))
# ── Case 2: Poor entries ───────────────────────────────────────────
elif mean_entry < self.poor_entry:
z = (self.poor_entry - mean_entry) / max(0.01, df["entry_quality"].std())
confidence = min(0.95, self._confidence_from_z(z))
# Trades with high MAE but positive result still suggest entry timing issue
high_mae_losers = df[(df["mae_pips"] > df["mfe_pips"]) & (df["net_money"] < 0)]
impact = abs(float(high_mae_losers["net_money"].sum()))
findings.append(Finding(
run_id=self.run_id,
analyzer=self.name,
description=(
f"Poor entry quality ({mean_entry:.2f}): "
f"significant adverse move before trades become profitable. "
f"{len(high_mae_losers)} trades had MAE > MFE and closed at a loss. "
f"Consider tightening entry filters (ATR, EMA slope, score gate)."
),
severity=self._severity(confidence, impact, metrics.net_profit),
confidence=confidence,
impact_estimate_pnl=impact,
suggested_params={
"InpUseATRFilter": True, # placeholder name; map to actual param
"InpATRMultiplier": round(float(df["mae_pips"].quantile(0.70)) / 100, 1),
"InpMinScore": 9, # tighten quality gate
},
evidence={
"mean_entry_quality": round(mean_entry, 4),
"mean_exit_quality": round(mean_exit, 4),
"diagnosis": "poor_entry",
"high_mae_loser_count": int(len(high_mae_losers)),
"sample_size": len(df),
},
))
# ── Case 3: Both broken ────────────────────────────────────────────
elif mean_exit < self.poor_exit and mean_entry < self.poor_entry:
confidence = 0.75
impact = abs(metrics.net_profit) * 0.5 # rough
findings.append(Finding(
run_id=self.run_id,
analyzer=self.name,
description=(
f"Both entry ({mean_entry:.2f}) and exit ({mean_exit:.2f}) quality are poor. "
f"This suggests a systematic issue with the strategy logic. "
f"Consider testing a different BotMode or EntryMode."
),
severity="high",
confidence=confidence,
impact_estimate_pnl=impact,
suggested_params={"InpBotMode": 2}, # conservative mode
evidence={
"mean_entry_quality": round(mean_entry, 4),
"mean_exit_quality": round(mean_exit, 4),
"diagnosis": "both_broken",
"sample_size": len(df),
},
))
# ── Always report summary stats as a LOW finding for visibility ───
findings.append(Finding(
run_id=self.run_id,
analyzer=self.name,
description=(
f"Entry quality: {mean_entry:.2f} | Exit quality: {mean_exit:.2f} | "
f"Sample: {len(df)} trades with MFE data."
),
severity="low",
confidence=0.99,
impact_estimate_pnl=0.0,
suggested_params={},
evidence={
"mean_entry_quality": round(mean_entry, 4),
"mean_exit_quality": round(mean_exit, 4),
"sample_size": len(df),
"diagnosis": "summary",
},
))
return sorted(findings, key=lambda f: f.confidence, reverse=True)
+208
View File
@@ -0,0 +1,208 @@
"""
analysis/equity_curve.py
Analyzes equity curve shape: drawdown clusters, flatness, recovery efficiency.
"""
from __future__ import annotations
from typing import Optional
import numpy as np
import pandas as pd
from scipy.stats import linregress
from data.models import Finding, RunMetrics
from analysis.base import BaseAnalyzer
class EquityCurveAnalyzer(BaseAnalyzer):
"""
Equity Curve Shape Analyzer.
Metrics computed:
- Flatness score : % of trades where equity is below its running high-water mark
- Recovery time : average trades needed to recover from a drawdown
- Equity R² : linearity of cumulative PnL (high = consistent growth)
- Loss clusters : sequences of consecutive losses (≥ N in a row)
"""
name = "equity_curve"
def __init__(
self,
max_flatness: float = 0.50,
min_r_squared: float = 0.70,
cluster_min_length: int = 3,
):
self.max_flatness = max_flatness
self.min_r_sq = min_r_squared
self.cluster_min = cluster_min_length
def analyze(self, trades: pd.DataFrame, metrics: RunMetrics) -> list[Finding]:
if "net_money" not in trades.columns or len(trades) < self.min_trades:
return []
df = trades.sort_values("open_time").reset_index(drop=True)
df["cumulative_pnl"] = df["net_money"].cumsum()
df["hwm"] = df["cumulative_pnl"].cummax() # high-water mark
findings = []
findings += self._check_flatness(df, metrics)
findings += self._check_r_squared(df, metrics)
findings += self._check_loss_clusters(df, metrics)
return sorted(findings, key=lambda f: f.confidence, reverse=True)
# ── Flatness ──────────────────────────────────────────────────────────────
def _check_flatness(self, df: pd.DataFrame, metrics: RunMetrics) -> list[Finding]:
"""% of time equity is below its high-water mark."""
below_hwm = (df["cumulative_pnl"] < df["hwm"]).sum()
flatness = below_hwm / len(df)
if flatness <= self.max_flatness:
return []
confidence = min(0.90, (flatness - self.max_flatness) * 4)
impact = abs(metrics.net_profit) * (flatness - self.max_flatness)
# Compute average recovery length (trades to get back to HWM)
recovery_lengths = self._compute_recovery_lengths(df)
avg_recovery = float(np.mean(recovery_lengths)) if recovery_lengths else 0.0
return [Finding(
run_id=self.run_id,
analyzer=self.name,
description=(
f"Equity below high-water mark {flatness*100:.0f}% of the time "
f"(threshold: {self.max_flatness*100:.0f}%). "
f"Avg recovery: {avg_recovery:.0f} trades. "
f"Consider reducing risk or adding drawdown pause logic."
),
severity=self._severity(confidence, impact, metrics.net_profit),
confidence=confidence,
impact_estimate_pnl=impact,
suggested_params={
"InpRiskPercent": round(max(0.5, metrics.net_profit / 10000 * 0.75), 1),
"InpMaxDailyLossPct": 2.0,
},
evidence={
"flatness_score": round(float(flatness), 4),
"avg_recovery": round(avg_recovery, 1),
"below_hwm_count": int(below_hwm),
"total_trades": len(df),
},
)]
def _compute_recovery_lengths(self, df: pd.DataFrame) -> list[int]:
"""Count how many trades it takes to recover from each drawdown trough."""
lengths = []
in_dd = False
count = 0
for _, row in df.iterrows():
below = row["cumulative_pnl"] < row["hwm"]
if below and not in_dd:
in_dd = True
count = 1
elif below and in_dd:
count += 1
elif not below and in_dd:
lengths.append(count)
in_dd = False
count = 0
return lengths
# ── R² linearity ──────────────────────────────────────────────────────────
def _check_r_squared(self, df: pd.DataFrame, metrics: RunMetrics) -> list[Finding]:
"""Linear regression on cumulative PnL; low R² = high variance / choppy growth."""
x = np.arange(len(df))
y = df["cumulative_pnl"].values
try:
slope, intercept, r_value, p_value, _ = linregress(x, y)
except Exception:
return []
r_sq = r_value ** 2
if r_sq >= self.min_r_sq or slope <= 0:
return []
confidence = min(0.85, (self.min_r_sq - r_sq) * 3)
return [Finding(
run_id=self.run_id,
analyzer=self.name,
description=(
f"Equity curve linearity R²={r_sq:.2f} (threshold {self.min_r_sq:.2f}). "
f"High variance in growth pattern — inconsistent performance. "
f"May indicate regime sensitivity or scattered trade timing."
),
severity="medium" if r_sq < 0.50 else "low",
confidence=confidence,
impact_estimate_pnl=0.0,
suggested_params={},
evidence={
"r_squared": round(r_sq, 4),
"slope": round(float(slope), 4),
"p_value": round(float(p_value), 4),
},
)]
# ── Loss clusters ─────────────────────────────────────────────────────────
def _check_loss_clusters(self, df: pd.DataFrame, metrics: RunMetrics) -> list[Finding]:
"""Find sequences of ≥ N consecutive losing trades."""
clusters = []
streak = 0
start_idx = None
for idx, row in df.iterrows():
if row["net_money"] < 0:
if streak == 0:
start_idx = idx
streak += 1
else:
if streak >= self.cluster_min:
cluster_df = df.loc[start_idx:idx - 1]
clusters.append({
"length": streak,
"total_pnl": float(cluster_df["net_money"].sum()),
"start_time": str(df.loc[start_idx, "open_time"]) if "open_time" in df.columns else "?",
})
streak = 0
# Handle cluster at end of data
if streak >= self.cluster_min and start_idx is not None:
cluster_df = df.loc[start_idx:]
clusters.append({
"length": streak,
"total_pnl": float(cluster_df["net_money"].sum()),
"start_time": str(df.loc[start_idx, "open_time"]) if "open_time" in df.columns else "?",
})
if not clusters:
return []
max_cluster = max(c["length"] for c in clusters)
total_cluster_loss = sum(c["total_pnl"] for c in clusters if c["total_pnl"] < 0)
confidence = min(0.85, len(clusters) * 0.12 + max_cluster * 0.05)
return [Finding(
run_id=self.run_id,
analyzer=self.name,
description=(
f"Found {len(clusters)} loss cluster(s) of ≥ {self.cluster_min} consecutive losses. "
f"Worst streak: {max_cluster} trades. "
f"Total cluster losses: ${total_cluster_loss:.0f}."
),
severity=self._severity(confidence, abs(total_cluster_loss), metrics.net_profit),
confidence=confidence,
impact_estimate_pnl=abs(total_cluster_loss),
suggested_params={
"InpMaxDailyLossPct": 2.0,
"InpMaxTradesPerDay": 3,
},
evidence={
"cluster_count": len(clusters),
"max_streak": max_cluster,
"total_cluster_loss": round(total_cluster_loss, 2),
"clusters": clusters[:5], # keep top 5 for display
},
)]
+189
View File
@@ -0,0 +1,189 @@
"""
analysis/reversal.py
Detects trades that went into significant profit but ultimately closed as losses.
Measures profit giveback and proposes trailing / TP tightening adjustments.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from data.models import Finding, RunMetrics
from analysis.base import BaseAnalyzer
class ReversalAnalyzer(BaseAnalyzer):
"""
Profit Reversal Detector.
A "reversal" trade is one that:
- Closed at a loss (net_money < 0)
- Had MFE >= threshold pips (i.e. was at significant unrealised profit at some point)
Also flags trades that won but captured less than X% of their MFE (partial giveback).
"""
name = "reversal"
def __init__(
self,
mfe_threshold_pips: float = 15.0,
min_reversal_rate: float = 0.15,
poor_capture_rate: float = 0.55,
permutation_n: int = 500,
):
self.mfe_threshold = mfe_threshold_pips
self.min_reversal_rate = min_reversal_rate
self.poor_capture_rate = poor_capture_rate
self.permutation_n = permutation_n
def analyze(self, trades: pd.DataFrame, metrics: RunMetrics) -> list[Finding]:
findings = []
has_mfe = "mfe_pips" in trades.columns and trades["mfe_pips"].notna().sum() > 10
if has_mfe:
findings += self._check_reversals(trades, metrics)
findings += self._check_capture_rate(trades, metrics)
else:
# Without MFE, do a simpler check using result_class if available
if "result_class" in trades.columns:
findings += self._check_result_classes(trades, metrics)
return sorted(findings, key=lambda f: f.confidence, reverse=True)
# ── Reversal rate check ───────────────────────────────────────────────────
def _check_reversals(self, df: pd.DataFrame, metrics: RunMetrics) -> list[Finding]:
"""Find losing trades that had significant unrealised profit."""
losers = df[df["net_money"] < 0]
if len(losers) == 0:
return []
reversals = losers[losers["mfe_pips"] >= self.mfe_threshold]
rate = len(reversals) / len(losers)
if rate < self.min_reversal_rate or len(reversals) < 5:
return []
avg_giveback_pips = float(reversals["mfe_pips"].mean())
total_lost = float(losers["net_money"].sum())
reversal_lost = float(reversals["net_money"].sum())
# Permutation test: is the reversal rate unusually high?
all_mfe = df[df["net_money"] < 0]["mfe_pips"].dropna().values
if len(all_mfe) > 0:
p_val = self._permutation_pvalue(
reversals["mfe_pips"].values, all_mfe,
n_permutations=self.permutation_n, alternative="greater"
)
else:
p_val = 0.01 # assume significant
confidence = max(0.0, min(1.0, 1 - p_val))
impact_pnl = abs(reversal_lost) # upper bound on recoverable PnL
# Compute median reversal time for trailing stop suggestion
if "duration_minutes" in df.columns:
median_dur = float(reversals["duration_minutes"].median())
else:
median_dur = 0
# Compute 25th percentile of MFE at reversal — suggest TrailStart at this level
mfe_p25 = float(reversals["mfe_pips"].quantile(0.25))
suggested = {
"InpUseTrailing": True,
"InpTrailStartPips": round(max(10.0, mfe_p25 * 0.85), 1),
"InpTrailStepPips": 10.0,
}
return [Finding(
run_id=self.run_id,
analyzer=self.name,
description=(
f"{rate*100:.0f}% of losing trades had MFE ≥ {self.mfe_threshold:.0f} pips "
f"before reversing ({len(reversals)} trades). "
f"Avg giveback: {avg_giveback_pips:.1f} pips. "
f"Estimated recoverable PnL: ${impact_pnl:.0f}."
),
severity=self._severity(confidence, impact_pnl, metrics.net_profit),
confidence=confidence,
impact_estimate_pnl=impact_pnl,
suggested_params=suggested,
evidence={
"reversal_count": len(reversals),
"reversal_rate": round(rate, 4),
"avg_giveback_pips": round(avg_giveback_pips, 2),
"mfe_p25": round(mfe_p25, 2),
"median_duration_min": round(median_dur, 0),
"p_value": round(p_val, 4),
},
)]
# ── MFE Capture rate check ────────────────────────────────────────────────
def _check_capture_rate(self, df: pd.DataFrame, metrics: RunMetrics) -> list[Finding]:
"""Check if winning trades are capturing enough of their MFE."""
winners = df[(df["net_money"] > 0) & df["mfe_pips"].notna() & (df["mfe_pips"] > 0)]
if len(winners) < 10:
return []
if "exit_quality" not in df.columns or df["exit_quality"].isna().all():
winners = winners.copy()
winners["exit_quality"] = winners["net_pips"] / winners["mfe_pips"].clip(lower=0.01)
mean_capture = float(winners["exit_quality"].mean())
if mean_capture >= self.poor_capture_rate:
return []
potential_gain = float(
(winners["mfe_pips"] - winners["net_pips"]).clip(lower=0).mean()
) * float(winners["lot_size"].mean()) * 100 # rough $
confidence = self._confidence_from_z(
(self.poor_capture_rate - mean_capture) / max(0.01, winners["exit_quality"].std())
)
return [Finding(
run_id=self.run_id,
analyzer=self.name,
description=(
f"Winners capture only {mean_capture*100:.0f}% of their MFE on average. "
f"Potential gain with better exits: ~${potential_gain*len(winners):.0f}."
),
severity=self._severity(confidence, potential_gain * len(winners), metrics.net_profit),
confidence=min(0.95, confidence),
impact_estimate_pnl=potential_gain * len(winners),
suggested_params={
"InpUseTrailing": True,
"InpTrailStartPips": round(float(winners["mfe_pips"].quantile(0.30)), 1),
},
evidence={
"mean_capture_ratio": round(mean_capture, 4),
"winner_count": len(winners),
},
)]
# ── Fallback: result_class based ─────────────────────────────────────────
def _check_result_classes(self, df: pd.DataFrame, metrics: RunMetrics) -> list[Finding]:
"""Simple reversal check using pre-classified result_class column."""
reversals = df[df["result_class"] == "reversal"]
losers = df[df["net_money"] < 0]
if len(losers) == 0 or len(reversals) == 0:
return []
rate = len(reversals) / len(losers)
if rate < self.min_reversal_rate:
return []
return [Finding(
run_id=self.run_id,
analyzer=self.name,
description=f"{rate*100:.0f}% of losers classified as reversals (MFE-based).",
severity="medium",
confidence=0.65,
impact_estimate_pnl=abs(float(reversals["net_money"].sum())),
suggested_params={"InpUseTrailing": True},
evidence={"reversal_rate": round(rate, 4)},
)]
+304
View File
@@ -0,0 +1,304 @@
"""
analysis/time_performance.py
Analyzes trade performance by hour (UTC), session, and day of week.
Identifies statistically significant negative-edge time windows.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from data.models import Finding, RunMetrics
from analysis.base import BaseAnalyzer
class TimePerformanceAnalyzer(BaseAnalyzer):
"""
Session / Hour / Day-of-Week Performance Analyzer.
Buckets trades by time dimension and flags windows with:
- Z-score < threshold (mean PnL very negative vs overall)
- Statistically significant by permutation test (p < 0.10)
- Minimum trade count (don't flag buckets with too few trades)
"""
name = "time_performance"
def __init__(
self,
z_score_threshold: float = -1.5,
min_bucket_trades: int = 10,
permutation_n: int = 1000,
pvalue_threshold: float = 0.10,
):
self.z_threshold = z_score_threshold
self.min_bucket = min_bucket_trades
self.perm_n = permutation_n
self.p_threshold = pvalue_threshold
def analyze(self, trades: pd.DataFrame, metrics: RunMetrics) -> list[Finding]:
if "hour_utc" not in trades.columns:
return []
findings = []
findings += self._analyze_hours(trades, metrics)
findings += self._analyze_sessions(trades, metrics)
findings += self._analyze_days(trades, metrics)
return sorted(findings, key=lambda f: f.confidence, reverse=True)
# ── Hour analysis ─────────────────────────────────────────────────────────
def _analyze_hours(self, df: pd.DataFrame, metrics: RunMetrics) -> list[Finding]:
"""Flag individual UTC hours with poor performance."""
global_mean = df["net_money"].mean()
global_std = df["net_money"].std()
all_pnl = df["net_money"].values
if global_std == 0:
return []
findings = []
for hour in sorted(df["hour_utc"].dropna().unique()):
bucket = df[df["hour_utc"] == hour]
if len(bucket) < self.min_bucket:
continue
mean_pnl = bucket["net_money"].mean()
z = (mean_pnl - global_mean) / global_std
if z >= self.z_threshold:
continue
# Permutation test
p_val = self._permutation_pvalue(
bucket["net_money"].values, all_pnl,
n_permutations=self.perm_n, alternative="less"
)
if p_val >= self.p_threshold:
continue
impact_pnl = abs(float(bucket[bucket["net_money"] < 0]["net_money"].sum()))
confidence = max(0.0, min(0.97, 1 - p_val))
findings.append(Finding(
run_id=self.run_id,
analyzer=self.name,
description=(
f"Hour {hour:02d}:00 UTC: mean PnL ${mean_pnl:.2f} "
f"(Z={z:.2f}, {len(bucket)} trades). "
f"Estimated negative contribution: ${impact_pnl:.0f}."
),
severity=self._severity(confidence, impact_pnl, metrics.net_profit),
confidence=confidence,
impact_estimate_pnl=impact_pnl,
suggested_params={}, # session filter suggestion built by aggregator
evidence={
"type": "hour",
"hour_utc": int(hour),
"mean_pnl": round(mean_pnl, 2),
"z_score": round(z, 3),
"trade_count": int(len(bucket)),
"p_value": round(p_val, 4),
},
))
# Consolidate consecutive bad hours into a single window finding
if findings:
findings = self._consolidate_hour_findings(findings, df, metrics)
return findings
def _consolidate_hour_findings(
self, hour_findings: list[Finding], df: pd.DataFrame, metrics: RunMetrics
) -> list[Finding]:
"""
Group consecutive flagged hours into a single window finding.
E.g. hours [14, 15, 16] → "14:0017:00 UTC bad window"
Returns a single consolidated finding (plus keeps top individual for detail).
"""
bad_hours = sorted(
int(f.evidence["hour_utc"]) for f in hour_findings
)
if not bad_hours:
return hour_findings
# Find contiguous groups
groups = []
group = [bad_hours[0]]
for h in bad_hours[1:]:
if h == group[-1] + 1:
group.append(h)
else:
groups.append(group)
group = [h]
groups.append(group)
consolidated = []
for g in groups:
start_h = g[0]
end_h = g[-1] + 1
window = df[df["hour_utc"].between(start_h, g[-1])]
total_pnl = float(window["net_money"].sum())
n_trades = len(window)
impact = abs(float(window[window["net_money"] < 0]["net_money"].sum()))
# Derive session filter params from window
# Convert UTC to broker local time for session params
broker_start = (start_h + 2) % 24 # UTC+2 (from config)
broker_end = (end_h + 2) % 24
max_conf = max(f.confidence for f in hour_findings
if f.evidence["hour_utc"] in g)
consolidated.append(Finding(
run_id=self.run_id,
analyzer=self.name,
description=(
f"Negative edge window {start_h:02d}:00{end_h:02d}:00 UTC: "
f"${total_pnl:.0f} total, {n_trades} trades. "
f"Consider excluding this window via session filter."
),
severity=self._severity(max_conf, impact, metrics.net_profit),
confidence=max_conf,
impact_estimate_pnl=impact,
suggested_params={
"InpUseSession": True,
# Preserve existing session start; cut end before bad window
# These are broker-local hours
"InpSessionEnd": (broker_start) % 24,
},
evidence={
"type": "hour_window",
"start_utc": start_h,
"end_utc": end_h,
"broker_start": broker_start,
"broker_end": broker_end,
"total_pnl": round(total_pnl, 2),
"trade_count": n_trades,
"hours_flagged": g,
},
))
return consolidated
# ── Session analysis ──────────────────────────────────────────────────────
def _analyze_sessions(self, df: pd.DataFrame, metrics: RunMetrics) -> list[Finding]:
if "session" not in df.columns:
return []
global_mean = df["net_money"].mean()
global_std = df["net_money"].std()
all_pnl = df["net_money"].values
if global_std == 0:
return []
findings = []
for session in df["session"].dropna().unique():
bucket = df[df["session"] == session]
if len(bucket) < self.min_bucket:
continue
mean_pnl = bucket["net_money"].mean()
z = (mean_pnl - global_mean) / global_std
if z >= self.z_threshold:
continue
p_val = self._permutation_pvalue(
bucket["net_money"].values, all_pnl,
n_permutations=self.perm_n, alternative="less"
)
if p_val >= self.p_threshold:
continue
pf = (
bucket[bucket["net_money"] > 0]["net_money"].sum() /
max(0.01, abs(bucket[bucket["net_money"] < 0]["net_money"].sum()))
)
impact = abs(float(bucket[bucket["net_money"] < 0]["net_money"].sum()))
confidence = max(0.0, min(0.97, 1 - p_val))
findings.append(Finding(
run_id=self.run_id,
analyzer=self.name,
description=(
f"{session} session: PF {pf:.2f}, mean PnL ${mean_pnl:.2f} "
f"(Z={z:.2f}, {len(bucket)} trades). Recommend excluding this session."
),
severity=self._severity(confidence, impact, metrics.net_profit),
confidence=confidence,
impact_estimate_pnl=impact,
suggested_params={"InpUseSession": True},
evidence={
"type": "session",
"session": session,
"profit_factor": round(float(pf), 3),
"mean_pnl": round(mean_pnl, 2),
"z_score": round(z, 3),
"trade_count": int(len(bucket)),
"p_value": round(p_val, 4),
},
))
return findings
# ── Day-of-week analysis ──────────────────────────────────────────────────
def _analyze_days(self, df: pd.DataFrame, metrics: RunMetrics) -> list[Finding]:
if "day_of_week" not in df.columns:
return []
DAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
global_mean = df["net_money"].mean()
global_std = df["net_money"].std()
all_pnl = df["net_money"].values
if global_std == 0:
return []
findings = []
for day in range(5): # 0=Mon … 4=Fri
bucket = df[df["day_of_week"] == day]
if len(bucket) < self.min_bucket:
continue
mean_pnl = bucket["net_money"].mean()
z = (mean_pnl - global_mean) / global_std
if z >= self.z_threshold:
continue
p_val = self._permutation_pvalue(
bucket["net_money"].values, all_pnl,
n_permutations=self.perm_n, alternative="less"
)
if p_val >= self.p_threshold:
continue
impact = abs(float(bucket[bucket["net_money"] < 0]["net_money"].sum()))
confidence = max(0.0, min(0.97, 1 - p_val))
findings.append(Finding(
run_id=self.run_id,
analyzer=self.name,
description=(
f"{DAY_NAMES[day]}: mean PnL ${mean_pnl:.2f} "
f"(Z={z:.2f}, {len(bucket)} trades). "
f"Possible day-of-week edge degradation."
),
severity="low", # day-level findings are informational
confidence=confidence,
impact_estimate_pnl=impact,
suggested_params={},
evidence={
"type": "day_of_week",
"day": DAY_NAMES[day],
"day_index": day,
"mean_pnl": round(mean_pnl, 2),
"z_score": round(z, 3),
"trade_count": int(len(bucket)),
"p_value": round(p_val, 4),
},
))
return findings
+151
View File
@@ -0,0 +1,151 @@
"""
app.py — MT5 EA Optimizer Web App
Double-click to launch. Browser opens automatically at http://localhost:5000
"""
import sys, os, threading, webbrowser, time
from pathlib import Path
# ── Make sure imports resolve from project root ───────────────────────────────
BASE_DIR = Path(__file__).parent
sys.path.insert(0, str(BASE_DIR))
from flask import Flask, render_template, jsonify, request, send_from_directory
from flask_socketio import SocketIO, emit
from optimizer_loop import OptimizerLoop
# ── App setup ─────────────────────────────────────────────────────────────────
app = Flask(__name__,
template_folder="ui/templates",
static_folder="ui/static")
app.config["SECRET_KEY"] = "mt5optimizer2024"
socketio = SocketIO(app, cors_allowed_origins="*", async_mode="threading")
REPORTS_DIR = BASE_DIR / "Reports"
REPORTS_DIR.mkdir(exist_ok=True)
# Global optimizer instance
optimizer: OptimizerLoop = None
optimizer_thread: threading.Thread = None
# ── Routes ────────────────────────────────────────────────────────────────────
@app.route("/")
def index():
return render_template("index.html")
@app.route("/api/status")
def status():
if optimizer is None:
return jsonify({"state": "idle", "iteration": 0, "best_score": 0})
return jsonify(optimizer.get_status())
@app.route("/api/start", methods=["POST"])
def start():
global optimizer, optimizer_thread
if optimizer and optimizer.running:
return jsonify({"ok": False, "msg": "Already running"})
data = request.get_json(silent=True) or {}
optimizer = OptimizerLoop(
config_path=str(BASE_DIR / "config.yaml"),
socketio=socketio,
reports_dir=REPORTS_DIR,
auto_mode=data.get("auto", True),
)
optimizer_thread = threading.Thread(target=optimizer.run, daemon=True)
optimizer_thread.start()
return jsonify({"ok": True})
@app.route("/api/pause", methods=["POST"])
def pause():
if optimizer:
optimizer.toggle_pause()
return jsonify({"ok": True, "paused": optimizer.paused})
return jsonify({"ok": False})
@app.route("/api/stop", methods=["POST"])
def stop():
if optimizer:
optimizer.stop()
return jsonify({"ok": True})
@app.route("/api/skip", methods=["POST"])
def skip():
if optimizer:
optimizer.skip_hypothesis()
return jsonify({"ok": True})
@app.route("/api/history")
def history():
if optimizer is None:
return jsonify([])
return jsonify(optimizer.score_history)
@app.route("/reports")
@app.route("/reports/")
def reports_index():
"""Reports browser page — fixes the 404 on the Reports button."""
runs = []
for run_dir in sorted(REPORTS_DIR.iterdir(), reverse=True) if REPORTS_DIR.exists() else []:
summary = run_dir / "summary.json"
if summary.exists():
import json
try:
runs.append(json.loads(summary.read_text()))
except Exception:
pass
return render_template("reports_index.html", runs=runs[:100])
@app.route("/reports/<path:filename>")
def reports_file(filename):
"""Serve individual report files (HTML, CSV, JSON)."""
return send_from_directory(REPORTS_DIR, filename)
@app.route("/api/runs")
def runs_list():
runs = []
if REPORTS_DIR.exists():
for run_dir in sorted(REPORTS_DIR.iterdir(), reverse=True):
summary = run_dir / "summary.json"
if summary.exists():
import json
try:
runs.append(json.loads(summary.read_text()))
except Exception:
pass
return jsonify(runs[:50])
# ── SocketIO events ───────────────────────────────────────────────────────────
@socketio.on("connect")
def on_connect():
if optimizer:
emit("status_sync", optimizer.get_status())
# ── Launch ────────────────────────────────────────────────────────────────────
def open_browser():
time.sleep(1.5)
webbrowser.open("http://localhost:5000")
if __name__ == "__main__":
print("=" * 60)
print(" MT5 EA Optimizer — Starting...")
print(" Opening browser at http://localhost:5000")
print("=" * 60)
threading.Thread(target=open_browser, daemon=True).start()
socketio.run(app, host="0.0.0.0", port=5000, debug=False, use_reloader=False)
+103
View File
@@ -0,0 +1,103 @@
# MT5 EA Strategy Optimizer — Master Configuration
# LEGSTECH_EA_V2 | XAUUSD | H1
# ─────────────────────────────────────────────
ea:
name: "LEGSTECH_EA_V2"
file: "LEGSTECH_EA_V2" # .ex5 file name (no extension), in Experts/
symbol: "XAUUSD"
timeframe: "H1" # Period code used in INI (H1 = 16385... see MT5 period codes)
mt5_period_code: 16385 # PERIOD_H1 numeric code for [Tester] Period
periods:
train_start: "2022.01.01"
train_end: "2023.12.31"
validate_start: "2024.01.01"
validate_end: "2024.06.30"
oos_start: "2024.07.01" # LOCKED — never touched during optimization
oos_end: "2024.12.31"
broker:
timezone_offset_hours: 2 # Broker server = UTC+2. Set to 0 for UTC brokers.
deposit: 10000.0
currency: "USD"
leverage: 100
# Session definitions in BROKER LOCAL TIME (will be normalized to UTC internally)
sessions:
Asian: {start: 0, end: 9}
London: {start: 9, end: 18} # UTC+2: 07:00 UTC = 09:00 broker
NY: {start: 15, end: 24} # UTC+2: 13:00 UTC = 15:00 broker
LondonNY: {start: 15, end: 18} # Overlap
mt5:
terminal_exe: "C:/Program Files/MetaTrader 5/terminal64.exe"
# Terminal data folder — detected automatically from your MetaQuotes installation
appdata_path: "C:/Users/DELL/AppData/Roaming/MetaQuotes/Terminal/D0E8209F77C8CF37AD8BF550E51FF075"
# MQL5 Files folder — TradeLogger CSV is written here during backtests
# NOTE: Strategy Tester writes to the Agent subfolder, not the terminal Files folder
mql5_files_path: "C:/Users/DELL/AppData/Roaming/MetaQuotes/Tester/D0E8209F77C8CF37AD8BF550E51FF075/Agent-127.0.0.1-3000/MQL5/Files"
tester_model: 4 # 4=OHLC M1 (fast, reliable). Use 0=Every Tick only if full tick data available
tester_timeout_seconds: 1800 # 30 min max per test
shutdown_terminal: 1 # ShutdownTerminal=1 in INI — MT5 closes after test
data_readiness_wait_seconds: 10 # Wait N seconds after MT5 launch for data to load before testing
kill_on_start: true # Always kill existing MT5 before each run
report_subdir: "runs" # relative to project root
logging:
level: "INFO" # DEBUG | INFO | WARNING | ERROR
file: "optimizer.log"
thresholds:
min_trades: 50 # below this → no statistical confidence
min_profit_factor: 1.20
min_calmar: 0.35
max_oos_degradation: 0.30 # 30% drop IS→OOS is acceptable; above = reject
sensitivity_tolerance: 0.30 # ±10% param → >30% calmar drop = fragile, reject
min_wfv_ratio: 0.70 # OOS calmar must be ≥ 70% of IS calmar in WFV
scoring:
weights:
calmar: 0.35
profit_factor: 0.20
mfe_capture: 0.20 # avg MFE capture ratio (exit quality)
session_stability: 0.15 # 1 - std_dev of per-session Calmar
recovery_factor: 0.10
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}
significance_trades: 150 # full significance weight at this trade count
analysis:
reversal:
mfe_threshold_pips: 15.0 # minimum MFE to classify as reversal candidate
min_reversal_rate: 0.15 # above this → HIGH finding
permutation_n: 500 # permutation tests for significance
time_performance:
min_trades_per_bucket: 10 # ignore time buckets with fewer trades
z_score_threshold: -1.5 # flag as negative edge
permutation_n: 1000
entry_exit:
poor_exit_quality: 0.55 # below this → HIGH finding
poor_entry_quality: 0.40 # below this → HIGH finding
equity_curve:
max_flatness_score: 0.50 # above this → raise finding
min_r_squared: 0.70 # below this → high variance
mutation:
max_hypotheses_per_cycle: 3 # test at most N hypotheses before picking best
dedup_lookback_runs: 10 # avoid re-testing same delta seen in last N runs
explore_fallback_after: 5 # switch to explore if N targeted runs all rejected
optimization:
max_iterations: 50
convergence_window: 3 # stop if no improvement in this many promotions
convergence_threshold: 0.04 # minimum composite score delta to count as improvement
paths:
db: "optimizer.db"
runs_dir: "runs"
reports_dir: "reports"
log_file: "optimizer.log"
View File
+181
View File
@@ -0,0 +1,181 @@
"""
data/models.py
Pydantic v2 data models for all entities in the optimizer.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal, Optional
from pydantic import BaseModel, Field, computed_field, model_validator
import uuid
# ─────────────────────────────────────────────────────────────────────────────
# Trade
# ─────────────────────────────────────────────────────────────────────────────
class Trade(BaseModel):
"""One closed trade, enriched with MAE/MFE from the logger CSV."""
ticket: int
open_time: datetime
close_time: datetime
direction: Literal["buy", "sell"]
open_price: float
close_price: float
sl: float
tp: float
lot_size: float
net_pips: float
net_money: float
duration_minutes: int
commission: float = 0.0
swap: float = 0.0
# From TradeLogger CSV (may be None if logger not available)
mfe_pips: Optional[float] = None
mae_pips: Optional[float] = None
# Derived — populated during ingest
session: Optional[str] = None # London | NY | Asian | LondonNY | Off
day_of_week: Optional[int] = None # 0=Mon … 4=Fri
hour_broker: Optional[int] = None # broker local hour
hour_utc: Optional[int] = None # UTC hour (after timezone normalisation)
result_class: Optional[str] = None # win | loss | be | reversal
# Computed quality scores (None when MFE/MAE not available)
mfe_capture_ratio: Optional[float] = None # net_money / mfe_value; 1.0 = captured all
entry_quality: Optional[float] = None # 1 - (mae_pips / max(mfe_pips,1))
exit_quality: Optional[float] = None # net_pips / max(mfe_pips, 1)
@property
def won(self) -> bool:
return self.net_money > 0
@property
def lost(self) -> bool:
return self.net_money < 0
# ─────────────────────────────────────────────────────────────────────────────
# RunMetrics
# ─────────────────────────────────────────────────────────────────────────────
class RunMetrics(BaseModel):
"""Summary metrics for one backtest run."""
run_id: str
net_profit: float
profit_factor: float
max_drawdown_abs: float # in account currency
max_drawdown_pct: float # as fraction (0.15 = 15%)
calmar_ratio: float
sharpe_ratio: float
total_trades: int
win_rate: float # fraction (0.55 = 55%)
avg_win: float
avg_loss: float
recovery_factor: float
largest_loss: float
expected_payoff: float
# MAE/MFE derived (populated when logger CSV available)
avg_mfe_capture: Optional[float] = None
avg_mfe_pips: Optional[float] = None
avg_mae_pips: Optional[float] = None
reversal_rate: Optional[float] = None # reverted trades / total losers
# Filled by composite scorer
composite_score: float = 0.0
# ─────────────────────────────────────────────────────────────────────────────
# Run
# ─────────────────────────────────────────────────────────────────────────────
class Run(BaseModel):
"""One complete backtest run record."""
run_id: str = Field(default_factory=lambda: str(uuid.uuid4())[:8])
run_ts: datetime = Field(default_factory=datetime.utcnow)
ea_name: str
symbol: str
timeframe: str
period_start: str
period_end: str
params: dict[str, Any] # snapshot of all EA inputs used
phase: Literal["baseline", "explore", "validate", "wfv", "oos"]
hypothesis_id: Optional[str] = None
tester_model: int = 0 # 0=Every Tick
ini_snapshot: Optional[str] = None # full .ini content for reproducibility
report_path: Optional[str] = None
log_csv_path: Optional[str] = None
# ─────────────────────────────────────────────────────────────────────────────
# Finding
# ─────────────────────────────────────────────────────────────────────────────
class Finding(BaseModel):
"""One actionable observation from an analyzer module."""
finding_id: str = Field(default_factory=lambda: str(uuid.uuid4())[:8])
run_id: str
analyzer: str
description: str
severity: Literal["high", "medium", "low"]
confidence: float # 0.01.0
impact_estimate_pnl: float = 0.0 # estimated PnL recovery if addressed
suggested_params: dict[str, Any] = {}
evidence: dict[str, Any] = {} # raw supporting data (for reports)
# ─────────────────────────────────────────────────────────────────────────────
# Hypothesis
# ─────────────────────────────────────────────────────────────────────────────
class Hypothesis(BaseModel):
"""A proposed parameter change, motivated by one or more findings."""
hypothesis_id: str = Field(default_factory=lambda: str(uuid.uuid4())[:8])
parent_run_id: str
finding_ids: list[str]
description: str
param_delta: dict[str, Any] # {param_name: proposed_value}
strategy: Literal["targeted", "compound", "rollback", "explore"]
kb_rule_id: Optional[str] = None # KB rule that generated this
status: Literal["pending", "tested", "validated", "rejected"] = "pending"
tested_run_id: Optional[str] = None
# ─────────────────────────────────────────────────────────────────────────────
# Candidate
# ─────────────────────────────────────────────────────────────────────────────
class Candidate(BaseModel):
"""A parameter set that has passed all validation gates."""
candidate_id: str = Field(default_factory=lambda: str(uuid.uuid4())[:8])
run_id: str
promoted_ts: datetime = Field(default_factory=datetime.utcnow)
composite_score: float
oos_score: Optional[float] = None
params: dict[str, Any]
lineage: list[str] = [] # run_ids from baseline to this candidate
# ─────────────────────────────────────────────────────────────────────────────
# RunResult (returned by MT5Runner)
# ─────────────────────────────────────────────────────────────────────────────
class RunResult(BaseModel):
"""Raw file paths returned after a tester run completes."""
run_id: str
report_xml: Optional[str] = None # path to MT5 XML report
report_html: Optional[str] = None # path to MT5 HTML report
trade_log_csv: Optional[str] = None # path to TradeLogger CSV
success: bool = True
error_message: Optional[str] = None
# ─────────────────────────────────────────────────────────────────────────────
# GateResult (returned by ValidationGate)
# ─────────────────────────────────────────────────────────────────────────────
class GateResult(BaseModel):
passed: bool
details: dict[str, bool | float | str] = {}
reason: Optional[str] = None
+326
View File
@@ -0,0 +1,326 @@
"""
data/store.py
SQLite (metadata) + Parquet (per-run trade data) storage layer.
"""
from __future__ import annotations
import json
import sqlite3
import shutil
from pathlib import Path
from typing import Any, Optional
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq
from loguru import logger
from data.models import (
Run, RunMetrics, Finding, Hypothesis, Candidate, Trade
)
# ── Schema DDL ────────────────────────────────────────────────────────────────
SCHEMA_SQL = """
PRAGMA journal_mode=WAL;
PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS runs (
run_id TEXT PRIMARY KEY,
run_ts TEXT NOT NULL,
ea_name TEXT NOT NULL,
symbol TEXT NOT NULL,
timeframe TEXT NOT NULL,
period_start TEXT NOT NULL,
period_end TEXT NOT NULL,
params_json TEXT NOT NULL,
phase TEXT NOT NULL,
hypothesis_id TEXT,
tester_model INTEGER DEFAULT 0,
ini_snapshot TEXT,
report_path TEXT,
log_csv_path TEXT
);
CREATE TABLE IF NOT EXISTS run_metrics (
run_id TEXT PRIMARY KEY REFERENCES runs(run_id),
net_profit REAL,
profit_factor REAL,
max_drawdown_abs REAL,
max_drawdown_pct REAL,
calmar_ratio REAL,
sharpe_ratio REAL,
total_trades INTEGER,
win_rate REAL,
avg_win REAL,
avg_loss REAL,
recovery_factor REAL,
largest_loss REAL,
expected_payoff REAL,
avg_mfe_capture REAL,
avg_mfe_pips REAL,
avg_mae_pips REAL,
reversal_rate REAL,
composite_score REAL
);
CREATE TABLE IF NOT EXISTS findings (
finding_id TEXT PRIMARY KEY,
run_id TEXT NOT NULL REFERENCES runs(run_id),
analyzer TEXT NOT NULL,
description TEXT NOT NULL,
severity TEXT NOT NULL,
confidence REAL NOT NULL,
impact_estimate_pnl REAL DEFAULT 0,
suggested_params TEXT,
evidence TEXT
);
CREATE TABLE IF NOT EXISTS hypotheses (
hypothesis_id TEXT PRIMARY KEY,
parent_run_id TEXT NOT NULL REFERENCES runs(run_id),
finding_ids TEXT NOT NULL,
description TEXT NOT NULL,
param_delta TEXT NOT NULL,
strategy TEXT NOT NULL,
kb_rule_id TEXT,
status TEXT NOT NULL DEFAULT 'pending',
tested_run_id TEXT
);
CREATE TABLE IF NOT EXISTS candidates (
candidate_id TEXT PRIMARY KEY,
run_id TEXT NOT NULL REFERENCES runs(run_id),
promoted_ts TEXT NOT NULL,
composite_score REAL NOT NULL,
oos_score REAL,
params_json TEXT NOT NULL,
lineage_json TEXT
);
"""
# ── DataStore ─────────────────────────────────────────────────────────────────
class DataStore:
"""
Central storage interface.
- SQLite for all structured metadata (runs, metrics, findings, hypotheses, candidates)
- Parquet for per-run trade arrays (cheap columnar access for analysis)
"""
def __init__(self, db_path: str | Path, runs_dir: str | Path):
self.db_path = Path(db_path)
self.runs_dir = Path(runs_dir)
self.runs_dir.mkdir(parents=True, exist_ok=True)
self._init_db()
# ── Init ──────────────────────────────────────────────────────────────────
def _init_db(self) -> None:
with self._conn() as conn:
conn.executescript(SCHEMA_SQL)
logger.debug(f"Database initialised at {self.db_path}")
def _conn(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
return conn
# ── Runs ──────────────────────────────────────────────────────────────────
def save_run(self, run: Run) -> None:
with self._conn() as conn:
conn.execute("""
INSERT OR REPLACE INTO runs
(run_id, run_ts, ea_name, symbol, timeframe, period_start, period_end,
params_json, phase, hypothesis_id, tester_model, ini_snapshot,
report_path, log_csv_path)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", (
run.run_id,
run.run_ts.isoformat(),
run.ea_name, run.symbol, run.timeframe,
run.period_start, run.period_end,
json.dumps(run.params),
run.phase, run.hypothesis_id, run.tester_model,
run.ini_snapshot, run.report_path, run.log_csv_path,
))
logger.debug(f"Saved run {run.run_id}")
def get_run(self, run_id: str) -> Optional[Run]:
with self._conn() as conn:
row = conn.execute(
"SELECT * FROM runs WHERE run_id=?", (run_id,)
).fetchone()
if not row:
return None
d = dict(row)
d["params"] = json.loads(d.pop("params_json"))
return Run(**d)
def list_runs(self, phase: Optional[str] = None, n: int = 100) -> list[dict]:
q = "SELECT run_id, run_ts, phase, hypothesis_id FROM runs"
args: list[Any] = []
if phase:
q += " WHERE phase=?"
args.append(phase)
q += " ORDER BY run_ts DESC LIMIT ?"
args.append(n)
with self._conn() as conn:
return [dict(r) for r in conn.execute(q, args).fetchall()]
# ── Run Metrics ───────────────────────────────────────────────────────────
def save_metrics(self, m: RunMetrics) -> None:
with self._conn() as conn:
conn.execute("""
INSERT OR REPLACE INTO run_metrics
(run_id, net_profit, profit_factor, max_drawdown_abs, max_drawdown_pct,
calmar_ratio, sharpe_ratio, total_trades, win_rate, avg_win, avg_loss,
recovery_factor, largest_loss, expected_payoff,
avg_mfe_capture, avg_mfe_pips, avg_mae_pips, reversal_rate,
composite_score)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", (
m.run_id, m.net_profit, m.profit_factor,
m.max_drawdown_abs, m.max_drawdown_pct,
m.calmar_ratio, m.sharpe_ratio,
m.total_trades, m.win_rate, m.avg_win, m.avg_loss,
m.recovery_factor, m.largest_loss, m.expected_payoff,
m.avg_mfe_capture, m.avg_mfe_pips, m.avg_mae_pips,
m.reversal_rate, m.composite_score,
))
def get_metrics(self, run_id: str) -> Optional[RunMetrics]:
with self._conn() as conn:
row = conn.execute(
"SELECT * FROM run_metrics WHERE run_id=?", (run_id,)
).fetchone()
return RunMetrics(**dict(row)) if row else None
def best_candidate_score(self) -> float:
"""Return the highest composite_score ever achieved by a promoted candidate."""
with self._conn() as conn:
row = conn.execute(
"SELECT MAX(composite_score) FROM candidates"
).fetchone()
return float(row[0]) if row and row[0] is not None else 0.0
# ── Findings ──────────────────────────────────────────────────────────────
def save_findings(self, findings: list[Finding]) -> None:
with self._conn() as conn:
for f in findings:
conn.execute("""
INSERT OR REPLACE INTO findings
(finding_id, run_id, analyzer, description, severity,
confidence, impact_estimate_pnl, suggested_params, evidence)
VALUES (?,?,?,?,?,?,?,?,?)
""", (
f.finding_id, f.run_id, f.analyzer, f.description,
f.severity, f.confidence, f.impact_estimate_pnl,
json.dumps(f.suggested_params), json.dumps(f.evidence),
))
def get_findings(self, run_id: str) -> list[Finding]:
with self._conn() as conn:
rows = conn.execute(
"SELECT * FROM findings WHERE run_id=? ORDER BY confidence DESC",
(run_id,)
).fetchall()
out = []
for r in rows:
d = dict(r)
d["suggested_params"] = json.loads(d["suggested_params"] or "{}")
d["evidence"] = json.loads(d["evidence"] or "{}")
out.append(Finding(**d))
return out
# ── Hypotheses ────────────────────────────────────────────────────────────
def save_hypothesis(self, h: Hypothesis) -> None:
with self._conn() as conn:
conn.execute("""
INSERT OR REPLACE INTO hypotheses
(hypothesis_id, parent_run_id, finding_ids, description,
param_delta, strategy, kb_rule_id, status, tested_run_id)
VALUES (?,?,?,?,?,?,?,?,?)
""", (
h.hypothesis_id, h.parent_run_id,
json.dumps(h.finding_ids), h.description,
json.dumps(h.param_delta), h.strategy,
h.kb_rule_id, h.status, h.tested_run_id,
))
def update_hypothesis_status(
self, hypothesis_id: str,
status: str,
tested_run_id: Optional[str] = None
) -> None:
with self._conn() as conn:
conn.execute(
"UPDATE hypotheses SET status=?, tested_run_id=? WHERE hypothesis_id=?",
(status, tested_run_id, hypothesis_id)
)
def get_recent_param_deltas(self, n: int = 10) -> list[dict]:
"""Return param_delta dicts from the last N tested hypotheses (for dedup)."""
with self._conn() as conn:
rows = conn.execute("""
SELECT param_delta FROM hypotheses
WHERE status IN ('tested','validated','rejected')
ORDER BY rowid DESC LIMIT ?
""", (n,)).fetchall()
return [json.loads(r["param_delta"]) for r in rows]
# ── Candidates ────────────────────────────────────────────────────────────
def save_candidate(self, c: Candidate) -> None:
with self._conn() as conn:
conn.execute("""
INSERT OR REPLACE INTO candidates
(candidate_id, run_id, promoted_ts, composite_score,
oos_score, params_json, lineage_json)
VALUES (?,?,?,?,?,?,?)
""", (
c.candidate_id, c.run_id,
c.promoted_ts.isoformat(),
c.composite_score, c.oos_score,
json.dumps(c.params),
json.dumps(c.lineage),
))
logger.info(f"Promoted candidate {c.candidate_id} (score={c.composite_score:.4f})")
def list_candidates(self) -> list[dict]:
with self._conn() as conn:
rows = conn.execute(
"SELECT * FROM candidates ORDER BY composite_score DESC"
).fetchall()
return [dict(r) for r in rows]
# ── Trade Data (Parquet) ──────────────────────────────────────────────────
def save_trades(self, run_id: str, trades: list[Trade]) -> Path:
"""Serialise Trade objects to Parquet. Returns path to written file."""
parquet_path = self.runs_dir / run_id / "trades.parquet"
parquet_path.parent.mkdir(parents=True, exist_ok=True)
rows = [t.model_dump() for t in trades]
df = pd.DataFrame(rows)
# Ensure datetime columns are proper dtype
for col in ["open_time", "close_time"]:
if col in df.columns:
df[col] = pd.to_datetime(df[col], utc=True)
df.to_parquet(parquet_path, index=False, engine="pyarrow")
logger.debug(f"Saved {len(trades)} trades for run {run_id}")
return parquet_path
def load_trades(self, run_id: str) -> pd.DataFrame:
"""Load trade Parquet for a given run. Returns empty DataFrame if not found."""
parquet_path = self.runs_dir / run_id / "trades.parquet"
if not parquet_path.exists():
logger.warning(f"No trade parquet found for run {run_id}")
return pd.DataFrame()
return pd.read_parquet(parquet_path, engine="pyarrow")
+557
View File
@@ -0,0 +1,557 @@
"""
main.py
MT5 EA Strategy Optimizer — CLI Entry Point
LEGSTECH_EA_V2 | XAUUSD | H1
Usage:
python main.py # interactive mode
python main.py --baseline # run baseline only and show analysis
python main.py --auto # fully automated loop (no human prompts)
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from datetime import datetime
from typing import Optional, Any
import yaml
import pandas as pd
from loguru import logger
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.prompt import Confirm, Prompt
from rich import print as rprint
# ── Project imports ──────────────────────────────────────────────────────────
from data.models import Run, RunMetrics, Candidate
from data.store import DataStore
from mt5.ini_builder import IniBuilder
from mt5.runner import MT5Runner
from mt5.report_parser import ReportParser
from mt5.log_reader import TradeLogReader
from analysis.reversal import ReversalAnalyzer
from analysis.time_performance import TimePerformanceAnalyzer
from analysis.entry_exit_quality import EntryExitQualityAnalyzer
from analysis.equity_curve import EquityCurveAnalyzer
from scoring.composite import CompositeScorer
from mutation.engine import MutationEngine
from validation.gate import ValidationGate
console = Console()
CONFIG_PATH = Path("config.yaml")
MANIFEST_PATH = Path("mutation/param_manifest.yaml")
KB_PATH = Path("mutation/knowledge_base.yaml")
DB_PATH = Path("optimizer.db")
RUNS_DIR = Path("runs")
def load_config() -> dict:
with open(CONFIG_PATH) as f:
return yaml.safe_load(f)
# ── Component factory ─────────────────────────────────────────────────────────
def build_components(cfg: dict):
store = DataStore(DB_PATH, RUNS_DIR)
builder = IniBuilder(CONFIG_PATH, MANIFEST_PATH)
runner = MT5Runner(CONFIG_PATH)
parser = ReportParser()
log_rdr = TradeLogReader(
broker_tz_offset_hours=cfg["broker"]["timezone_offset_hours"],
pip_size=0.1, # XAUUSD: 0.1 per pip
)
analyzers = [
ReversalAnalyzer(
mfe_threshold_pips=cfg["analysis"]["reversal"]["mfe_threshold_pips"],
min_reversal_rate=cfg["analysis"]["reversal"]["min_reversal_rate"],
permutation_n=cfg["analysis"]["reversal"]["permutation_n"],
),
TimePerformanceAnalyzer(
z_score_threshold=cfg["analysis"]["time_performance"]["z_score_threshold"],
min_bucket_trades=cfg["analysis"]["time_performance"]["min_trades_per_bucket"],
permutation_n=cfg["analysis"]["time_performance"]["permutation_n"],
),
EntryExitQualityAnalyzer(
poor_exit_threshold=cfg["analysis"]["entry_exit"]["poor_exit_quality"],
poor_entry_threshold=cfg["analysis"]["entry_exit"]["poor_entry_quality"],
),
EquityCurveAnalyzer(
max_flatness=cfg["analysis"]["equity_curve"]["max_flatness_score"],
min_r_squared=cfg["analysis"]["equity_curve"]["min_r_squared"],
),
]
scorer = CompositeScorer(str(CONFIG_PATH))
mutator = MutationEngine(KB_PATH, MANIFEST_PATH,
dedup_lookback=cfg["mutation"]["dedup_lookback_runs"])
gate = ValidationGate(CONFIG_PATH)
return store, builder, runner, parser, log_rdr, analyzers, scorer, mutator, gate
# ── Single run pipeline ───────────────────────────────────────────────────────
def execute_run(
run_id: str,
params: dict[str, Any],
period_start: str,
period_end: str,
phase: str,
hypothesis_id: Optional[str],
cfg: dict,
store: DataStore,
builder: IniBuilder,
runner: MT5Runner,
parser: ReportParser,
log_rdr: TradeLogReader,
analyzers: list,
scorer: CompositeScorer,
) -> tuple[Optional[RunMetrics], pd.DataFrame]:
"""
Execute one complete backtest run:
1. Build INI → Launch MT5 → Wait → Parse report → Merge logger CSV
2. Compute derived fields, enrich trades
3. Compute composite score
4. Save everything to store
Returns (metrics, trades_df) or (None, empty_df) on failure.
"""
run_dir = RUNS_DIR / run_id
run_dir.mkdir(parents=True, exist_ok=True)
# 1. Build INI
ini_path = builder.build(
run_id=run_id,
params=params,
period_start=period_start,
period_end=period_end,
output_dir=run_dir,
phase=phase,
)
# 2. Save run record
run = Run(
run_id=run_id,
ea_name=cfg["ea"]["name"],
symbol=cfg["ea"]["symbol"],
timeframe=cfg["ea"]["timeframe"],
period_start=period_start,
period_end=period_end,
params=params,
phase=phase,
hypothesis_id=hypothesis_id,
tester_model=cfg["mt5"]["tester_model"],
ini_snapshot=ini_path.read_text(),
)
store.save_run(run)
# 3. Launch MT5
report_dir = run_dir / "report"
# MT5 Files folder: may need adjusting to actual terminal data path
mt5_files_dir = None # TODO: set to actual MQL5/Files path once terminal path confirmed
console.print(f"[dim]Launching MT5... (timeout {cfg['mt5']['tester_timeout_seconds']}s)[/dim]")
result = runner.run(run_id, ini_path, report_dir, log_csv_search_dir=mt5_files_dir)
if not result.success:
logger.error(f"Run {run_id} failed: {result.error_message}")
console.print(f"[red]✗ MT5 run failed: {result.error_message}[/red]")
return None, pd.DataFrame()
# 4. Parse report
metrics, trades = parser.parse(result.report_xml, result.report_html)
if metrics is None:
console.print(f"[red]✗ Could not parse report for {run_id}[/red]")
return None, pd.DataFrame()
metrics.run_id = run_id
# 5. Merge TradeLogger CSV → enrich with MAE/MFE + derived fields
if trades:
trades = log_rdr.merge(
trades, result.trade_log_csv,
reversal_mfe_threshold_pips=cfg["analysis"]["reversal"]["mfe_threshold_pips"],
)
trades_df = pd.DataFrame([t.model_dump() for t in trades])
else:
trades_df = pd.DataFrame()
# 6. Compute reversal rate and MFE capture for metrics
if not trades_df.empty:
if "result_class" in trades_df.columns:
losers = trades_df[trades_df["net_money"] < 0]
reversals = trades_df[trades_df["result_class"] == "reversal"]
metrics.reversal_rate = (
len(reversals) / max(1, len(losers))
)
if "mfe_capture_ratio" in trades_df.columns:
metrics.avg_mfe_capture = float(
trades_df["mfe_capture_ratio"].dropna().mean() or 0
)
if "mfe_pips" in trades_df.columns:
metrics.avg_mfe_pips = float(trades_df["mfe_pips"].dropna().mean() or 0)
metrics.avg_mae_pips = float(trades_df.get("mae_pips", pd.Series()).dropna().mean() or 0)
# 7. Compute composite score (session stats can be added here in v2)
metrics.composite_score = scorer.score(metrics)
# 8. Persist
store.save_metrics(metrics)
if not trades_df.empty:
store.save_trades(run_id, trades)
run.report_path = result.report_xml
run.log_csv_path = result.trade_log_csv
store.save_run(run)
return metrics, trades_df
# ── Analysis pipeline ─────────────────────────────────────────────────────────
def run_analysis(
run_id: str,
trades_df: pd.DataFrame,
metrics: RunMetrics,
analyzers: list,
store: DataStore,
) -> list:
"""Run all analyzers and persist findings."""
all_findings = []
for analyzer in analyzers:
findings = analyzer.run(trades_df, metrics, run_id)
all_findings.extend(findings)
console.print(
f" [green]✓[/green] {analyzer.name:<25}{len(findings)} finding(s)"
)
all_findings.sort(key=lambda f: f.confidence, reverse=True)
store.save_findings(all_findings)
return all_findings
# ── Display helpers ───────────────────────────────────────────────────────────
def display_metrics(metrics: RunMetrics, label: str = "Backtest Results") -> None:
table = Table(title=label, show_header=True, header_style="bold cyan")
table.add_column("Metric", style="dim")
table.add_column("Value", justify="right")
table.add_row("Net Profit", f"${metrics.net_profit:,.2f}")
table.add_row("Profit Factor", f"{metrics.profit_factor:.3f}")
table.add_row("Calmar Ratio", f"{metrics.calmar_ratio:.3f}")
table.add_row("Max Drawdown", f"{metrics.max_drawdown_pct*100:.1f}% (${metrics.max_drawdown_abs:,.0f})")
table.add_row("Total Trades", str(metrics.total_trades))
table.add_row("Win Rate", f"{metrics.win_rate*100:.1f}%")
table.add_row("Sharpe", f"{metrics.sharpe_ratio:.3f}")
table.add_row("Recovery Factor", f"{metrics.recovery_factor:.3f}")
if metrics.avg_mfe_capture is not None:
table.add_row("MFE Capture", f"{metrics.avg_mfe_capture*100:.1f}%")
if metrics.reversal_rate is not None:
table.add_row("Reversal Rate", f"{metrics.reversal_rate*100:.1f}%")
table.add_row("Composite Score", f"[bold]{metrics.composite_score:.4f}[/bold]")
console.print(table)
def display_findings(findings: list) -> None:
table = Table(title="Analysis Findings", show_header=True, header_style="bold yellow")
table.add_column("#", width=3)
table.add_column("Finding", max_width=60)
table.add_column("Severity", width=8)
table.add_column("Confidence", width=10, justify="right")
table.add_column("Est. Impact", width=12, justify="right")
sev_colors = {"high": "red", "medium": "yellow", "low": "dim"}
for i, f in enumerate(findings, 1):
color = sev_colors.get(f.severity, "white")
table.add_row(
str(i),
f.description[:60] + ("" if len(f.description) > 60 else ""),
f"[{color}]{f.severity.upper()}[/{color}]",
f"{f.confidence:.2f}",
f"${f.impact_estimate_pnl:,.0f}",
)
console.print(table)
def display_hypotheses(hypotheses: list, current_params: dict) -> None:
table = Table(title="Proposed Hypotheses", show_header=True, header_style="bold magenta")
table.add_column("#", width=3)
table.add_column("Description", max_width=50)
table.add_column("Parameter Changes", max_width=40)
for i, h in enumerate(hypotheses, 1):
changes = []
for param, val in h.param_delta.items():
old = current_params.get(param, "?")
changes.append(f"{param}: {old}{val}")
table.add_row(
str(i),
h.description[:50],
"\n".join(changes),
)
console.print(table)
# ── Main loop ─────────────────────────────────────────────────────────────────
def main(auto_mode: bool = False, baseline_only: bool = False) -> None:
cfg = load_config()
store, builder, runner, parser, log_rdr, analyzers, scorer, mutator, gate = (
build_components(cfg)
)
ea = cfg["ea"]
per = cfg["periods"]
console.print(Panel(
f"[bold cyan]MT5 EA Strategy Optimizer[/bold cyan]\n"
f"EA: {ea['name']} | Symbol: {ea['symbol']} | TF: {ea['timeframe']}\n"
f"Train: {per['train_start']}{per['train_end']} | "
f"OOS: {per['oos_start']}{per['oos_end']} [bold red](LOCKED)[/bold red]",
title="[bold]Session Start[/bold]",
))
# ── PHASE 0: Baseline ─────────────────────────────────────────────────────
console.rule("[bold]Phase 0: Baseline Run[/bold]")
default_params = builder.default_params()
baseline_id = f"baseline_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}"
baseline_metrics, baseline_trades = execute_run(
run_id=baseline_id,
params=default_params,
period_start=per["train_start"],
period_end=per["train_end"],
phase="baseline",
hypothesis_id=None,
cfg=cfg, store=store, builder=builder, runner=runner,
parser=parser, log_rdr=log_rdr, analyzers=analyzers, scorer=scorer,
)
if baseline_metrics is None:
console.print("[red]Baseline run failed. Check MT5 config and terminal path.[/red]")
sys.exit(1)
display_metrics(baseline_metrics, label="Baseline Results")
if baseline_only:
# ── Analysis only ────────────────────────────────────────────────────
console.rule("[bold]Analysis[/bold]")
findings = run_analysis(baseline_id, baseline_trades, baseline_metrics,
analyzers, store)
display_findings(findings)
return
current_params = default_params.copy()
current_metrics = baseline_metrics
best_score = baseline_metrics.composite_score
iteration = 0
no_improvement_count = 0
max_iter = cfg["optimization"]["max_iterations"]
conv_win = cfg["optimization"]["convergence_window"]
conv_thr = cfg["optimization"]["convergence_threshold"]
# ── Iteration loop ────────────────────────────────────────────────────────
while iteration < max_iter:
iteration += 1
console.rule(f"[bold]Iteration {iteration}[/bold]")
# Analysis
console.print("[bold]Running analysis modules...[/bold]")
parent_run_id = baseline_id if iteration == 1 else f"iter_{iteration-1}"
trades_df = store.load_trades(
baseline_id if iteration == 1 else f"iter_{iteration-1}_best"
)
if trades_df.empty:
trades_df = baseline_trades
findings = run_analysis(
baseline_id, trades_df, current_metrics, analyzers, store
)
display_findings(findings[:8]) # top 8
if not findings:
console.print("[yellow]No actionable findings. Stopping.[/yellow]")
break
# Mutation
recent_deltas = store.get_recent_param_deltas(cfg["mutation"]["dedup_lookback_runs"])
hypotheses = mutator.propose(
findings=findings,
current_params=current_params,
recent_deltas=recent_deltas,
max_proposals=cfg["mutation"]["max_hypotheses_per_cycle"],
)
if not hypotheses:
console.print("[yellow]No new hypotheses available. Stopping.[/yellow]")
break
display_hypotheses(hypotheses, current_params)
# Human approval (skipped in auto mode)
if auto_mode:
selected_indices = list(range(len(hypotheses)))
else:
choice = Prompt.ask(
"Apply which hypotheses?",
default="1",
)
if choice.lower() in ("skip", "s", ""):
console.print("[dim]Skipping...[/dim]")
continue
if choice.lower() == "all":
selected_indices = list(range(len(hypotheses)))
else:
selected_indices = [int(x.strip()) - 1 for x in choice.split(",")]
# Test selected hypotheses
iteration_best: Optional[RunMetrics] = None
iteration_best_params: Optional[dict] = None
iteration_best_hyp = None
for idx in selected_indices:
if idx < 0 or idx >= len(hypotheses):
continue
hyp = hypotheses[idx]
test_params = {**current_params, **hyp.param_delta}
run_id = f"iter_{iteration}_h{idx+1}"
console.print(f"\n[bold]Testing hypothesis {idx+1}: {hyp.description}[/bold]")
store.save_hypothesis(hyp)
test_metrics, test_trades = execute_run(
run_id=run_id,
params=test_params,
period_start=per["train_start"],
period_end=per["train_end"],
phase="explore",
hypothesis_id=hyp.hypothesis_id,
cfg=cfg, store=store, builder=builder, runner=runner,
parser=parser, log_rdr=log_rdr, analyzers=analyzers, scorer=scorer,
)
if test_metrics is None:
continue
display_metrics(test_metrics, label=f"H{idx+1} Results")
delta_score = test_metrics.composite_score - current_metrics.composite_score
color = "green" if delta_score > 0 else "red"
console.print(
f"Score delta: [{color}]{delta_score:+.4f}[/{color}] "
f"({current_metrics.composite_score:.4f}{test_metrics.composite_score:.4f})"
)
store.update_hypothesis_status(hyp.hypothesis_id, "tested", run_id)
if iteration_best is None or test_metrics.composite_score > iteration_best.composite_score:
iteration_best = test_metrics
iteration_best_params = test_params
iteration_best_hyp = hyp
if iteration_best is None:
console.print("[red]All hypotheses failed to run.[/red]")
continue
# Validation gate
gate_result = gate.run_is_check(iteration_best)
if not gate_result.passed:
console.print(f"[red]IS gate failed: {gate_result.details}[/red]")
store.update_hypothesis_status(iteration_best_hyp.hypothesis_id, "rejected")
no_improvement_count += 1
else:
# Walk-forward validation
if auto_mode or Confirm.ask("Run walk-forward validation?", default=True):
wfv = gate.run_walk_forward(iteration_best_params, cfg, store, builder,
runner, parser, log_rdr, analyzers, scorer)
console.print(f"WFV: OOS/IS ratio = {wfv.oos_is_ratio:.2f} "
f"(threshold {cfg['thresholds']['min_wfv_ratio']:.2f})")
if wfv.passed:
console.print(f"[green]Walk-forward PASSED[/green]")
else:
console.print(f"[yellow]Walk-forward FAILED — not promoting.[/yellow]")
store.update_hypothesis_status(iteration_best_hyp.hypothesis_id, "rejected")
no_improvement_count += 1
continue
# OOS test
run_oos = auto_mode or Confirm.ask("Run OOS validation?", default=False)
oos_score = None
if run_oos:
oos_metrics, _ = execute_run(
run_id=f"oos_{iteration}",
params=iteration_best_params,
period_start=per["oos_start"],
period_end=per["oos_end"],
phase="oos",
hypothesis_id=iteration_best_hyp.hypothesis_id,
cfg=cfg, store=store, builder=builder, runner=runner,
parser=parser, log_rdr=log_rdr, analyzers=analyzers, scorer=scorer,
)
if oos_metrics:
oos_score = oos_metrics.composite_score
oos_deg = (iteration_best.composite_score - oos_score) / max(0.001, iteration_best.composite_score)
if oos_deg > cfg["thresholds"]["max_oos_degradation"]:
console.print(f"[red]OOS degradation {oos_deg:.1%} > threshold. Rejected.[/red]")
store.update_hypothesis_status(iteration_best_hyp.hypothesis_id, "rejected")
no_improvement_count += 1
continue
display_metrics(oos_metrics, label="OOS Results")
# Promote candidate
candidate = Candidate(
run_id=iteration_best.run_id,
composite_score=iteration_best.composite_score,
oos_score=oos_score,
params=iteration_best_params,
)
store.save_candidate(candidate)
store.update_hypothesis_status(iteration_best_hyp.hypothesis_id, "validated")
console.print(f"[bold green]✅ Candidate C{candidate.candidate_id} promoted![/bold green]")
# Update baseline
improvement = iteration_best.composite_score - best_score
if improvement >= conv_thr:
current_params = iteration_best_params
current_metrics = iteration_best
best_score = iteration_best.composite_score
no_improvement_count = 0
else:
no_improvement_count += 1
# Convergence check
if no_improvement_count >= conv_win:
console.print(
f"[yellow]Convergence: no improvement in {no_improvement_count} iterations. Stopping.[/yellow]"
)
break
if not (auto_mode or Confirm.ask("Continue to next iteration?", default=True)):
break
# ── Summary ───────────────────────────────────────────────────────────────
console.rule("[bold]Optimization Complete[/bold]")
candidates = store.list_candidates()
if candidates:
console.print(f"[bold green]{len(candidates)} candidate(s) promoted.[/bold green]")
console.print(f"Best composite score: {max(c['composite_score'] for c in candidates):.4f}")
else:
console.print("[yellow]No candidates were promoted in this session.[/yellow]")
# ── Entry point ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
parser_cli = argparse.ArgumentParser(description="MT5 EA Strategy Optimizer")
parser_cli.add_argument("--auto", action="store_true", help="Run fully automated (no prompts)")
parser_cli.add_argument("--baseline", action="store_true", help="Run baseline + analysis only")
parser_cli.add_argument("--log-level", default="INFO", help="Logging level")
args = parser_cli.parse_args()
logger.remove()
logger.add(sys.stderr, level=args.log_level)
logger.add("optimizer.log", level="DEBUG", rotation="10 MB")
main(auto_mode=args.auto, baseline_only=args.baseline)
+242
View File
@@ -0,0 +1,242 @@
//+------------------------------------------------------------------+
//| TradeLogger.mqh |
//| Lightweight per-trade MAE/MFE logger for MT5 Strategy Tester |
//| Drop into: MQL5/Include/TradeLogger.mqh |
//| |
//| Integration (2 steps in your EA): |
//| 1. #include <TradeLogger.mqh> // top of your EA file |
//| 2. TL_OnTick(); // inside OnTick() |
//| |
//| Output CSV is written to MQL5/Files/TradeLog_<EA>_<Symbol>.csv |
//+------------------------------------------------------------------+
#property strict
//--- Configuration (override before including if needed)
#ifndef TL_MFE_THRESHOLD_PIPS
#define TL_MFE_THRESHOLD_PIPS 0.0 // minimum MFE to record (0 = record all)
#endif
#ifndef TL_MAX_TRACKED
#define TL_MAX_TRACKED 256 // max simultaneously open trades tracked
#endif
//--- Internal state per tracked position
struct TL_TradeState
{
ulong ticket;
datetime open_time;
double open_price;
double sl;
double tp;
double lot_size;
int direction; // 1=buy, -1=sell
double mfe_price; // most favourable price seen
double mae_price; // most adverse price seen
bool active;
};
static TL_TradeState TL_Positions[TL_MAX_TRACKED];
static int TL_Count = 0;
static int TL_FileHandle = INVALID_HANDLE;
static bool TL_Initialized = false;
static string TL_FilePath = "";
//--- Point-to-pip conversion helper
double TL_PipSize()
{
double ps = SymbolInfoDouble(_Symbol, SYMBOL_POINT);
int digits = (int)SymbolInfoInteger(_Symbol, SYMBOL_DIGITS);
// For 5-digit brokers, 1 pip = 10 points; for 2-digit (XAUUSD etc), 1 pip = 1 point
if(digits == 3 || digits == 5) return ps * 10.0;
return ps;
}
//--- Internal: open (or reopen) the CSV file
bool TL_OpenFile()
{
if(TL_FileHandle != INVALID_HANDLE) return true;
string ea_name = MQLInfoString(MQL_PROGRAM_NAME);
TL_FilePath = ea_name + "_" + _Symbol + "_TradeLog.csv";
TL_FileHandle = FileOpen(TL_FilePath,
FILE_WRITE | FILE_CSV | FILE_ANSI | FILE_SHARE_READ,
',');
if(TL_FileHandle == INVALID_HANDLE)
{
Print("[TradeLogger] ERROR: Cannot open file '", TL_FilePath,
"' error=", GetLastError());
return false;
}
// Write CSV header
FileWrite(TL_FileHandle,
"ticket","open_time","close_time",
"direction","open_price","close_price",
"sl","tp","lot_size",
"mfe_pips","mae_pips",
"net_pips","net_money",
"duration_minutes",
"commission","swap");
return true;
}
//--- Internal: find slot index for a ticket (-1 = not found)
int TL_FindSlot(ulong ticket)
{
for(int i = 0; i < TL_Count; i++)
if(TL_Positions[i].ticket == ticket && TL_Positions[i].active)
return i;
return -1;
}
//--- Internal: register a newly opened position
void TL_RegisterPosition(ulong ticket)
{
if(TL_Count >= TL_MAX_TRACKED) return; // overflow guard
if(!PositionSelectByTicket(ticket)) return;
TL_TradeState &s = TL_Positions[TL_Count];
s.ticket = ticket;
s.open_time = (datetime)PositionGetInteger(POSITION_TIME);
s.open_price = PositionGetDouble(POSITION_PRICE_OPEN);
s.sl = PositionGetDouble(POSITION_SL);
s.tp = PositionGetDouble(POSITION_TP);
s.lot_size = PositionGetDouble(POSITION_VOLUME);
s.direction = (PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY) ? 1 : -1;
s.mfe_price = s.open_price;
s.mae_price = s.open_price;
s.active = true;
TL_Count++;
}
//--- Internal: flush a closed trade to CSV
void TL_FlushClosed(int idx)
{
if(!TL_OpenFile()) return;
TL_TradeState &s = TL_Positions[idx];
// Retrieve closed deal data from history
if(!HistorySelectByPosition(s.ticket)) return;
int deals = HistoryDealsTotal();
if(deals < 2) return; // need at least open + close deal
// Find the closing deal (last deal in history for this position)
ulong close_deal = 0;
double close_price = 0;
double net_money = 0;
double commission = 0;
double swap_val = 0;
datetime close_time = 0;
for(int d = deals - 1; d >= 0; d--)
{
ulong deal_ticket = HistoryDealGetTicket(d);
if(HistoryDealGetInteger(deal_ticket, DEAL_ENTRY) == DEAL_ENTRY_OUT ||
HistoryDealGetInteger(deal_ticket, DEAL_ENTRY) == DEAL_ENTRY_INOUT)
{
close_deal = deal_ticket;
close_price = HistoryDealGetDouble(deal_ticket, DEAL_PRICE);
net_money = HistoryDealGetDouble(deal_ticket, DEAL_PROFIT);
commission = HistoryDealGetDouble(deal_ticket, DEAL_COMMISSION);
swap_val = HistoryDealGetDouble(deal_ticket, DEAL_SWAP);
close_time = (datetime)HistoryDealGetInteger(deal_ticket, DEAL_TIME);
break;
}
}
if(close_deal == 0) return;
double pip = TL_PipSize();
double mfe_pips = (s.mfe_price - s.open_price) * s.direction / pip;
double mae_pips = (s.open_price - s.mae_price) * s.direction / pip;
double net_pips = (close_price - s.open_price) * s.direction / pip;
int dur_min = (int)((close_time - s.open_time) / 60);
FileWrite(TL_FileHandle,
(string)s.ticket,
TimeToString(s.open_time, TIME_DATE|TIME_MINUTES),
TimeToString(close_time, TIME_DATE|TIME_MINUTES),
(s.direction == 1 ? "buy" : "sell"),
DoubleToString(s.open_price, _Digits),
DoubleToString(close_price, _Digits),
DoubleToString(s.sl, _Digits),
DoubleToString(s.tp, _Digits),
DoubleToString(s.lot_size, 2),
DoubleToString(MathMax(0, mfe_pips), 2),
DoubleToString(MathMax(0, mae_pips), 2),
DoubleToString(net_pips, 2),
DoubleToString(net_money, 2),
(string)dur_min,
DoubleToString(commission, 2),
DoubleToString(swap_val, 2));
FileFlush(TL_FileHandle); // flush after each trade — safe even if tester aborts
}
//+------------------------------------------------------------------+
//| TL_OnTick() — Call this inside your EA's OnTick() |
//+------------------------------------------------------------------+
void TL_OnTick()
{
double bid = SymbolInfoDouble(_Symbol, SYMBOL_BID);
double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK);
// --- Update running MFE/MAE for all tracked positions ---
for(int i = 0; i < TL_Count; i++)
{
if(!TL_Positions[i].active) continue;
ulong ticket = TL_Positions[i].ticket;
// Check if position is still open
if(!PositionSelectByTicket(ticket))
{
// Position closed — flush to CSV then deactivate
TL_FlushClosed(i);
TL_Positions[i].active = false;
continue;
}
double current_price = (TL_Positions[i].direction == 1) ? bid : ask;
// Update MFE (best price in trade direction)
if(TL_Positions[i].direction == 1) // BUY: higher is better
TL_Positions[i].mfe_price = MathMax(TL_Positions[i].mfe_price, current_price);
else // SELL: lower is better
TL_Positions[i].mfe_price = MathMin(TL_Positions[i].mfe_price, current_price);
// Update MAE (worst price against trade direction)
if(TL_Positions[i].direction == 1) // BUY: lower is worse
TL_Positions[i].mae_price = MathMin(TL_Positions[i].mae_price, current_price);
else // SELL: higher is worse
TL_Positions[i].mae_price = MathMax(TL_Positions[i].mae_price, current_price);
}
// --- Register any newly opened positions not yet tracked ---
int total = PositionsTotal();
for(int p = 0; p < total; p++)
{
ulong ticket = PositionGetTicket(p);
if(ticket == 0) continue;
if(!PositionSelectByTicket(ticket)) continue;
if(PositionGetString(POSITION_SYMBOL) != _Symbol) continue;
if(TL_FindSlot(ticket) == -1)
TL_RegisterPosition(ticket);
}
}
//+------------------------------------------------------------------+
//| TL_Deinit() — Optionally call in OnDeinit() to close file |
//+------------------------------------------------------------------+
void TL_Deinit()
{
if(TL_FileHandle != INVALID_HANDLE)
{
FileClose(TL_FileHandle);
TL_FileHandle = INVALID_HANDLE;
}
Print("[TradeLogger] Log written to: ", TL_FilePath);
}
//+------------------------------------------------------------------+
View File
+163
View File
@@ -0,0 +1,163 @@
"""
mt5/ini_builder.py
Generates the MT5 strategy tester .ini file from a parameter dict + config.
"""
from __future__ import annotations
import configparser
from pathlib import Path
from typing import Any
import yaml
from loguru import logger
# MT5 period string names (used in [Tester] Period= field)
TIMEFRAME_NAMES = {
"M1": "M1", "M5": "M5", "M15": "M15", "M30": "M30",
"H1": "H1", "H4": "H4", "D1": "D1", "W1": "W1", "MN": "MN1",
}
# MT5 tester model codes
MODEL_CODES = {
"every_tick": 0,
"every_tick_real": 1,
"ohlc_m1": 4,
}
class IniBuilder:
"""
Builds MT5 strategy tester .ini files.
Usage:
builder = IniBuilder(config_path="config.yaml", manifest_path="mutation/param_manifest.yaml")
ini_path = builder.build(
run_id="run_001",
params={"InpRiskPercent": 1.5, "InpUseTrailing": True, ...},
period_start="2022.01.01",
period_end="2023.12.31",
output_dir=Path("runs/run_001"),
)
"""
def __init__(self, config_path: str | Path, manifest_path: str | Path):
with open(config_path) as f:
self.cfg = yaml.safe_load(f)
with open(manifest_path) as f:
self.manifest = yaml.safe_load(f)["parameters"]
# ── Public ────────────────────────────────────────────────────────────────
def build(
self,
run_id: str,
params: dict[str, Any],
period_start: str,
period_end: str,
output_dir: Path,
phase: str = "explore",
) -> Path:
"""
Write <run_id>.ini to output_dir and return its path.
params: dict of EA input values (partial OK — missing params use manifest defaults)
"""
output_dir.mkdir(parents=True, exist_ok=True)
ini_path = output_dir / f"{run_id}.ini"
report_dir = output_dir / "report"
report_dir.mkdir(parents=True, exist_ok=True)
full_params = self._merge_with_defaults(params)
ini_text = self._render(run_id, full_params, period_start, period_end,
report_dir, phase)
ini_path.write_text(ini_text, encoding="utf-8")
logger.debug(f"INI written: {ini_path}")
return ini_path
def default_params(self) -> dict[str, Any]:
"""Return all EA parameters at their default values."""
return self._merge_with_defaults({})
# ── Internal ──────────────────────────────────────────────────────────────
def _merge_with_defaults(self, overrides: dict[str, Any]) -> dict[str, Any]:
"""Merge caller-supplied overrides with manifest defaults."""
result: dict[str, Any] = {}
for name, spec in self.manifest.items():
if name in overrides:
result[name] = overrides[name]
else:
result[name] = spec.get("default", 0)
return result
def _format_value(self, name: str, value: Any) -> str:
"""Format a parameter value for the [TesterInputs] section."""
spec = self.manifest.get(name, {})
ptype = spec.get("type", "float")
if ptype == "bool":
return "true" if value else "false"
if ptype == "fixed":
# Fixed params: write exact default
return str(value)
if ptype == "int" or ptype == "enum":
return str(int(value))
if ptype == "float":
# Determine decimal places from step
step = spec.get("step", 0.1)
decimals = len(str(step).split(".")[-1]) if "." in str(step) else 0
return f"{float(value):.{decimals}f}"
return str(value)
def _render(
self,
run_id: str,
params: dict[str, Any],
period_start: str,
period_end: str,
report_dir: Path,
phase: str,
) -> str:
"""Render final INI content as a string."""
ea_cfg = self.cfg["ea"]
mt5_cfg = self.cfg["mt5"]
broker_cfg = self.cfg["broker"]
# Period must be the string name (H1, M30 etc) — NOT the ENUM integer
tf_name = TIMEFRAME_NAMES.get(ea_cfg["timeframe"].upper(), "H1")
# Model: 0=Every Tick (slow), 4=OHLC M1 (fast, reliable for ini-based launch)
model_code = mt5_cfg.get("tester_model", 4)
# Report path must be RELATIVE to the MT5 terminal data folder
# MT5 appends its own base path. Use run_id as the report name.
report_name = f"Optimizer_{run_id}"
lines = [
f"; MT5 Optimizer INI — run_id={run_id} phase={phase}",
f"",
f"[Tester]",
f"Expert={ea_cfg['file']}",
f"Symbol={ea_cfg['symbol']}",
f"Period={tf_name}",
f"Optimization=0",
f"Model={model_code}",
f"FromDate={period_start}",
f"ToDate={period_end}",
f"ForwardMode=0",
f"Report={report_name}",
f"ReplaceReport=1",
f"ShutdownTerminal={mt5_cfg.get('shutdown_terminal', 1)}",
f"Deposit={broker_cfg['deposit']}",
f"Currency={broker_cfg['currency']}",
f"Leverage={broker_cfg['leverage']}",
f"",
f"[TesterInputs]",
]
for name, value in params.items():
formatted = self._format_value(name, value)
lines.append(f"{name}={formatted}")
lines.append("") # trailing newline
return "\n".join(lines)
+159
View File
@@ -0,0 +1,159 @@
"""
mt5/log_reader.py
Reads the TradeLogger.mqh CSV and merges MAE/MFE data into parsed trades.
Also computes derived fields: session, day_of_week, result_class, quality scores.
"""
from __future__ import annotations
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Optional
import pandas as pd
from loguru import logger
from data.models import Trade
# Session definitions in UTC hours (inclusive start, exclusive end)
SESSIONS_UTC = {
"Asian": (0, 9),
"London": (7, 16),
"LondonNY": (13, 16),
"NY": (13, 22),
}
def classify_session(hour_utc: int) -> str:
"""Classify a UTC hour into its primary trading session."""
in_london = SESSIONS_UTC["London"][0] <= hour_utc < SESSIONS_UTC["London"][1]
in_ny = SESSIONS_UTC["NY"][0] <= hour_utc < SESSIONS_UTC["NY"][1]
if in_london and in_ny:
return "LondonNY"
elif in_london:
return "London"
elif in_ny:
return "NY"
elif SESSIONS_UTC["Asian"][0] <= hour_utc < SESSIONS_UTC["Asian"][1]:
return "Asian"
else:
return "Off"
# ── Main reader/merger ────────────────────────────────────────────────────────
class TradeLogReader:
"""
Reads the CSV produced by TradeLogger.mqh and merges into a list of Trade objects.
Strategy:
1. Load CSV, index by ticket
2. For each Trade, look up ticket in CSV
3. Fill mfe_pips, mae_pips, duration_minutes if found
4. Compute all derived fields for every trade (session, quality scores, etc.)
"""
def __init__(self, broker_tz_offset_hours: int = 2, pip_size: float = 0.1):
self.tz_offset = broker_tz_offset_hours # broker local = UTC + offset
self.pip_size = pip_size # XAUUSD: 0.1 per pip
# ── Public ────────────────────────────────────────────────────────────────
def merge(
self,
trades: list[Trade],
csv_path: Optional[str | Path],
reversal_mfe_threshold_pips: float = 15.0,
) -> list[Trade]:
"""
Merge TradeLogger CSV into trade list, compute all derived fields.
If csv_path is None or unreadable, derived fields are computed without MFE/MAE.
"""
log_df = self._load_csv(csv_path) if csv_path else None
enriched = []
for trade in trades:
# Fill MAE/MFE from logger if available
if log_df is not None and trade.ticket in log_df.index:
row = log_df.loc[trade.ticket]
trade.mfe_pips = float(row.get("mfe_pips", 0) or 0)
trade.mae_pips = float(row.get("mae_pips", 0) or 0)
# Override duration with logger value (tick-accurate)
if "duration_minutes" in row:
trade.duration_minutes = int(row["duration_minutes"] or trade.duration_minutes)
# Compute all derived fields
trade = self._enrich(trade, reversal_mfe_threshold_pips)
enriched.append(trade)
logger.info(
f"Enriched {len(enriched)} trades. "
f"MAE/MFE available: {sum(1 for t in enriched if t.mfe_pips is not None)}"
)
return enriched
# ── Internal ──────────────────────────────────────────────────────────────
def _load_csv(self, csv_path: str | Path) -> Optional[pd.DataFrame]:
path = Path(csv_path)
if not path.exists():
logger.warning(f"TradeLogger CSV not found: {path}")
return None
try:
df = pd.read_csv(path, dtype={"ticket": int})
if "ticket" not in df.columns:
logger.error("TradeLogger CSV missing 'ticket' column.")
return None
df = df.set_index("ticket")
logger.debug(f"Loaded {len(df)} rows from TradeLogger CSV.")
return df
except Exception as e:
logger.error(f"Failed to read TradeLogger CSV: {e}")
return None
def _enrich(self, trade: Trade, threshold_pips: float) -> Trade:
"""Compute all derived classification and quality fields."""
# --- Timezone normalisation ---
# Broker timestamps are in broker local time (UTC+offset).
# We compute UTC hour by subtracting the offset.
broker_hour = trade.open_time.hour
hour_utc = (broker_hour - self.tz_offset) % 24
trade.hour_broker = broker_hour
trade.hour_utc = hour_utc
trade.day_of_week = trade.open_time.weekday() # 0=Mon, 4=Fri
trade.session = classify_session(hour_utc)
# --- Result class ---
won = trade.net_money > 0
be = abs(trade.net_money) < 0.01 # effectively breakeven
if be:
trade.result_class = "be"
elif won:
trade.result_class = "win"
else:
# Check if it's a reversal: lost, but had positive MFE above threshold
if trade.mfe_pips is not None and trade.mfe_pips >= threshold_pips:
trade.result_class = "reversal"
else:
trade.result_class = "loss"
# --- Quality scores (only when MFE/MAE available) ---
if trade.mfe_pips is not None and trade.mae_pips is not None:
mfe = max(trade.mfe_pips, 0.01) # prevent division by zero
mae = max(trade.mae_pips, 0.0)
# Entry quality: how far against you before move in your favour
# High = entered well (little adverse move relative to favourable move)
trade.entry_quality = max(0.0, min(1.0, 1.0 - (mae / (mfe + mae + 0.01))))
# Exit quality: what fraction of MFE did we capture
mfe_value = mfe * self.pip_size * trade.lot_size * 100 # approx value in $
if mfe_value > 0:
trade.mfe_capture_ratio = max(0.0, trade.net_money / mfe_value)
trade.exit_quality = max(0.0, min(1.0, trade.net_pips / mfe))
else:
trade.mfe_capture_ratio = 0.0
trade.exit_quality = 0.0
return trade
+338
View File
@@ -0,0 +1,338 @@
"""
mt5/report_parser.py
Parses the MT5 strategy tester HTML report (production format: pure HTML tables).
Extracts RunMetrics and paired in/out deal trades.
"""
from __future__ import annotations
import re
from datetime import datetime
from pathlib import Path
from typing import Optional
from lxml import html as lhtml
from loguru import logger
from data.models import RunMetrics, Trade
# ── Helpers ───────────────────────────────────────────────────────────────────
def _clean(s: str) -> str:
"""Remove HTML entity remnants, spaces, currency symbols."""
if not s:
return ""
# MT5 uses non-breaking spaces (0xa0) and regular spaces
s = s.replace("\xa0", "").replace(",", "").replace(" ", "").strip()
return s
def _parse_float(s: str) -> float:
s = _clean(s)
# Remove everything except digits, dot, minus
s = re.sub(r"[^\d.\-]", "", s)
try:
return float(s)
except (ValueError, TypeError):
return 0.0
def _parse_int(s: str) -> int:
s = _clean(s)
s = re.sub(r"[^\d\-]", "", s.split("(")[0])
try:
return int(s)
except (ValueError, TypeError):
return 0
def _parse_dt(s: str) -> Optional[datetime]:
s = (s or "").strip()
for fmt in ("%Y.%m.%d %H:%M:%S", "%Y.%m.%d %H:%M", "%Y.%m.%d"):
try:
return datetime.strptime(s, fmt)
except ValueError:
continue
return None
def _cell_text(td) -> str:
"""Get all inner text from an lxml element, stripping tags."""
return "".join(td.itertext()).strip()
# ── Main Parser ───────────────────────────────────────────────────────────────
class ReportParser:
"""
Parses the MT5 HTML strategy tester report.
Report format (confirmed from live MT5 output):
- Summary metrics: <td>Label:</td><td><b>Value</b></td> pairs
- Deals table: Time | Deal | Symbol | Type | Direction | Volume |
Price | Order | Commission | Swap | Profit | Balance | Comment
Direction='in' → position open (entry deal)
Direction='out' → position close (exit deal, has Profit value)
"""
def parse(
self, xml_path: Optional[str], html_path: Optional[str]
) -> tuple[Optional[RunMetrics], list[Trade]]:
"""
Parse the MT5 HTML report. xml_path is ignored (MT5 command-line
runs produce .htm, not .xml). Falls back gracefully if html is missing.
"""
path = None
if html_path and Path(html_path).exists():
path = Path(html_path)
elif xml_path and Path(xml_path).exists():
path = Path(xml_path)
if path is None:
logger.error("No report file available to parse.")
return None, []
logger.debug(f"Parsing report: {path}")
try:
raw = path.read_bytes()
# MT5 HTML reports are UTF-16 LE (BOM: ff fe) — detect and decode
if raw[:2] == b'\xff\xfe':
# Pass raw bytes; lxml's HTML parser handles UTF-16 correctly
tree = lhtml.document_fromstring(raw)
else:
# Regular UTF-8 or latin-1
try:
content = raw.decode("utf-8")
except UnicodeDecodeError:
content = raw.decode("windows-1252", errors="replace")
tree = lhtml.document_fromstring(content.encode("utf-8"))
except Exception as e:
logger.error(f"Failed to parse HTML: {e}")
return None, []
summary = self._extract_summary(tree)
deals = self._extract_deals(tree)
trades = self._pair_deals(deals)
if not summary:
logger.warning("No summary data found in MT5 HTML report.")
return None, trades
metrics = self._build_metrics(summary)
logger.info(f"Parsed: {metrics.total_trades} trades, PF={metrics.profit_factor:.3f}")
return metrics, trades
# ── Summary ───────────────────────────────────────────────────────────────
def _extract_summary(self, tree) -> dict[str, str]:
"""
Extract label→value pairs from the stats tables.
MT5 pattern: <td ...>Label:</td> <td ...><b>Value</b></td>
The label and value are adjacent siblings in the same <tr>.
"""
result: dict[str, str] = {}
for tr in tree.iter("tr"):
tds = list(tr.findall(".//td"))
if len(tds) < 2:
continue
for i in range(len(tds) - 1):
label = _cell_text(tds[i]).rstrip(":")
val = _cell_text(tds[i + 1])
if label and val and len(label) < 60:
result[label] = val
logger.debug(f"Summary fields: {len(result)}")
return result
# ── Deals ─────────────────────────────────────────────────────────────────
def _extract_deals(self, tree) -> list[dict]:
"""
Find the Deals table and parse every row.
Columns: Time|Deal|Symbol|Type|Direction|Volume|Price|Order|Commission|Swap|Profit|Balance|Comment
"""
# Find the <th> that contains "Deals" text
deals_header = None
for th in tree.iter("th"):
if "Deals" in (_cell_text(th) or ""):
deals_header = th
break
if deals_header is None:
logger.warning("Deals table not found in report.")
return []
# Walk up to find the table element
table = deals_header
while table is not None and table.tag != "table":
table = table.getparent()
if table is None:
return []
rows = table.findall(".//tr")
# Skip header rows (first 2 rows: table title + column headers)
data_rows = []
header_seen = 0
for row in rows:
ths = row.findall(".//th")
tds = row.findall(".//td")
if ths:
header_seen += 1
continue
if not tds:
continue
data_rows.append(tds)
deals = []
for tds in data_rows:
texts = [_cell_text(td) for td in tds]
if len(texts) < 13:
continue
# Cols: 0=Time 1=Deal 2=Symbol 3=Type 4=Direction 5=Volume
# 6=Price 7=Order 8=Commission 9=Swap 10=Profit 11=Balance 12=Comment
deal_type = texts[3].lower()
if "balance" in deal_type or "credit" in deal_type:
continue # skip balance entries at start
deals.append({
"time": texts[0],
"deal": _parse_int(texts[1]),
"symbol": texts[2],
"type": deal_type, # buy / sell
"direction": texts[4].lower(), # in / out
"volume": _parse_float(texts[5]),
"price": _parse_float(texts[6]),
"order": _parse_int(texts[7]),
"commission": _parse_float(texts[8]),
"swap": _parse_float(texts[9]),
"profit": _parse_float(texts[10]),
"balance": _parse_float(texts[11]),
"comment": texts[12] if len(texts) > 12 else "",
})
logger.debug(f"Raw deals extracted: {len(deals)}")
return deals
# ── Pairing in→out ────────────────────────────────────────────────────────
def _pair_deals(self, deals: list[dict]) -> list[Trade]:
"""
Pair 'in' (open) and 'out' (close) deals to form complete trades.
MT5 reports alternate: in-deal → out-deal for each closed position.
"""
trades: list[Trade] = []
pending: Optional[dict] = None # the last 'in' deal
for d in deals:
if d["direction"] == "in":
pending = d
elif d["direction"] == "out" and pending is not None:
open_dt = _parse_dt(pending["time"])
close_dt = _parse_dt(d["time"])
if not open_dt or not close_dt:
pending = None
continue
direction = pending["type"] # buy / sell
open_price = pending["price"]
close_price = d["price"]
net_money = d["profit"]
lot_size = d["volume"]
commission = d["commission"] + pending["commission"]
swap = d["swap"] + pending["swap"]
duration_m = max(0, int((close_dt - open_dt).total_seconds() / 60))
# Net pips (XAUUSD: price moves in dollars, 1 pip = 0.1)
price_diff = (close_price - open_price) * (1 if direction == "buy" else -1)
net_pips = round(price_diff / 0.1, 2) if price_diff != 0 else 0.0
trades.append(Trade(
ticket = d["deal"],
open_time = open_dt,
close_time = close_dt,
direction = direction,
open_price = open_price,
close_price = close_price,
lot_size = lot_size,
net_pips = net_pips,
net_money = net_money,
duration_minutes = duration_m,
commission = commission,
swap = swap,
sl = 0.0, # not in deals table
tp = 0.0,
))
pending = None
# if direction is empty/"" skip it
logger.info(f"Paired {len(trades)} complete trades from deals.")
return trades
# ── Metrics ───────────────────────────────────────────────────────────────
def _build_metrics(self, raw: dict[str, str]) -> RunMetrics:
"""Build RunMetrics from the extracted label→value dictionary."""
def get(*keys) -> str:
for k in keys:
v = raw.get(k, "")
if v:
return v
return "0"
net_profit = _parse_float(get("Total Net Profit", "Net Profit", "Balance"))
gross_profit = _parse_float(get("Gross Profit"))
gross_loss = _parse_float(get("Gross Loss"))
profit_factor = _parse_float(get("Profit Factor"))
# Total Deals = number of deal rows; Total Trades = positions
total_trades = _parse_int(get("Total Trades", "Total Deals"))
win_trades = _parse_int(get("Profit Trades", "Profit Trades (% of total)",
"Profit Deals"))
# Drawdown: "2 160.22 (19.25%)"
dd_str = get("Equity Drawdown Maximal", "Equity Drawdown Relative",
"Balance Drawdown Maximal")
max_dd_abs = _parse_float(dd_str.split("(")[0])
pct_match = re.search(r"([\d.]+)%", dd_str)
max_dd_pct = float(pct_match.group(1)) / 100 if pct_match else 0.0
initial_dep = _parse_float(get("Initial Deposit", "Deposit"))
if initial_dep <= 0:
initial_dep = 10_000.0
sharpe = _parse_float(get("Sharpe Ratio", "Sharp Ratio"))
recovery_factor = _parse_float(get("Recovery Factor"))
expected_payoff = _parse_float(get("Expected Payoff"))
# Compute max_dd_pct if only absolute was found
if max_dd_pct == 0.0 and max_dd_abs > 0:
total_equity = initial_dep + net_profit
max_dd_pct = max_dd_abs / max(1, total_equity)
# Calmar = annualised return / max drawdown
# Use simple ratio since we don't know exact test duration
calmar = 0.0
if max_dd_pct > 0:
annual_return = net_profit / initial_dep
calmar = round(annual_return / max_dd_pct, 4)
win_rate = win_trades / total_trades if total_trades > 0 else 0.0
loss_trades = max(0, total_trades - win_trades)
avg_win = gross_profit / win_trades if win_trades > 0 else 0.0
avg_loss = gross_loss / loss_trades if loss_trades > 0 else 0.0
return RunMetrics(
run_id = "__placeholder__",
net_profit = net_profit,
profit_factor = profit_factor,
max_drawdown_abs= max_dd_abs,
max_drawdown_pct= max_dd_pct,
calmar_ratio = calmar,
sharpe_ratio = sharpe,
total_trades = total_trades,
win_rate = win_rate,
avg_win = avg_win,
avg_loss = avg_loss,
recovery_factor = recovery_factor,
largest_loss = _parse_float(get("Largest loss trade")),
expected_payoff = expected_payoff,
)
+375
View File
@@ -0,0 +1,375 @@
"""
mt5/runner.py
Robust MT5 Strategy Tester runner with:
1. MT5 process control (kill existing, launch fresh)
2. Pre-run environment validation
3. Historical data readiness wait
4. Actionable error messages
5. Auto-retry on failure (1 retry)
6. Report detection in MT5 native reports folder
"""
from __future__ import annotations
import shutil
import subprocess
import time
import psutil
from pathlib import Path
from typing import Optional
import yaml
from loguru import logger
from data.models import RunResult
# ── Custom Exceptions ─────────────────────────────────────────────────────────
class MT5TimeoutError(RuntimeError):
pass
class MT5ValidationError(RuntimeError):
pass
# ── MT5Runner ─────────────────────────────────────────────────────────────────
class MT5Runner:
POLL_INTERVAL_S = 5 # seconds between report checks
PROCESS_SETTLE_S = 2 # seconds to wait after process exit
MT5_INIT_WAIT_S = 8 # seconds after launch before testing begins
KILL_WAIT_S = 3 # seconds after killing MT5 before launching fresh
MAX_RETRIES = 1 # retry once on failure
MT5_EXE_NAME = "terminal64.exe"
def __init__(self, config_path: str | Path = "config.yaml"):
with open(config_path) as f:
cfg = yaml.safe_load(f)
self.cfg = cfg
self.terminal_exe = Path(cfg["mt5"]["terminal_exe"])
self.timeout_s = cfg["mt5"]["tester_timeout_seconds"]
self.appdata_path = Path(cfg["mt5"]["appdata_path"])
# MT5 writes reports to the ROOT of the terminal data folder
# (not a 'reports' subfolder) — confirmed by log inspection
self.mt5_reports_dir = self.appdata_path
self.mql5_files_dir = Path(cfg["mt5"].get(
"mql5_files_path",
str(self.appdata_path / "MQL5" / "Files")
))
self.data_wait_s = cfg["mt5"].get("data_readiness_wait_seconds", 10)
# ── Public entry point ────────────────────────────────────────────────────
def run(
self,
run_id: str,
ini_path: Path,
report_dir: Path,
log_csv_search_dir: Optional[Path] = None,
) -> RunResult:
"""
Full execution pipeline with retry:
1. Kill existing MT5
2. Validate environment
3. Launch fresh MT5
4. Wait for data readiness
5. Poll for report
6. Auto-retry once on failure
"""
report_dir.mkdir(parents=True, exist_ok=True)
report_stem = f"Optimizer_{run_id}"
for attempt in range(1, self.MAX_RETRIES + 2):
is_retry = attempt > 1
if is_retry:
logger.warning(f"[{run_id}] Retry attempt {attempt}...")
try:
# ── Step 1: Kill any running MT5 ─────────────────────────
self._kill_mt5(run_id)
# ── Step 1b: Clear stale reports from previous runs ──────
self._clear_stale_reports(run_id)
# ── Step 2: Pre-run validation ───────────────────────────
self._validate(run_id)
# ── Step 3: Launch fresh MT5 ─────────────────────────────
proc = self._launch_mt5(run_id, ini_path)
# ── Step 4: Wait for MT5 to initialize + data ────────────
logger.info(f"[{run_id}] Waiting {self.MT5_INIT_WAIT_S}s for MT5 to initialize...")
time.sleep(self.MT5_INIT_WAIT_S)
# ── Step 5: Wait for report + TradeLog ───────────────────
result = self._wait_for_report(run_id, proc, report_dir, report_stem)
# ── Step 6: Find TradeLogger CSV ─────────────────────────
result.trade_log_csv = self._find_trade_log(run_id, log_csv_search_dir)
logger.success(f"[{run_id}] Run complete. Report: {result.report_xml}")
return result
except MT5ValidationError as e:
logger.error(f"[{run_id}] Validation error: {e}")
return RunResult(run_id=run_id, success=False, error_message=str(e))
except Exception as e:
logger.warning(f"[{run_id}] Attempt {attempt} failed: {e}")
if attempt > self.MAX_RETRIES:
msg = self._diagnose_failure(str(e))
logger.error(f"[{run_id}] All retries exhausted. {msg}")
return RunResult(run_id=run_id, success=False, error_message=msg)
logger.info(f"[{run_id}] Will retry after {self.KILL_WAIT_S}s...")
time.sleep(self.KILL_WAIT_S)
# ── Step 1: Kill existing MT5 ─────────────────────────────────────────────
def _kill_mt5(self, run_id: str) -> None:
"""Find and terminate any running MT5 processes."""
killed = 0
for proc in psutil.process_iter(["pid", "name", "exe"]):
try:
if proc.info["name"] and self.MT5_EXE_NAME.lower() in proc.info["name"].lower():
logger.info(f"[{run_id}] Closing existing MT5 (PID {proc.pid})...")
proc.terminate()
try:
proc.wait(timeout=5)
except psutil.TimeoutExpired:
proc.kill()
killed += 1
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
if killed > 0:
logger.info(f"[{run_id}] Closed {killed} MT5 instance(s). Waiting {self.KILL_WAIT_S}s...")
time.sleep(self.KILL_WAIT_S)
else:
logger.debug(f"[{run_id}] No existing MT5 process found.")
# ── Step 2: Pre-run validation ────────────────────────────────────────────
def _validate(self, run_id: str) -> None:
"""Validate all required files and directories exist before launch."""
errors = []
# Check terminal executable
if not self.terminal_exe.exists():
errors.append(f"MT5 terminal not found: {self.terminal_exe}")
# Check EA compiled file
ea_name = self.cfg["ea"]["file"]
ea_candidates = [
self.appdata_path / "MQL5" / "Experts" / f"{ea_name}.ex5",
self.appdata_path / "MQL5" / "Experts" / f"{ea_name}",
]
ea_found = any(p.exists() for p in ea_candidates)
if not ea_found:
errors.append(
f"EA file not found: {ea_name}.ex5 — "
f"ensure you compiled the EA in MetaEditor before running."
)
# Check appdata path
if not self.appdata_path.exists():
errors.append(f"MT5 appdata folder not found: {self.appdata_path}")
# Symbol check (basic — just ensure it's set)
symbol = self.cfg["ea"].get("symbol", "")
if not symbol:
errors.append("Symbol not configured in config.yaml ea.symbol")
if errors:
for e in errors:
logger.error(f"[{run_id}] Validation: {e}")
raise MT5ValidationError(
"Pre-run validation failed:\n" + "\n".join(f"{e}" for e in errors)
)
logger.debug(f"[{run_id}] Pre-run validation passed.")
# ── Step 3: Launch MT5 ───────────────────────────────────────────────────
def _launch_mt5(self, run_id: str, ini_path: Path) -> subprocess.Popen:
"""Launch a fresh MT5 instance with the given INI config."""
cmd = [str(self.terminal_exe), f"/config:{ini_path}"]
logger.info(f"[{run_id}] Launching MT5: {' '.join(cmd)}")
proc = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
logger.debug(f"[{run_id}] MT5 PID: {proc.pid}")
return proc
# ── Step 5: Wait for report ───────────────────────────────────────────────
def _wait_for_report(
self,
run_id: str,
proc: subprocess.Popen,
report_dir: Path,
report_stem: str,
) -> RunResult:
"""
Poll both our local dir and MT5's native reports folder.
MT5 writes the report to <appdata>\\reports\\ using the name from INI Report= field.
"""
elapsed = 0
search_dirs = [report_dir, self.mt5_reports_dir]
while elapsed < self.timeout_s:
# Check for report in all locations
for search in search_dirs:
if not search.exists():
continue
result = self._find_report_files(search, report_stem)
if result:
xml_path, html_path = result
time.sleep(self.PROCESS_SETTLE_S)
# Archive to our local report dir
if xml_path:
report_dir.mkdir(parents=True, exist_ok=True)
dest = report_dir / Path(xml_path).name
if not dest.exists():
shutil.copy2(xml_path, dest)
logger.info(f"[{run_id}] Report found in {search} after {elapsed}s")
return RunResult(
run_id=run_id,
report_xml=xml_path,
report_html=html_path,
success=True,
)
# Check if process already exited
ret = proc.poll()
if ret is not None:
time.sleep(self.PROCESS_SETTLE_S)
# Final check after process exit
for search in search_dirs:
if not search.exists():
continue
result = self._find_report_files(search, report_stem)
if result:
xml_path, html_path = result
return RunResult(
run_id=run_id,
report_xml=xml_path,
report_html=html_path,
success=True,
)
# Process exited but no report — diagnose
if ret != 0:
raise RuntimeError(
f"MT5 exited with error code {ret}. "
f"Possible causes: invalid INI parameters, EA not compiled, "
f"or missing historical data."
)
else:
raise RuntimeError(
"MT5 exited without generating a report. "
"Possible causes: Symbol data not downloaded, "
"invalid date range, or EA failed to initialize."
)
time.sleep(self.POLL_INTERVAL_S)
elapsed += self.POLL_INTERVAL_S
if elapsed % 30 == 0:
logger.info(f"[{run_id}] Still waiting for report... {elapsed}/{self.timeout_s}s")
raise MT5TimeoutError(
f"No report after {self.timeout_s}s. "
f"MT5 may be stuck or the test is taking too long. "
f"Consider reducing the test date range or using OHLC M1 model."
)
def _clear_stale_reports(self, run_id: str) -> None:
"""Remove stale Optimizer_* reports from MT5 appdata root to avoid false-positive detection."""
try:
import glob
# Only delete files NOT matching the current run_id
for pattern in ["*.htm", "*.xml", "*.html"]:
for f in self.mt5_reports_dir.glob(f"Optimizer_*{pattern[-3:]}"):
if run_id not in f.name:
try:
f.unlink()
logger.debug(f"[{run_id}] Cleared stale report: {f.name}")
except Exception:
pass
except Exception as e:
logger.debug(f"[{run_id}] Could not clear stale reports: {e}")
def _find_report_files(
self, search_dir: Path, report_stem: str
) -> Optional[tuple[Optional[str], Optional[str]]]:
"""Search for report XML/HTML files by stem prefix."""
xml_list = sorted(
list(search_dir.glob(f"{report_stem}*.xml")) +
list(search_dir.glob(f"{report_stem}*.XML")),
key=lambda p: p.stat().st_mtime, reverse=True
)
htm_list = sorted(
list(search_dir.glob(f"{report_stem}*.htm")) +
list(search_dir.glob(f"{report_stem}*.html")) +
list(search_dir.glob(f"{report_stem}*.HTM")),
key=lambda p: p.stat().st_mtime, reverse=True
)
if xml_list or htm_list:
return (
str(xml_list[0]) if xml_list else None,
str(htm_list[0]) if htm_list else None,
)
return None
# ── TradeLogger CSV lookup ────────────────────────────────────────────────
def _find_trade_log(
self, run_id: str, search_dir: Optional[Path]
) -> Optional[Path]:
"""Find the TradeLogger CSV written by the EA during the backtest."""
ea_name = self.cfg["ea"]["file"]
symbol = self.cfg["ea"]["symbol"]
candidates = [
self.mql5_files_dir / f"{ea_name}_{symbol}_TradeLog.csv",
]
if search_dir:
candidates.append(search_dir / f"{ea_name}_{symbol}_TradeLog.csv")
for path in candidates:
if path.exists():
logger.debug(f"[{run_id}] TradeLog found: {path}")
return path
logger.debug(f"[{run_id}] TradeLog CSV not found (fallback to report-only mode).")
return None
# ── Error diagnosis ───────────────────────────────────────────────────────
def _diagnose_failure(self, error_msg: str) -> str:
"""Convert technical errors to actionable user-facing messages."""
msg = error_msg.lower()
if "exit" in msg and "cleanly" in msg:
return (
"MT5 started but did not produce a report.\n"
"✦ Check that XAUUSD historical data is downloaded in MT5\n"
"✦ Ensure the date range (2022-2023) has data available\n"
"✦ Verify the EA compiled successfully in MetaEditor"
)
if "timeout" in msg:
return (
f"MT5 tester timed out after {self.timeout_s}s.\n"
"✦ Try a shorter date range in config.yaml\n"
"✦ Switch tester_model to 4 (OHLC M1) for faster runs"
)
if "validation" in msg or "not found" in msg:
return error_msg
return (
f"MT5 run failed: {error_msg}\n"
"✦ Ensure MT5 is fully closed before starting the optimizer\n"
"✦ Check config.yaml paths are correct"
)
View File
+247
View File
@@ -0,0 +1,247 @@
"""
mutation/engine.py
Translates analysis findings into concrete parameter hypotheses.
Uses knowledge_base.yaml rules as a structured ruleset.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Optional
import numpy as np
import yaml
from loguru import logger
from data.models import Finding, Hypothesis
class MutationEngine:
"""
Finding → Hypothesis translator.
Workflow:
1. Load knowledge_base.yaml rules
2. For each finding, find matching rules
3. Filter rules already tested recently (dedup)
4. Resolve dynamic mutation values (percentile-based, derived)
5. Build Hypothesis objects
6. Return sorted by estimated PnL impact
"""
def __init__(
self,
kb_path: str | Path = "mutation/knowledge_base.yaml",
manifest_path: str | Path = "mutation/param_manifest.yaml",
dedup_lookback: int = 10,
):
with open(kb_path) as f:
self.kb = yaml.safe_load(f)["rules"]
with open(manifest_path) as f:
self.manifest = yaml.safe_load(f)["parameters"]
self.dedup_lookback = dedup_lookback
# ── Public ────────────────────────────────────────────────────────────────
def propose(
self,
findings: list[Finding],
current_params: dict[str, Any],
recent_deltas: list[dict], # from store.get_recent_param_deltas()
max_proposals: int = 3,
) -> list[Hypothesis]:
"""
Generate hypotheses from findings, de-duplicate, and return top-N.
"""
hypotheses: list[Hypothesis] = []
for finding in findings:
for rule in self.kb:
if not self._rule_matches(rule, finding, current_params):
continue
param_delta = self._build_delta(rule, finding, current_params)
if not param_delta:
continue
# Skip if identical delta was recently tested
if self._already_tested(param_delta, recent_deltas):
logger.debug(f"Skipping rule {rule['id']} — already tested.")
continue
h = Hypothesis(
parent_run_id=finding.run_id,
finding_ids=[finding.finding_id],
description=f"[{rule['id']}] {rule['action_label']}",
param_delta=param_delta,
strategy=rule.get("strategy", "targeted"),
kb_rule_id=rule["id"],
)
hypotheses.append((h, finding.impact_estimate_pnl))
# Sort by impact descending, deduplicate by KB rule
seen_rules = set()
ranked: list[Hypothesis] = []
for h, impact in sorted(hypotheses, key=lambda x: x[1], reverse=True):
if h.kb_rule_id not in seen_rules:
seen_rules.add(h.kb_rule_id)
ranked.append(h)
if len(ranked) >= max_proposals:
break
logger.info(f"Proposed {len(ranked)} hypotheses from {len(findings)} findings.")
return ranked
# ── Rule matching ─────────────────────────────────────────────────────────
def _rule_matches(
self, rule: dict, finding: Finding, current_params: dict
) -> bool:
"""Check if a KB rule's trigger matches this finding and current params."""
trigger = rule.get("trigger", {})
# Analyzer match
if trigger.get("analyzer") and trigger["analyzer"] != finding.analyzer:
return False
# Evaluate condition expression against finding evidence + current params
condition = trigger.get("condition", "")
if condition:
env = {**finding.evidence, **current_params}
# Simple boolean parsing for conditions like "reversal_rate > 0.15"
try:
if not self._eval_condition(condition, env):
return False
except Exception as e:
logger.debug(f"Rule {rule['id']} condition eval error: {e}")
return False
return True
def _eval_condition(self, condition: str, env: dict) -> bool:
"""
Evaluate a simple condition string.
Supports: >, <, >=, <=, ==, AND, OR
Variables are looked up in env dict.
"""
# Replace variable names with their values
tokens = condition.split()
resolved_tokens = []
for token in tokens:
if token in ("AND", "OR", "and", "or", ">", "<", ">=", "<=", "==", "!="):
resolved_tokens.append(token.lower())
elif token in env:
val = env[token]
resolved_tokens.append(str(val) if not isinstance(val, str) else f'"{val}"')
else:
resolved_tokens.append(token)
expr = " ".join(resolved_tokens)
return bool(eval(expr, {"__builtins__": {}})) # restricted eval
# ── Delta building ────────────────────────────────────────────────────────
def _build_delta(
self,
rule: dict,
finding: Finding,
current_params: dict,
) -> dict[str, Any]:
"""
Translate a KB mutation spec into a concrete {param_name: new_value} dict.
Handles: set, multiply, derive_from, set_to_percentile.
"""
delta: dict[str, Any] = {}
mutations = rule.get("mutations", {})
for param_name, mutation_spec in mutations.items():
current = current_params.get(param_name)
spec = self.manifest.get(param_name, {})
ptype = spec.get("type", "float")
p_min = spec.get("min")
p_max = spec.get("max")
new_val = self._resolve_mutation(
mutation_spec, current, ptype, p_min, p_max, finding
)
if new_val is not None:
delta[param_name] = new_val
# Auto-cascade: if enabling a bool, set defaults for depends_on params
if ptype == "bool" and new_val is True:
delta.update(self._cascade_dependencies(param_name, current_params))
return delta
def _resolve_mutation(
self,
spec: dict | Any,
current: Any,
ptype: str,
p_min: Optional[float],
p_max: Optional[float],
finding: Finding,
) -> Optional[Any]:
"""Resolve a single mutation spec into a concrete value."""
if not isinstance(spec, dict):
return spec # bare value
# set: directly set to a value
if "set" in spec:
return spec["set"]
# multiply: multiply current value by factor
if "multiply" in spec and current is not None:
result = float(current) * spec["multiply"]
if "clamp_min" in spec:
result = max(spec["clamp_min"], result)
if p_min is not None:
result = max(p_min, result)
if p_max is not None:
result = min(p_max, result)
return round(result, 2) if ptype == "float" else int(result)
# set_to_percentile: use Nth percentile of a finding evidence list
if "set_to_percentile" in spec:
pct = spec["set_to_percentile"] / 100.0
data = finding.evidence.get("mfe_pips_distribution", [])
if data:
val = float(np.percentile(data, pct * 100))
if "scale" in spec:
val *= spec["scale"]
if p_min is not None:
val = max(p_min, val)
if p_max is not None:
val = min(p_max, val)
return round(val, 1)
# derive_from: use evidence field
if "derive_from" in spec:
key = spec["derive_from"]
val = finding.evidence.get(key)
if val is not None:
return val
return None
def _cascade_dependencies(
self, bool_param: str, current_params: dict
) -> dict[str, Any]:
"""When a bool param is enabled, fill in sensible defaults for its dependents."""
cascade = {}
for name, spec in self.manifest.items():
if spec.get("depends_on") != bool_param:
continue
# Only set if not already in current params or at a sub-optimal default
if name not in current_params:
cascade[name] = spec.get("default", 0)
return cascade
# ── Deduplication ─────────────────────────────────────────────────────────
def _already_tested(self, delta: dict, recent_deltas: list[dict]) -> bool:
"""Check if an identical param delta was tested recently."""
delta_str = json.dumps(delta, sort_keys=True)
for past in recent_deltas:
if json.dumps(past, sort_keys=True) == delta_str:
return True
return False
+187
View File
@@ -0,0 +1,187 @@
rules:
# ── Trailing / Exit Rules ────────────────────────────────────────────────────
- id: KB001
trigger:
analyzer: reversal
condition: "reversal_rate > 0.15"
action_label: "Enable trailing stop to protect in-profit trades (reversal rate high)"
mutations:
InpUseTrailing:
set: true
InpTrailStartPips:
derive_from: "mfe_p25" # 25th percentile of reversal MFE
fallback: 20.0
InpTrailStepPips:
set: 10.0
strategy: targeted
- id: KB002
trigger:
analyzer: reversal
condition: "reversal_rate > 0.20"
action_label: "Tighten TP ratio — too many trades reversing before target hit"
mutations:
InpRRRatio:
multiply: 0.80
clamp_min: 1.0
strategy: targeted
- id: KB003
trigger:
analyzer: reversal
condition: "mean_capture_ratio < 0.55"
action_label: "Low MFE capture on winners — enable or tighten trailing"
mutations:
InpUseTrailing:
set: true
InpTrailStartPips:
set: 15.0
InpTrailStepPips:
set: 8.0
strategy: targeted
# ── Session / Time Filter Rules ───────────────────────────────────────────────
- id: KB004
trigger:
analyzer: time_performance
condition: "type == 'hour_window'"
action_label: "Exclude identified negative-edge UTC time window via session filter"
mutations:
InpUseSession:
set: true
InpSessionEnd:
derive_from: "broker_start" # end session before bad window starts
strategy: targeted
- id: KB005
trigger:
analyzer: time_performance
condition: "type == 'session'"
action_label: "Negative-edge session detected — tighten or disable session window"
mutations:
InpUseSession:
set: true
strategy: targeted
# ── Entry Quality Rules ────────────────────────────────────────────────────────
- id: KB006
trigger:
analyzer: entry_exit_quality
condition: "diagnosis == 'poor_entry'"
action_label: "Poor entry quality — tighten ATR filter and score gate"
mutations:
InpUseSpreadGuard:
set: true
InpMinScore:
multiply: 1.125
clamp_min: 6
InpATRMultiplier:
multiply: 1.20
clamp_min: 0.3
strategy: targeted
- id: KB007
trigger:
analyzer: entry_exit_quality
condition: "diagnosis == 'good_entry_poor_exit'"
action_label: "Good entries, poor exits — enable trailing with conservative start"
mutations:
InpUseTrailing:
set: true
InpTrailStartPips:
set: 18.0
InpTrailStepPips:
set: 10.0
strategy: targeted
- id: KB008
trigger:
analyzer: entry_exit_quality
condition: "diagnosis == 'both_broken'"
action_label: "Both entry and exit quality poor — test conservative bot mode"
mutations:
InpBotMode:
set: 2
InpMinScore:
multiply: 1.25
clamp_min: 6
strategy: compound
# ── Risk / Drawdown Rules ─────────────────────────────────────────────────────
- id: KB009
trigger:
analyzer: equity_curve
condition: "flatness_score > 0.50"
action_label: "Equity spending too much time in drawdown — reduce risk per trade"
mutations:
InpRiskPercent:
multiply: 0.75
clamp_min: 0.5
InpMaxDailyLossPct:
multiply: 0.80
clamp_min: 1.0
strategy: targeted
- id: KB010
trigger:
analyzer: equity_curve
condition: "cluster_count > 3"
action_label: "Repeated loss clusters — limit consecutive trades and daily risk"
mutations:
InpMaxTradesPerDay:
multiply: 0.75
clamp_min: 2
InpMaxDailyLossPct:
set: 2.0
strategy: targeted
# ── Breakeven Rules ────────────────────────────────────────────────────────────
- id: KB011
trigger:
analyzer: reversal
condition: "reversal_rate > 0.12"
action_label: "Enable breakeven stop to lock in partial profit before reversal"
mutations:
InpUseBreakeven:
set: true
InpBEPips:
derive_from: "mfe_p25"
fallback: 15.0
InpBEBufferPips:
set: 2.0
strategy: targeted
# ── Filter Tightening Rules ────────────────────────────────────────────────────
- id: KB012
trigger:
analyzer: entry_exit_quality
condition: "high_mae_loser_count > 10"
action_label: "High MAE losers — require EMA slope confirmation"
mutations:
InpUseEMA:
set: true
InpRequireEMASlope:
set: true
InpEMASlopeBars:
set: 2
strategy: targeted
- id: KB013
trigger:
analyzer: entry_exit_quality
condition: "mean_entry_quality < 0.35"
action_label: "Very poor entry quality — tighten minimum RR gate"
mutations:
InpUseMinRR:
set: true
InpMinRRRatio:
multiply: 1.25
clamp_min: 1.0
strategy: targeted
+394
View File
@@ -0,0 +1,394 @@
# Parameter Manifest — LEGSTECH_EA_V2
# Generated from: LEGSTECH_EA_V2.set
# Format: value = default, min/max/step = optimization bounds
# Types: float | int | bool | enum
# ─────────────────────────────────────────────────────────────
parameters:
# ── Bot / Mode ─────────────────────────────────────────────
InpBotMode:
type: enum
values: [0, 1, 2] # 0, 1, 2 per .set range 0→2 step 1
default: 1
category: mode
description: "Bot operating mode"
# ── Timeframe Selection (PERIOD_ codes) ────────────────────
# These are MT5 ENUM_TIMEFRAMES integer codes. Not optimized — fixed.
InpHTF:
type: fixed
default: 16408 # PERIOD_H4
category: timeframe
InpMTF:
type: fixed
default: 16388 # PERIOD_H1
category: timeframe
InpLTF:
type: fixed
default: 16385 # PERIOD_M30
category: timeframe
# ── Risk Management ────────────────────────────────────────
InpRiskType:
type: enum
values: [0, 1] # 0=fixed lot, 1=percent risk
default: 1
category: risk
InpFixedLot:
type: float
min: 0.01
max: 1.0
step: 0.01
default: 0.01
category: risk
depends_on_value: {InpRiskType: 0} # only active when using fixed lot mode
InpRiskPercent:
type: float
min: 0.5
max: 3.0
step: 0.5
default: 1.0
category: risk
depends_on_value: {InpRiskType: 1} # only active when using percent risk mode
InpMaxDailyLossPct:
type: float
min: 1.0
max: 5.0
step: 0.5
default: 3.0
category: risk
InpMaxTradesPerDay:
type: int
min: 1
max: 10
step: 1
default: 5
category: risk
# ── Stop Loss ──────────────────────────────────────────────
InpSLType:
type: enum
values: [0, 1] # 0=fixed pips, 1=ATR-based
default: 0
category: sl
InpSLBuffer:
type: float
min: 5.0
max: 30.0
step: 5.0
default: 10.0
category: sl
description: "Buffer pips added to SL"
InpFixedSLPips:
type: float
min: 50.0
max: 200.0
step: 10.0
default: 100.0
category: sl
depends_on_value: {InpSLType: 0}
InpMaxSLPips:
type: float
min: 100.0
max: 400.0
step: 50.0
default: 200.0
category: sl
description: "Hard cap on calculated SL size"
InpUseFractalSL:
type: bool
default: false # 0 in .set
category: sl
# ── Take Profit ────────────────────────────────────────────
InpTPType:
type: enum
values: [0, 1] # 0=RR ratio, 1=fixed pips
default: 0
category: tp
InpRRRatio:
type: float
min: 1.0
max: 3.0
step: 0.5
default: 1.5
category: tp
depends_on_value: {InpTPType: 0}
InpFixedTPPips:
type: float
min: 50.0
max: 200.0
step: 10.0
default: 100.0
category: tp
depends_on_value: {InpTPType: 1}
InpUseFractalFilter:
type: bool
default: false # 0 in .set
category: tp
# ── Session Filter ─────────────────────────────────────────
InpUseSession:
type: bool
default: true # 1 in .set
category: filter_session
InpSessionStart:
type: int
min: 0
max: 23
step: 1
default: 7
category: filter_session
depends_on: InpUseSession
description: "Session start hour (broker local time)"
InpSessionEnd:
type: int
min: 0
max: 23
step: 1
default: 20
category: filter_session
depends_on: InpUseSession
description: "Session end hour (broker local time)"
# ── Trade Limits ───────────────────────────────────────────
InpMaxOpenTrades:
type: int
min: 1
max: 3
step: 1
default: 1
category: risk
InpAllowMultiple:
type: bool
default: false # 0 in .set
category: risk
# ── Execution ─────────────────────────────────────────────
InpMagicNumber:
type: fixed
default: 202402
category: execution
description: "Fixed — do not optimize"
InpSlippage:
type: int
min: 5
max: 30
step: 5
default: 10
category: execution
# ── Trailing Stop ─────────────────────────────────────────
InpUseTrailing:
type: bool
default: true # 1 in .set
category: exit_trail
InpTrailStartPips:
type: float
min: 10.0
max: 50.0
step: 5.0
default: 20.0
category: exit_trail
depends_on: InpUseTrailing
InpTrailStepPips:
type: float
min: 5.0
max: 30.0
step: 5.0
default: 10.0
category: exit_trail
depends_on: InpUseTrailing
# ── Break Even ────────────────────────────────────────────
InpUseBreakeven:
type: bool
default: true # 1 in .set
category: exit_be
InpBEPips:
type: float
min: 10.0
max: 40.0
step: 5.0
default: 15.0
category: exit_be
depends_on: InpUseBreakeven
description: "Pips in profit to activate breakeven"
InpBEBufferPips:
type: float
min: 1.0
max: 5.0
step: 1.0
default: 2.0
category: exit_be
depends_on: InpUseBreakeven
description: "Buffer pips above entry for breakeven SL"
# ── EMA Filter ────────────────────────────────────────────
InpUseEMA:
type: bool
default: true # 1 in .set
category: filter_ema
InpEMAPeriod:
type: int
min: 20
max: 100
step: 10
default: 50
category: filter_ema
depends_on: InpUseEMA
InpRequireEMASlope:
type: bool
default: true # 1 in .set
category: filter_ema
depends_on: InpUseEMA
InpEMASlopeBars:
type: int
min: 1
max: 3
step: 1
default: 1
category: filter_ema
depends_on: InpRequireEMASlope
# ── Entry Mode ────────────────────────────────────────────
InpEntryMode:
type: enum
values: [0, 1]
default: 1
category: entry
InpSLBufferMode:
type: enum
values: [0, 1]
default: 1
category: sl
# ── ATR ───────────────────────────────────────────────────
InpATRPeriod:
type: int
min: 10
max: 20
step: 2
default: 14
category: atr
InpATRMultiplier:
type: float
min: 0.3
max: 1.0
step: 0.1
default: 0.5
category: atr
# ── Spread Guard ──────────────────────────────────────────
InpUseSpreadGuard:
type: bool
default: true # 1 in .set
category: filter_spread
InpMaxSpreadPips:
type: float
min: 10.0
max: 50.0
step: 5.0
default: 30.0
category: filter_spread
depends_on: InpUseSpreadGuard
# ── Minimum R:R Gate ──────────────────────────────────────
InpUseMinRR:
type: bool
default: true # 1 in .set
category: filter_rr
InpMinRRRatio:
type: float
min: 1.0
max: 3.0
step: 0.5
default: 1.5
category: filter_rr
depends_on: InpUseMinRR
# ── Score Gate ────────────────────────────────────────────
InpUseScoreGate:
type: bool
default: true # 1 in .set
category: filter_score
InpMinScore:
type: int
min: 6
max: 11
step: 1
default: 8
category: filter_score
depends_on: InpUseScoreGate
description: "Minimum signal quality score required to enter trade"
# ── Tester-Specific (fixed during automation) ─────────────
InpTesterMode:
type: fixed
default: 1
category: tester
description: "Must be 1 during automated backtesting"
InpTesterInitDeposit:
type: fixed
default: 10000.0
category: tester
InpTesterSpreadPts:
type: int
min: 10
max: 50
step: 5
default: 20
category: tester
description: "Spread in points used in tester (20 pts = 2.0 pips for XAUUSD)"
InpShowPanel:
type: fixed
default: 0 # force off during automation (no GUI needed)
category: tester
# ── Parameter categories (for mutation engine grouping) ──────
categories:
mode: [InpBotMode]
risk: [InpRiskType, InpFixedLot, InpRiskPercent, InpMaxDailyLossPct, InpMaxTradesPerDay, InpMaxOpenTrades, InpAllowMultiple]
sl: [InpSLType, InpSLBuffer, InpFixedSLPips, InpMaxSLPips, InpUseFractalSL, InpSLBufferMode]
tp: [InpTPType, InpRRRatio, InpFixedTPPips, InpUseFractalFilter]
exit_trail: [InpUseTrailing, InpTrailStartPips, InpTrailStepPips]
exit_be: [InpUseBreakeven, InpBEPips, InpBEBufferPips]
filter_session: [InpUseSession, InpSessionStart, InpSessionEnd]
filter_ema: [InpUseEMA, InpEMAPeriod, InpRequireEMASlope, InpEMASlopeBars]
filter_spread: [InpUseSpreadGuard, InpMaxSpreadPips]
filter_rr: [InpUseMinRR, InpMinRRRatio]
filter_score: [InpUseScoreGate, InpMinScore]
entry: [InpEntryMode]
atr: [InpATRPeriod, InpATRMultiplier]
tester: [InpTesterMode, InpTesterInitDeposit, InpTesterSpreadPts, InpShowPanel]
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+495
View File
@@ -0,0 +1,495 @@
"""
optimizer_loop.py
Background thread that runs the full optimization loop and emits
real-time SocketIO events to the dashboard.
"""
from __future__ import annotations
import json
import threading
import time
import uuid
from datetime import datetime
from pathlib import Path
from typing import Any, Optional
import yaml
from loguru import logger
# Project imports
from data.models import Run, RunMetrics, Candidate, Hypothesis
from data.store import DataStore
from mt5.ini_builder import IniBuilder
from mt5.runner import MT5Runner
from mt5.report_parser import ReportParser
from mt5.log_reader import TradeLogReader
from analysis.reversal import ReversalAnalyzer
from analysis.time_performance import TimePerformanceAnalyzer
from analysis.entry_exit_quality import EntryExitQualityAnalyzer
from analysis.equity_curve import EquityCurveAnalyzer
from scoring.composite import CompositeScorer
from mutation.engine import MutationEngine
from validation.gate import ValidationGate
from reports.writer import ReportWriter
import pandas as pd
BASE_DIR = Path(__file__).parent
MANIFEST_PATH = BASE_DIR / "mutation" / "param_manifest.yaml"
KB_PATH = BASE_DIR / "mutation" / "knowledge_base.yaml"
DB_PATH = BASE_DIR / "optimizer.db"
RUNS_DIR = BASE_DIR / "runs"
class OptimizerLoop:
"""
Runs the full optimization pipeline in a background thread.
Pushes events to the SocketIO broadcast channel so the dashboard
updates in real time.
"""
def __init__(self, config_path: str, socketio, reports_dir: Path, auto_mode: bool = True):
self.config_path = config_path
self.socketio = socketio
self.reports_dir = reports_dir
self.auto_mode = auto_mode
self.running = False
self.paused = False
self._stop_flag = False
self._skip_flag = False
self._pause_event = threading.Event()
self._pause_event.set() # not paused initially
# Live state (read by /api/status)
self.iteration = 0
self.phase = "idle"
self.best_score = 0.0
self.current_run_id: Optional[str] = None
self.score_history: list[dict] = []
self.run_start_ts: Optional[float] = None
with open(config_path) as f:
self.cfg = yaml.safe_load(f)
# ── Controls ──────────────────────────────────────────────────────────────
def toggle_pause(self):
self.paused = not self.paused
if self.paused:
self._pause_event.clear()
self._emit("status_change", {"state": "paused"})
else:
self._pause_event.set()
self._emit("status_change", {"state": "running"})
def stop(self):
self._stop_flag = True
self._pause_event.set()
self.running = False
def skip_hypothesis(self):
self._skip_flag = True
def get_status(self) -> dict:
elapsed = int(time.time() - self.run_start_ts) if self.run_start_ts else 0
return {
"state": "running" if self.running else ("paused" if self.paused else "idle"),
"iteration": self.iteration,
"phase": self.phase,
"best_score": round(self.best_score, 4),
"run_id": self.current_run_id,
"elapsed_s": elapsed,
}
# ── Main loop ─────────────────────────────────────────────────────────────
def run(self):
self.running = True
self._stop_flag = False
self.run_start_ts = time.time()
cfg = self.cfg
store, builder, runner, parser, log_rdr, analyzers, scorer, mutator, gate, writer = (
self._build_components()
)
per = cfg["periods"]
self._emit("status_change", {"state": "running", "phase": "baseline"})
self._emit("log", {"level": "info", "msg": "🚀 Optimizer started"})
# ── Baseline run ──────────────────────────────────────────────────────
self.phase = "baseline"
default_params = builder.default_params()
baseline_id = f"baseline_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}"
baseline_metrics, baseline_trades = self._execute_run(
run_id=baseline_id,
params=default_params,
period_start=per["train_start"],
period_end=per["train_end"],
phase="baseline",
hypothesis_id=None,
store=store, builder=builder, runner=runner,
parser=parser, log_rdr=log_rdr, analyzers=analyzers, scorer=scorer,
)
if baseline_metrics is None:
self._emit("error", {"msg": "Baseline run failed. Check MT5 configuration."})
self.running = False
return
# Write baseline report
findings = self._run_analysis(baseline_id, baseline_trades, baseline_metrics,
analyzers, store)
writer.write(baseline_id, baseline_metrics, baseline_trades, findings, default_params)
self.best_score = baseline_metrics.composite_score
current_params = default_params.copy()
current_metrics = baseline_metrics
no_improve_count = 0
max_iter = cfg["optimization"]["max_iterations"]
conv_win = cfg["optimization"]["convergence_window"]
conv_thr = cfg["optimization"]["convergence_threshold"]
# ── Iteration loop ────────────────────────────────────────────────────
while self.iteration < max_iter and not self._stop_flag:
self._pause_event.wait()
if self._stop_flag:
break
self.iteration += 1
self.phase = "analyze"
self._emit("iteration_start", {"iteration": self.iteration})
self._emit("log", {"level": "info", "msg": f"━━ Iteration {self.iteration} ━━"})
# Load latest trades (for re-analysis)
trades_df = store.load_trades(current_metrics.run_id)
if trades_df.empty and not baseline_trades.empty:
trades_df = baseline_trades
# Analysis
self.phase = "analyze"
findings = self._run_analysis(
current_metrics.run_id, trades_df, current_metrics, analyzers, store
)
if not findings:
self._emit("log", {"level": "warn", "msg": "No actionable findings. Stopping."})
break
# Mutation proposals
recent_deltas = store.get_recent_param_deltas(cfg["mutation"]["dedup_lookback_runs"])
hypotheses = mutator.propose(
findings=findings,
current_params=current_params,
recent_deltas=recent_deltas,
max_proposals=cfg["mutation"]["max_hypotheses_per_cycle"],
)
if not hypotheses:
self._emit("log", {"level": "warn", "msg": "No new hypotheses. Stopping."})
break
self._emit("hypotheses", {"items": [
{
"id": h.hypothesis_id,
"desc": h.description,
"delta": h.param_delta,
"strategy": h.strategy,
}
for h in hypotheses
]})
# Test each hypothesis
iteration_best: Optional[RunMetrics] = None
iteration_best_params: Optional[dict] = None
iteration_best_hyp: Optional[Hypothesis] = None
for idx, hyp in enumerate(hypotheses):
self._pause_event.wait()
if self._stop_flag or self._skip_flag:
self._skip_flag = False
break
test_params = {**current_params, **hyp.param_delta}
run_id = f"iter{self.iteration:03d}_h{idx+1}"
store.save_hypothesis(hyp)
self._emit("log", {
"level": "info",
"msg": f"Testing H{idx+1}: {hyp.description[:60]}"
})
self._emit("hypothesis_testing", {"idx": idx+1, "desc": hyp.description})
test_metrics, test_trades = self._execute_run(
run_id=run_id,
params=test_params,
period_start=per["train_start"],
period_end=per["train_end"],
phase="explore",
hypothesis_id=hyp.hypothesis_id,
store=store, builder=builder, runner=runner,
parser=parser, log_rdr=log_rdr, analyzers=analyzers, scorer=scorer,
)
if test_metrics is None:
continue
delta_score = test_metrics.composite_score - current_metrics.composite_score
color = "green" if delta_score > 0 else "red"
self._emit("log", {
"level": "success" if delta_score > 0 else "warn",
"msg": f"H{idx+1} score: {current_metrics.composite_score:.4f}"
f"{test_metrics.composite_score:.4f} ({delta_score:+.4f})"
})
# Write per-run report
h_findings = self._run_analysis(run_id, test_trades, test_metrics, analyzers, store)
writer.write(run_id, test_metrics, test_trades, h_findings, test_params,
hypothesis=hyp, baseline_score=current_metrics.composite_score)
store.update_hypothesis_status(hyp.hypothesis_id, "tested", run_id)
if iteration_best is None or test_metrics.composite_score > iteration_best.composite_score:
iteration_best = test_metrics
iteration_best_params = test_params
iteration_best_hyp = hyp
if iteration_best is None:
no_improve_count += 1
continue
# Validation gate
self.phase = "validate"
gate_result = gate.run_is_check(iteration_best)
if not gate_result.passed:
self._emit("log", {"level": "error", "msg": f"IS gate failed: {gate_result.reason}"})
store.update_hypothesis_status(iteration_best_hyp.hypothesis_id, "rejected")
no_improve_count += 1
continue
# Walk-forward
self._emit("log", {"level": "info", "msg": "Running walk-forward validation..."})
wfv = gate.run_walk_forward(
iteration_best_params, cfg, store, builder, runner,
parser, log_rdr, analyzers, scorer,
)
self._emit("log", {
"level": "success" if wfv.passed else "warn",
"msg": f"WFV: OOS/IS ratio = {wfv.oos_is_ratio:.2f} "
f"({'PASS ✅' if wfv.passed else 'FAIL ❌'})"
})
if not wfv.passed:
store.update_hypothesis_status(iteration_best_hyp.hypothesis_id, "rejected")
no_improve_count += 1
continue
# Promote candidate
candidate = Candidate(
run_id=iteration_best.run_id,
composite_score=iteration_best.composite_score,
params=iteration_best_params,
)
store.save_candidate(candidate)
store.update_hypothesis_status(iteration_best_hyp.hypothesis_id, "validated")
self._emit("candidate_promoted", {
"candidate_id": candidate.candidate_id,
"score": round(candidate.composite_score, 4),
"delta": round(candidate.composite_score - self.best_score, 4),
"params": candidate.params,
})
self._emit("log", {
"level": "success",
"msg": f"✅ Candidate promoted! Score: {candidate.composite_score:.4f}"
})
improvement = iteration_best.composite_score - self.best_score
if improvement >= conv_thr:
current_params = iteration_best_params
current_metrics = iteration_best
self.best_score = iteration_best.composite_score
no_improve_count = 0
else:
no_improve_count += 1
# Update score chart
self.score_history.append({
"iteration": self.iteration,
"score": round(self.best_score, 4),
"calmar": round(current_metrics.calmar_ratio, 4),
"pf": round(current_metrics.profit_factor, 4),
"ts": datetime.utcnow().isoformat(),
})
self._emit("score_update", self.score_history[-1])
if no_improve_count >= conv_win:
self._emit("log", {
"level": "warn",
"msg": f"Converged: no improvement in {no_improve_count} iterations."
})
break
# ── Done ─────────────────────────────────────────────────────────────
candidates = store.list_candidates()
self._emit("optimization_complete", {
"candidates": len(candidates),
"best_score": round(self.best_score, 4),
"iterations": self.iteration,
})
self._emit("log", {"level": "success", "msg": "🏁 Optimization complete!"})
self.running = False
self.phase = "idle"
# ── Single run ────────────────────────────────────────────────────────────
def _execute_run(
self,
run_id, params, period_start, period_end, phase, hypothesis_id,
store, builder, runner, parser, log_rdr, analyzers, scorer,
):
cfg = self.cfg
self.current_run_id = run_id
run_dir = RUNS_DIR / run_id
run_dir.mkdir(parents=True, exist_ok=True)
self._emit("run_started", {
"run_id": run_id,
"phase": phase,
"period": f"{period_start}{period_end}",
"params": {k: v for k, v in list(params.items())[:8]}, # first 8 for display
})
ini_path = builder.build(
run_id=run_id, params=params,
period_start=period_start, period_end=period_end,
output_dir=run_dir, phase=phase,
)
run = Run(
run_id=run_id, ea_name=cfg["ea"]["name"],
symbol=cfg["ea"]["symbol"], timeframe=cfg["ea"]["timeframe"],
period_start=period_start, period_end=period_end,
params=params, phase=phase, hypothesis_id=hypothesis_id,
tester_model=cfg["mt5"]["tester_model"],
ini_snapshot=ini_path.read_text(),
)
store.save_run(run)
self._emit("log", {"level": "info", "msg": f"⏳ MT5 running: {run_id}"})
result = runner.run(run_id, ini_path, run_dir / "report",
log_csv_search_dir=Path(cfg["mt5"]["mql5_files_path"]))
if not result.success:
self._emit("run_failed", {"run_id": run_id, "error": result.error_message})
return None, pd.DataFrame()
metrics, trades = parser.parse(result.report_xml, result.report_html)
if metrics is None:
self._emit("run_failed", {"run_id": run_id, "error": "Could not parse report"})
return None, pd.DataFrame()
metrics.run_id = run_id
if trades:
trades = log_rdr.merge(
trades, result.trade_log_csv,
reversal_mfe_threshold_pips=cfg["analysis"]["reversal"]["mfe_threshold_pips"],
)
trades_df = pd.DataFrame([t.model_dump() for t in trades])
else:
trades_df = pd.DataFrame()
if not trades_df.empty:
if "result_class" in trades_df.columns:
losers = trades_df[trades_df["net_money"] < 0]
reversals = trades_df[trades_df["result_class"] == "reversal"]
metrics.reversal_rate = len(reversals) / max(1, len(losers))
if "mfe_capture_ratio" in trades_df.columns:
metrics.avg_mfe_capture = float(trades_df["mfe_capture_ratio"].dropna().mean() or 0)
metrics.composite_score = scorer.score(metrics)
store.save_metrics(metrics)
if not trades_df.empty:
store.save_trades(run_id, trades)
run.report_path = result.report_xml
store.save_run(run)
self._emit("run_complete", {
"run_id": run_id,
"phase": phase,
"net_profit": round(metrics.net_profit, 2),
"profit_factor": round(metrics.profit_factor, 3),
"calmar": round(metrics.calmar_ratio, 3),
"drawdown_pct": round(metrics.max_drawdown_pct * 100, 1),
"win_rate": round(metrics.win_rate * 100, 1),
"total_trades": metrics.total_trades,
"score": round(metrics.composite_score, 4),
"reversal_rate": round((metrics.reversal_rate or 0) * 100, 1),
"mfe_capture": round((metrics.avg_mfe_capture or 0) * 100, 1),
})
return metrics, trades_df
def _run_analysis(self, run_id, trades_df, metrics, analyzers, store):
all_findings = []
for az in analyzers:
findings = az.run(trades_df, metrics, run_id)
for f in findings:
self._emit("finding", {
"analyzer": f.analyzer,
"severity": f.severity,
"description": f.description,
"confidence": round(f.confidence, 2),
"impact": round(f.impact_estimate_pnl, 0),
})
all_findings.extend(findings)
all_findings.sort(key=lambda f: f.confidence, reverse=True)
store.save_findings(all_findings)
return all_findings
# ── SocketIO emit helper ──────────────────────────────────────────────────
def _emit(self, event: str, data: dict = {}):
try:
self.socketio.emit(event, data)
except Exception as e:
logger.debug(f"Emit error ({event}): {e}")
# ── Component factory ─────────────────────────────────────────────────────
def _build_components(self):
cfg = self.cfg
store = DataStore(DB_PATH, RUNS_DIR)
builder = IniBuilder(self.config_path, str(MANIFEST_PATH))
runner = MT5Runner(self.config_path)
parser = ReportParser()
log_rdr = TradeLogReader(
broker_tz_offset_hours=cfg["broker"]["timezone_offset_hours"],
)
analyzers = [
ReversalAnalyzer(
mfe_threshold_pips=cfg["analysis"]["reversal"]["mfe_threshold_pips"],
min_reversal_rate=cfg["analysis"]["reversal"]["min_reversal_rate"],
permutation_n=cfg["analysis"]["reversal"]["permutation_n"],
),
TimePerformanceAnalyzer(
z_score_threshold=cfg["analysis"]["time_performance"]["z_score_threshold"],
min_bucket_trades=cfg["analysis"]["time_performance"]["min_trades_per_bucket"],
permutation_n=cfg["analysis"]["time_performance"]["permutation_n"],
),
EntryExitQualityAnalyzer(
poor_exit_threshold=cfg["analysis"]["entry_exit"]["poor_exit_quality"],
poor_entry_threshold=cfg["analysis"]["entry_exit"]["poor_entry_quality"],
),
EquityCurveAnalyzer(
max_flatness=cfg["analysis"]["equity_curve"]["max_flatness_score"],
min_r_squared=cfg["analysis"]["equity_curve"]["min_r_squared"],
),
]
scorer = CompositeScorer(self.config_path)
mutator = MutationEngine(KB_PATH, MANIFEST_PATH,
dedup_lookback=cfg["mutation"]["dedup_lookback_runs"])
gate = ValidationGate(self.config_path)
writer = ReportWriter(self.reports_dir)
return store, builder, runner, parser, log_rdr, analyzers, scorer, mutator, gate, writer
View File
+17
View File
@@ -0,0 +1,17 @@
pandas>=2.1
numpy>=1.26
pyarrow>=14.0
lxml>=4.9
pydantic>=2.5
pyyaml>=6.0
loguru>=0.7
rich>=13.0
sqlalchemy>=2.0
scipy>=1.11
plotly>=5.18
pytest>=7.4
flask
flask-socketio
eventlet
jinja2
pyinstaller
View File
+174
View File
@@ -0,0 +1,174 @@
"""
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),
}
+282
View File
@@ -0,0 +1,282 @@
"""
tests/test_analyzers.py
Unit tests for all analyzer modules using synthetic trade data.
"""
import pytest
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
from data.models import RunMetrics
from analysis.reversal import ReversalAnalyzer
from analysis.time_performance import TimePerformanceAnalyzer
from analysis.entry_exit_quality import EntryExitQualityAnalyzer
from analysis.equity_curve import EquityCurveAnalyzer
# ── Fixtures ──────────────────────────────────────────────────────────────────
def make_trade(
net_money: float,
mfe_pips: float = 0,
mae_pips: float = 0,
hour_utc: int = 10,
session: str = "London",
day_of_week: int = 1,
result_class: str = None,
duration_minutes: int = 60,
lot_size: float = 0.1,
net_pips: float = None,
open_time: datetime = None,
) -> dict:
if result_class is None:
result_class = "win" if net_money > 0 else ("reversal" if mfe_pips > 15 and net_money < 0 else "loss")
if net_pips is None:
net_pips = net_money / 100
if open_time is None:
open_time = datetime(2022, 1, 3, hour_utc, 0)
return {
"ticket": np.random.randint(100000, 999999),
"open_time": open_time,
"close_time": open_time + timedelta(minutes=duration_minutes),
"direction": "buy",
"open_price": 1900.0,
"close_price": 1900.0 + net_pips * 0.1,
"sl": 1880.0, "tp": 1920.0,
"lot_size": lot_size,
"net_money": net_money,
"net_pips": net_pips,
"duration_minutes": duration_minutes,
"commission": 0.0, "swap": 0.0,
"mfe_pips": mfe_pips, "mae_pips": mae_pips,
"session": session, "day_of_week": day_of_week,
"hour_utc": hour_utc, "hour_broker": (hour_utc + 2) % 24,
"result_class": result_class,
"mfe_capture_ratio": max(0, net_money) / max(1, mfe_pips * 10),
"entry_quality": max(0, 1 - mae_pips / max(mfe_pips + mae_pips, 1)),
"exit_quality": max(0, net_pips / max(mfe_pips, 1)),
}
def dummy_metrics(trades_df: pd.DataFrame, run_id: str = "test_run") -> RunMetrics:
wins = trades_df[trades_df["net_money"] > 0]
losses = trades_df[trades_df["net_money"] < 0]
net = trades_df["net_money"].sum()
gp = wins["net_money"].sum()
gl = abs(losses["net_money"].sum())
pf = gp / gl if gl > 0 else 99.0
dd = abs(losses["net_money"].min()) if len(losses) > 0 else 1
return RunMetrics(
run_id=run_id,
net_profit=net,
profit_factor=pf,
max_drawdown_abs=dd,
max_drawdown_pct=dd / 10000,
calmar_ratio=max(0, net / 10000) / max(0.01, dd / 10000),
sharpe_ratio=1.2,
total_trades=len(trades_df),
win_rate=len(wins) / max(1, len(trades_df)),
avg_win=gp / max(1, len(wins)),
avg_loss=gl / max(1, len(losses)),
recovery_factor=2.0,
largest_loss=abs(losses["net_money"].min()) if len(losses) > 0 else 0,
expected_payoff=net / max(1, len(trades_df)),
)
# ── Reversal Analyzer Tests ───────────────────────────────────────────────────
class TestReversalAnalyzer:
def test_detects_high_reversal_rate(self):
"""Should find a HIGH finding when many losers had significant MFE."""
trades = []
# 50% of losers had MFE > 20 pips (high reversal rate)
for _ in range(40):
trades.append(make_trade(net_money=100, mfe_pips=30, mae_pips=5))
for _ in range(20):
trades.append(make_trade(net_money=-80, mfe_pips=25, mae_pips=8)) # reversals
for _ in range(20):
trades.append(make_trade(net_money=-80, mfe_pips=5, mae_pips=20)) # normal losers
df = pd.DataFrame(trades)
metrics = dummy_metrics(df)
az = ReversalAnalyzer(mfe_threshold_pips=15, min_reversal_rate=0.30, permutation_n=50)
findings = az.run(df, metrics, "test")
assert len(findings) > 0
top = findings[0]
assert top.severity in ("high", "medium")
assert "InpUseTrailing" in top.suggested_params
assert top.suggested_params["InpUseTrailing"] is True
def test_no_finding_when_low_reversal_rate(self):
"""Should NOT find reversals when rate is below threshold."""
trades = [make_trade(net_money=100, mfe_pips=20)] * 50
trades += [make_trade(net_money=-80, mfe_pips=5)] * 20 # only small-MFE losers
df = pd.DataFrame(trades)
metrics = dummy_metrics(df)
az = ReversalAnalyzer(mfe_threshold_pips=15, min_reversal_rate=0.30, permutation_n=50)
findings = az.run(df, metrics, "test")
# Should have no reversal-rate finding (maybe only capture rate)
reversal_findings = [f for f in findings if "reversal" in f.description.lower() and "% of losing" in f.description]
assert len(reversal_findings) == 0
# ── Time Performance Tests ────────────────────────────────────────────────────
class TestTimePerformanceAnalyzer:
def test_detects_bad_hour_window(self):
"""Should flag a consistent loss at hour 1415 UTC."""
trades = []
# Good trades at most hours
for h in [8, 9, 10, 11, 12, 13]:
for _ in range(12):
trades.append(make_trade(net_money=100, hour_utc=h))
# Consistently losing trades at 1415 UTC
for h in [14, 15]:
for _ in range(15):
trades.append(make_trade(net_money=-150, hour_utc=h))
df = pd.DataFrame(trades)
metrics = dummy_metrics(df)
az = TimePerformanceAnalyzer(z_score_threshold=-1.0, min_bucket_trades=8, permutation_n=200)
findings = az.run(df, metrics, "test")
hour_findings = [f for f in findings if "UTC" in f.description and "14" in f.description]
assert len(hour_findings) > 0
def test_no_finding_for_uniform_performance(self):
"""No time-based finding when performance is uniform across hours."""
trades = []
rng = np.random.default_rng(42)
for h in range(8, 20):
for _ in range(12):
pnl = float(rng.normal(50, 20))
trades.append(make_trade(net_money=pnl, hour_utc=h))
df = pd.DataFrame(trades)
metrics = dummy_metrics(df)
az = TimePerformanceAnalyzer(z_score_threshold=-2.0, min_bucket_trades=8, permutation_n=200)
findings = az.run(df, metrics, "test")
# May or may not find something; just verify it runs without error
assert isinstance(findings, list)
# ── Entry/Exit Quality Tests ──────────────────────────────────────────────────
class TestEntryExitQualityAnalyzer:
def test_detects_good_entry_poor_exit(self):
"""When entries are good but exits capture little of MFE."""
trades = []
for _ in range(60):
# Small MAE (good entry), large MFE but poor capture
trades.append(make_trade(
net_money=20, mfe_pips=50, mae_pips=3,
net_pips=2, lot_size=0.1
))
df = pd.DataFrame(trades)
metrics = dummy_metrics(df)
az = EntryExitQualityAnalyzer(poor_exit_threshold=0.60, poor_entry_threshold=0.40)
findings = az.run(df, metrics, "test")
action_findings = [f for f in findings if "diagnosis" in f.evidence and
f.evidence["diagnosis"] == "good_entry_poor_exit"]
assert len(action_findings) > 0
def test_no_findings_for_healthy_trades(self):
"""Should not flag anything when both entry and exit quality are high."""
trades = []
for _ in range(60):
trades.append(make_trade(
net_money=80, mfe_pips=100, mae_pips=5,
net_pips=80, lot_size=0.1
))
df = pd.DataFrame(trades)
metrics = dummy_metrics(df)
az = EntryExitQualityAnalyzer(poor_exit_threshold=0.55, poor_entry_threshold=0.40)
findings = az.run(df, metrics, "test")
action_findings = [f for f in findings if f.severity in ("high", "medium")]
assert len(action_findings) == 0
# ── Equity Curve Tests ────────────────────────────────────────────────────────
class TestEquityCurveAnalyzer:
def test_detects_loss_clusters(self):
"""Should flag a sequence of 5+ consecutive losses."""
trades = []
base_time = datetime(2022, 1, 3, 10, 0)
# Wins, then a cluster of losses, then more wins
for i in range(30):
trades.append(make_trade(net_money=100, open_time=base_time + timedelta(hours=i)))
for i in range(30, 37): # 7 consecutive losses
trades.append(make_trade(net_money=-150, open_time=base_time + timedelta(hours=i)))
for i in range(37, 60):
trades.append(make_trade(net_money=100, open_time=base_time + timedelta(hours=i)))
df = pd.DataFrame(trades)
metrics = dummy_metrics(df)
az = EquityCurveAnalyzer(cluster_min_length=5)
findings = az.run(df, metrics, "test")
cluster_findings = [f for f in findings if "cluster" in f.description.lower()]
assert len(cluster_findings) > 0
def test_detects_high_flatness(self):
"""Should flag when equity spends most time in drawdown."""
trades = []
base_time = datetime(2022, 1, 3, 10, 0)
# Pattern: win a little, lose a lot, basically always in drawdown
for i in range(50):
if i % 5 == 0:
trades.append(make_trade(net_money=50, open_time=base_time + timedelta(hours=i)))
else:
trades.append(make_trade(net_money=-30, open_time=base_time + timedelta(hours=i)))
df = pd.DataFrame(trades)
metrics = dummy_metrics(df)
az = EquityCurveAnalyzer(max_flatness=0.30)
findings = az.run(df, metrics, "test")
flatness_findings = [f for f in findings if "high-water" in f.description]
assert len(flatness_findings) > 0
# ── Composite Score Tests ─────────────────────────────────────────────────────
class TestCompositeScorer:
def test_score_increases_with_calmar(self):
"""Higher Calmar should produce higher score, all else equal."""
from scoring.composite import CompositeScorer
def make_metrics(calmar):
return RunMetrics(
run_id="t",
net_profit=10000, profit_factor=1.5,
max_drawdown_abs=1000, max_drawdown_pct=0.10,
calmar_ratio=calmar, sharpe_ratio=1.2,
total_trades=100, win_rate=0.55,
avg_win=200, avg_loss=150,
recovery_factor=3.0, largest_loss=500,
expected_payoff=50,
avg_mfe_capture=0.6, reversal_rate=0.1,
)
scorer = CompositeScorer("config.yaml")
s1 = scorer.score(make_metrics(0.5))
s2 = scorer.score(make_metrics(1.5))
s3 = scorer.score(make_metrics(3.0))
assert s1 < s2 < s3
def test_score_zero_below_min_trades(self):
from scoring.composite import CompositeScorer
scorer = CompositeScorer("config.yaml")
m = RunMetrics(
run_id="t", net_profit=5000, profit_factor=2.0,
max_drawdown_abs=500, max_drawdown_pct=0.05,
calmar_ratio=2.0, sharpe_ratio=1.5,
total_trades=10, # below min_trades (50)
win_rate=0.6, avg_win=200, avg_loss=100,
recovery_factor=4.0, largest_loss=200, expected_payoff=100,
)
assert scorer.score(m) == 0.0
+318
View File
@@ -0,0 +1,318 @@
/* style.css — MT5 Optimizer Dashboard */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap');
/* ── Variables ────────────────────────────────────────────────────────────── */
:root {
--bg: #0a0d14;
--bg-card: #111621;
--bg-card2: #161c2a;
--border: #1e2740;
--border-glow: #2a3a6e;
--text: #e2e8f8;
--text-dim: #6b7fa3;
--text-muted: #3d4f70;
--accent: #4f8ef7;
--accent2: #7c3aed;
--green: #22d3a5;
--red: #f44;
--yellow: #fbbf24;
--orange: #f97316;
--radius: 12px;
--radius-sm: 8px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Inter', sans-serif;
font-size: 13px;
min-height: 100vh;
overflow-x: hidden;
}
/* ── Header ───────────────────────────────────────────────────────────────── */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 24px;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 100;
}
.logo { display: flex; align-items: center; gap: 12px; }
.logo-icon { font-size: 28px; }
.logo-title { font-weight: 800; font-size: 16px; letter-spacing: -0.3px; }
.logo-sub { font-size: 11px; color: var(--text-dim); font-family: 'JetBrains Mono', monospace; }
.header-center { display: flex; gap: 10px; flex-wrap: wrap; }
.stat-pill {
display: flex; align-items: center; gap: 6px;
background: var(--bg-card2);
border: 1px solid var(--border);
border-radius: 20px;
padding: 5px 12px;
font-size: 12px;
}
.pill-label { color: var(--text-dim); }
.pill-val { font-weight: 700; font-family: 'JetBrains Mono', monospace; color: var(--accent); }
.score-val { color: var(--green); font-size: 14px; }
.dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--text-muted);
transition: background 0.3s;
}
.dot-idle { background: var(--text-muted); }
.dot-running { background: var(--green); box-shadow: 0 0 8px var(--green); animation: pulse 1.5s infinite; }
.dot-paused { background: var(--yellow); }
.dot-error { background: var(--red); }
@keyframes pulse {
0%,100% { opacity: 1; } 50% { opacity: 0.4; }
}
.header-right { display: flex; gap: 8px; }
/* ── Buttons ──────────────────────────────────────────────────────────────── */
.btn {
padding: 8px 18px;
border-radius: 8px;
border: none;
cursor: pointer;
font-weight: 600;
font-size: 13px;
transition: all 0.2s;
}
.btn-start { background: linear-gradient(135deg, #4f8ef7, #7c3aed); color: #fff; }
.btn-start:hover { opacity: 0.85; transform: translateY(-1px); }
.btn-pause { background: var(--yellow); color: #000; }
.btn-stop { background: var(--red); color: #fff; }
.btn-secondary { background: var(--bg-card2); color: var(--text-dim); border: 1px solid var(--border); }
.btn-secondary:hover { color: var(--text); border-color: var(--accent); }
.hidden { display: none !important; }
/* ── Main Grid ────────────────────────────────────────────────────────────── */
.main-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
padding: 20px 24px;
max-width: 1600px;
margin: 0 auto;
}
@media (max-width: 1100px) {
.main-grid { grid-template-columns: 1fr; }
}
.col-left, .col-right { display: flex; flex-direction: column; gap: 16px; }
/* ── Cards ────────────────────────────────────────────────────────────────── */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
transition: border-color 0.3s;
}
.card:hover { border-color: var(--border-glow); }
.card-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 14px;
}
.card-title { font-weight: 700; font-size: 13px; }
.card-badge {
background: var(--bg-card2);
border: 1px solid var(--border);
border-radius: 20px;
padding: 3px 10px;
font-size: 11px;
color: var(--text-dim);
font-family: 'JetBrains Mono', monospace;
}
.run-id-badge {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--accent);
opacity: 0.7;
}
/* ── Chart ────────────────────────────────────────────────────────────────── */
.chart-wrap { height: 200px; position: relative; }
#scoreChart { width: 100% !important; height: 100% !important; }
/* ── Metrics Grid ─────────────────────────────────────────────────────────── */
.metrics-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-bottom: 14px;
}
.metric-card {
background: var(--bg-card2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 12px;
text-align: center;
}
.metric-label { font-size: 10px; color: var(--text-dim); margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
.metric-val { font-size: 18px; font-weight: 700; font-family: 'JetBrains Mono', monospace; color: var(--text); }
.score-bar-wrap { margin-top: 4px; }
.score-bar-label {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 6px;
font-size: 12px; color: var(--text-dim);
}
.score-big { font-size: 22px; font-weight: 800; font-family: 'JetBrains Mono', monospace; color: var(--green); }
.score-bar-track {
height: 6px; background: var(--bg-card2);
border-radius: 3px; overflow: hidden;
}
.score-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--green));
border-radius: 3px;
transition: width 0.6s ease;
}
/* ── Phase Tracker ────────────────────────────────────────────────────────── */
.phase-card { padding: 14px 20px; }
.phase-steps {
display: flex; align-items: center; justify-content: space-between;
}
.phase-step {
display: flex; flex-direction: column; align-items: center; gap: 6px;
opacity: 0.35; transition: opacity 0.3s;
}
.phase-step.active { opacity: 1; }
.phase-step.done { opacity: 0.6; }
.phase-dot {
width: 12px; height: 12px; border-radius: 50%;
background: var(--text-muted);
border: 2px solid var(--border);
transition: all 0.3s;
}
.phase-step.active .phase-dot {
background: var(--accent);
border-color: var(--accent);
box-shadow: 0 0 10px var(--accent);
}
.phase-step.done .phase-dot { background: var(--green); border-color: var(--green); }
.phase-name { font-size: 10px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; }
.phase-line { flex: 1; height: 1px; background: var(--border); margin: 0 4px; margin-bottom: 18px; }
/* ── Findings ─────────────────────────────────────────────────────────────── */
.findings-feed {
max-height: 220px; overflow-y: auto;
display: flex; flex-direction: column; gap: 6px;
}
.finding-empty { color: var(--text-muted); font-size: 12px; text-align: center; padding: 20px 0; }
.finding-row {
display: flex; align-items: flex-start; gap: 10px;
background: var(--bg-card2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 12px;
animation: slideIn 0.3s ease;
}
.finding-sev {
font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 4px;
flex-shrink: 0; text-transform: uppercase; letter-spacing: 0.5px; margin-top: 1px;
}
.sev-high { background: rgba(244,68,68,0.2); color: #f44; border: 1px solid rgba(244,68,68,0.3); }
.sev-medium { background: rgba(251,191,36,0.15); color: var(--yellow); border: 1px solid rgba(251,191,36,0.3); }
.sev-low { background: rgba(79,142,247,0.1); color: var(--accent); border: 1px solid rgba(79,142,247,0.2); }
.finding-text { flex: 1; font-size: 12px; line-height: 1.5; color: var(--text); }
.finding-conf { font-size: 10px; color: var(--text-dim); flex-shrink: 0; margin-top: 2px; }
/* ── Params Table ─────────────────────────────────────────────────────────── */
.hyp-desc {
font-size: 12px; color: var(--text-dim); margin-bottom: 10px;
padding: 8px 10px; background: var(--bg-card2);
border-radius: var(--radius-sm); border: 1px solid var(--border);
font-style: italic;
}
.params-table-wrap { overflow-x: auto; max-height: 180px; overflow-y: auto; }
.params-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.params-table thead th {
text-align: left; padding: 6px 12px;
color: var(--text-dim); font-weight: 600;
border-bottom: 1px solid var(--border);
font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px;
}
.params-table tbody td {
padding: 6px 12px;
border-bottom: 1px solid rgba(30,39,64,0.5);
font-family: 'JetBrains Mono', monospace;
}
.params-table tbody tr:last-child td { border-bottom: none; }
.param-changed { color: var(--green); }
.param-old { color: var(--text-dim); text-decoration: line-through; }
.param-new { color: var(--green); font-weight: 600; }
.param-arrow { color: var(--text-muted); padding: 0 4px; }
/* ── Log ──────────────────────────────────────────────────────────────────── */
.log-card { max-height: 220px; display: flex; flex-direction: column; }
.log-feed {
flex: 1; overflow-y: auto; max-height: 160px;
display: flex; flex-direction: column; gap: 2px;
font-family: 'JetBrains Mono', monospace; font-size: 11px;
}
.log-line { padding: 2px 4px; border-radius: 3px; }
.log-info { color: var(--text-dim); }
.log-success { color: var(--green); }
.log-warn { color: var(--yellow); }
.log-error { color: var(--red); }
.clear-btn {
background: none; border: none; color: var(--text-muted);
cursor: pointer; font-size: 11px; padding: 2px 6px;
}
.clear-btn:hover { color: var(--text-dim); }
/* ── Candidates Section ───────────────────────────────────────────────────── */
.candidates-section {
padding: 0 24px 24px;
max-width: 1600px;
margin: 0 auto;
}
.candidates-card {}
.candidates-list {
display: flex; gap: 12px; flex-wrap: wrap;
}
.cand-empty { color: var(--text-muted); font-size: 12px; padding: 10px 0; text-align: center; width: 100%; }
.candidate-chip {
background: linear-gradient(135deg, rgba(34,211,165,0.1), rgba(79,142,247,0.1));
border: 1px solid rgba(34,211,165,0.3);
border-radius: var(--radius-sm);
padding: 10px 16px;
display: flex; flex-direction: column; gap: 4px;
min-width: 200px;
animation: slideIn 0.4s ease;
}
.cand-score {
font-size: 22px; font-weight: 800;
font-family: 'JetBrains Mono', monospace;
color: var(--green);
}
.cand-id { font-size: 10px; color: var(--text-dim); font-family: 'JetBrains Mono', monospace; }
.cand-delta { font-size: 11px; font-weight: 600; }
.delta-up { color: var(--green); }
.delta-dn { color: var(--red); }
/* ── Scrollbar styling ────────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border-glow); border-radius: 2px; }
/* ── Animations ───────────────────────────────────────────────────────────── */
@keyframes slideIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
+374
View File
@@ -0,0 +1,374 @@
/* dashboard.js — Real-time MT5 Optimizer Dashboard */
// ── Socket.IO connection ───────────────────────────────────────────────────
const socket = io();
let startTime = null;
let timerInterval = null;
let findingsCount = 0;
let candidatesCount = 0;
let currentParams = {};
// ── Chart setup ────────────────────────────────────────────────────────────
const ctx = document.getElementById('scoreChart').getContext('2d');
const scoreChart = new Chart(ctx, {
type: 'line',
data: {
labels: [],
datasets: [
{
label: 'Composite Score',
data: [],
borderColor: '#22d3a5',
backgroundColor: 'rgba(34,211,165,0.08)',
fill: true,
tension: 0.4,
pointRadius: 4,
pointBackgroundColor: '#22d3a5',
borderWidth: 2,
},
{
label: 'Calmar Ratio',
data: [],
borderColor: '#4f8ef7',
backgroundColor: 'rgba(79,142,247,0.05)',
fill: false,
tension: 0.4,
pointRadius: 3,
borderWidth: 1.5,
borderDash: [4, 2],
},
]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 500 },
plugins: {
legend: {
labels: { color: '#6b7fa3', font: { size: 11, family: 'Inter' } }
},
tooltip: {
backgroundColor: '#111621',
borderColor: '#2a3a6e',
borderWidth: 1,
titleColor: '#e2e8f8',
bodyColor: '#6b7fa3',
}
},
scales: {
x: {
ticks: { color: '#6b7fa3', font: { size: 10 } },
grid: { color: 'rgba(30,39,64,0.6)' },
},
y: {
ticks: { color: '#6b7fa3', font: { size: 10 } },
grid: { color: 'rgba(30,39,64,0.6)' },
min: 0, max: 1,
}
}
}
});
// ── Controls ───────────────────────────────────────────────────────────────
function startOptimizer() {
fetch('/api/start', { method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ auto: true })
});
startTime = Date.now();
timerInterval = setInterval(updateTimer, 1000);
setRunning(true);
addLog('info', 'Starting optimizer...');
}
function pauseOptimizer() {
fetch('/api/pause', { method: 'POST' }).then(r => r.json()).then(d => {
const btn = document.getElementById('btn-pause');
btn.textContent = d.paused ? '▶ Resume' : '⏸ Pause';
setDot(d.paused ? 'paused' : 'running', d.paused ? 'Paused' : 'Running...');
});
}
function stopOptimizer() {
fetch('/api/stop', { method: 'POST' });
clearInterval(timerInterval);
setRunning(false);
setDot('idle', 'Stopped');
addLog('warn', 'Optimizer stopped by user.');
}
function clearLog() {
document.getElementById('log-feed').innerHTML = '';
}
function setRunning(on) {
document.getElementById('btn-start').classList.toggle('hidden', on);
document.getElementById('btn-pause').classList.toggle('hidden', !on);
document.getElementById('btn-stop').classList.toggle('hidden', !on);
if (on) setDot('running', 'Running...');
}
// ── Timer ──────────────────────────────────────────────────────────────────
function updateTimer() {
if (!startTime) return;
const s = Math.floor((Date.now() - startTime) / 1000);
const m = Math.floor(s / 60);
const ss = String(s % 60).padStart(2, '0');
document.getElementById('hdr-elapsed').textContent = `${m}:${ss}`;
}
// ── State dot ──────────────────────────────────────────────────────────────
function setDot(state, label) {
const dot = document.getElementById('state-dot');
dot.className = `dot dot-${state}`;
document.getElementById('state-label').textContent = label;
}
// ── Phase tracker ──────────────────────────────────────────────────────────
const PHASES = ['baseline', 'analyze', 'explore', 'validate', 'oos'];
function setPhase(phase) {
const idx = PHASES.indexOf(phase);
PHASES.forEach((p, i) => {
const el = document.getElementById(`phase-${p}`);
if (!el) return;
el.classList.remove('active', 'done');
if (i < idx) el.classList.add('done');
else if (i === idx) el.classList.add('active');
});
}
// ── Metrics update ─────────────────────────────────────────────────────────
function updateMetrics(d) {
const set = (id, val) => {
const el = document.getElementById(id);
if (el) el.textContent = val;
};
set('m-profit', d.net_profit !== undefined ? `$${d.net_profit.toLocaleString()}` : '—');
set('m-calmar', d.calmar !== undefined ? d.calmar.toFixed(3) : '—');
set('m-pf', d.profit_factor !== undefined ? d.profit_factor.toFixed(3) : '—');
set('m-dd', d.drawdown_pct !== undefined ? `${d.drawdown_pct}%` : '—');
set('m-wr', d.win_rate !== undefined ? `${d.win_rate.toFixed(1)}%` : '—');
set('m-trades', d.total_trades ?? '—');
set('m-mfe', d.mfe_capture !== undefined ? `${d.mfe_capture.toFixed(1)}%` : '—');
set('m-rev', d.reversal_rate !== undefined ? `${d.reversal_rate.toFixed(1)}%` : '—');
set('m-score', d.score !== undefined ? d.score.toFixed(4) : '—');
set('current-run-id', d.run_id || '—');
if (d.score !== undefined) {
const fill = document.getElementById('score-bar-fill');
if (fill) fill.style.width = `${Math.min(100, d.score * 100)}%`;
set('hdr-score', d.score.toFixed(4));
}
// Color profit
const pEl = document.getElementById('m-profit');
if (pEl && d.net_profit !== undefined) {
pEl.style.color = d.net_profit >= 0 ? 'var(--green)' : 'var(--red)';
}
}
// ── Findings ───────────────────────────────────────────────────────────────
function addFinding(f) {
findingsCount++;
const feed = document.getElementById('findings-feed');
const empty = feed.querySelector('.finding-empty');
if (empty) empty.remove();
const sevClass = { high: 'sev-high', medium: 'sev-medium', low: 'sev-low' }[f.severity] || 'sev-low';
const row = document.createElement('div');
row.className = 'finding-row';
row.innerHTML = `
<span class="finding-sev ${sevClass}">${f.severity}</span>
<span class="finding-text">${escHtml(f.description)}</span>
<span class="finding-conf">${(f.confidence * 100).toFixed(0)}%</span>
`;
feed.insertBefore(row, feed.firstChild);
// Keep max 20 findings visible
while (feed.children.length > 20) feed.removeChild(feed.lastChild);
document.getElementById('findings-count').textContent = `${findingsCount} findings`;
}
// ── Hypotheses / Params ────────────────────────────────────────────────────
function showHypotheses(items) {
if (!items || !items.length) return;
const h = items[0]; // show first (highest priority)
document.getElementById('hyp-desc').textContent = h.desc || '';
document.getElementById('hyp-badge').textContent = `${items.length} hypothesis(es)`;
const tbody = document.getElementById('params-tbody');
tbody.innerHTML = '';
Object.entries(h.delta || {}).forEach(([param, newVal]) => {
const oldVal = currentParams[param];
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="param-changed">${escHtml(param)}</td>
<td class="param-old">${oldVal !== undefined ? oldVal : '—'}</td>
<td class="param-new">${newVal}</td>
`;
tbody.appendChild(tr);
});
}
// ── Log ────────────────────────────────────────────────────────────────────
function addLog(level, msg) {
const feed = document.getElementById('log-feed');
const line = document.createElement('div');
line.className = `log-line log-${level}`;
const ts = new Date().toLocaleTimeString('en-GB', { hour12: false });
line.textContent = `[${ts}] ${msg}`;
feed.insertBefore(line, feed.firstChild);
while (feed.children.length > 200) feed.removeChild(feed.lastChild);
}
// ── Candidates ─────────────────────────────────────────────────────────────
function addCandidate(d) {
candidatesCount++;
const list = document.getElementById('candidates-list');
const empty = list.querySelector('.cand-empty');
if (empty) empty.remove();
const deltaClass = d.delta >= 0 ? 'delta-up' : 'delta-dn';
const deltaSign = d.delta >= 0 ? '+' : '';
const chip = document.createElement('div');
chip.className = 'candidate-chip';
chip.innerHTML = `
<div class="cand-score">${d.score.toFixed(4)}</div>
<div class="cand-delta ${deltaClass}">${deltaSign}${d.delta.toFixed(4)} vs prev</div>
<div class="cand-id">ID: ${d.candidate_id}</div>
`;
list.insertBefore(chip, list.firstChild);
document.getElementById('cand-count').textContent = `${candidatesCount} candidates`;
}
// ── Chart update ───────────────────────────────────────────────────────────
function pushChartPoint(d) {
const labels = scoreChart.data.labels;
labels.push(`Iter ${d.iteration}`);
scoreChart.data.datasets[0].data.push(d.score);
scoreChart.data.datasets[1].data.push(d.calmar > 1 ? 1 : d.calmar / 4); // normalize calmar to 0-1
if (labels.length > 50) {
labels.shift();
scoreChart.data.datasets.forEach(ds => ds.data.shift());
}
scoreChart.update('none');
document.getElementById('chart-badge').textContent = `Score: ${d.score.toFixed(4)}`;
}
// ── Utility ────────────────────────────────────────────────────────────────
function escHtml(str) {
return String(str)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── Socket events ──────────────────────────────────────────────────────────
socket.on('connect', () => addLog('info', 'Connected to optimizer.'));
socket.on('status_sync', d => {
document.getElementById('hdr-iter').textContent = d.iteration;
if (d.state === 'running') { setRunning(true); setDot('running', 'Running...'); }
if (d.best_score) document.getElementById('hdr-score').textContent = d.best_score.toFixed(4);
});
socket.on('status_change', d => {
if (d.state === 'running') setDot('running', 'Running...');
if (d.state === 'paused') setDot('paused', 'Paused');
if (d.state === 'idle') setDot('idle', 'Ready');
if (d.phase) setPhase(d.phase);
});
socket.on('iteration_start', d => {
document.getElementById('hdr-iter').textContent = d.iteration;
addLog('info', `━━ Starting Iteration ${d.iteration} ━━`);
setPhase('analyze');
});
socket.on('run_started', d => {
setPhase(d.phase === 'baseline' ? 'baseline' : 'explore');
document.getElementById('current-run-id').textContent = d.run_id;
addLog('info', `▶ MT5 started: ${d.run_id} [${d.period}]`);
currentParams = d.params || {};
});
socket.on('run_complete', d => {
updateMetrics(d);
const sign = d.score > 0 ? '✓' : '•';
addLog(d.score > 0.3 ? 'success' : 'info',
`${sign} ${d.run_id}: Score=${d.score} | Calmar=${d.calmar} | PF=${d.profit_factor} | DD=${d.drawdown_pct}%`
);
});
socket.on('run_failed', d => {
addLog('error', `✗ Run failed: ${d.run_id}${d.error}`);
});
socket.on('finding', f => addFinding(f));
socket.on('hypotheses', d => {
setPhase('explore');
showHypotheses(d.items);
addLog('info', `💡 ${d.items.length} hypothesis(es) proposed`);
});
socket.on('hypothesis_testing', d => {
addLog('info', `🧪 Testing H${d.idx}: ${d.desc.substring(0, 60)}...`);
});
socket.on('score_update', d => {
pushChartPoint(d);
document.getElementById('hdr-score').textContent = d.score.toFixed(4);
});
socket.on('candidate_promoted', d => {
addCandidate(d);
addLog('success', `🏆 Candidate promoted! Score: ${d.score}`);
});
socket.on('optimization_complete', d => {
clearInterval(timerInterval);
setRunning(false);
setDot('idle', 'Complete');
addLog('success', `🏁 Done! ${d.iterations} iterations, ${d.candidates} candidate(s), best score: ${d.best_score}`);
addLog('info', '📁 Reports saved to MT5_Optimizer\\Reports\\');
});
socket.on('error', d => {
addLog('error', `Error: ${d.msg}`);
setDot('error', 'Error');
});
socket.on('log', d => addLog(d.level, d.msg));
// ── Init: restore chart from history ──────────────────────────────────────
fetch('/api/history').then(r => r.json()).then(history => {
history.forEach(d => pushChartPoint(d));
});
fetch('/api/status').then(r => r.json()).then(d => {
document.getElementById('hdr-iter').textContent = d.iteration;
if (d.best_score) document.getElementById('hdr-score').textContent = d.best_score.toFixed(4);
if (d.state === 'running') {
setRunning(true);
setDot('running', 'Running...');
startTime = Date.now() - d.elapsed_s * 1000;
timerInterval = setInterval(updateTimer, 1000);
}
});
+218
View File
@@ -0,0 +1,218 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MT5 EA Optimizer — Live Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<!-- ── HEADER ── -->
<header class="header">
<div class="header-left">
<div class="logo">
<span class="logo-icon"></span>
<div>
<div class="logo-title">MT5 EA Optimizer</div>
<div class="logo-sub">LEGSTECH_EA_V2 · XAUUSD · H1</div>
</div>
</div>
</div>
<div class="header-center">
<div class="stat-pill" id="pill-state">
<span class="dot dot-idle" id="state-dot"></span>
<span id="state-label">Ready</span>
</div>
<div class="stat-pill">
<span class="pill-label">Iteration</span>
<span class="pill-val" id="hdr-iter">0</span>
</div>
<div class="stat-pill">
<span class="pill-label">Best Score</span>
<span class="pill-val score-val" id="hdr-score"></span>
</div>
<div class="stat-pill">
<span class="pill-label">Elapsed</span>
<span class="pill-val" id="hdr-elapsed">0:00</span>
</div>
</div>
<div class="header-right">
<button class="btn btn-secondary" id="btn-reports" onclick="window.open('/reports','_blank')">
📁 Reports
</button>
<button class="btn btn-pause hidden" id="btn-pause" onclick="pauseOptimizer()">
⏸ Pause
</button>
<button class="btn btn-stop hidden" id="btn-stop" onclick="stopOptimizer()">
⏹ Stop
</button>
<button class="btn btn-start" id="btn-start" onclick="startOptimizer()">
▶ Start Optimizer
</button>
</div>
</header>
<!-- ── MAIN GRID ── -->
<main class="main-grid">
<!-- LEFT COLUMN -->
<div class="col-left">
<!-- Score Chart -->
<div class="card">
<div class="card-header">
<span class="card-title">📈 Score History</span>
<span class="card-badge" id="chart-badge">Waiting...</span>
</div>
<div class="chart-wrap">
<canvas id="scoreChart"></canvas>
</div>
</div>
<!-- Current Run Metrics -->
<div class="card">
<div class="card-header">
<span class="card-title">🎯 Current Run Metrics</span>
<span class="run-id-badge" id="current-run-id"></span>
</div>
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-label">Net Profit</div>
<div class="metric-val" id="m-profit"></div>
</div>
<div class="metric-card">
<div class="metric-label">Calmar Ratio</div>
<div class="metric-val" id="m-calmar"></div>
</div>
<div class="metric-card">
<div class="metric-label">Profit Factor</div>
<div class="metric-val" id="m-pf"></div>
</div>
<div class="metric-card">
<div class="metric-label">Max Drawdown</div>
<div class="metric-val" id="m-dd"></div>
</div>
<div class="metric-card">
<div class="metric-label">Win Rate</div>
<div class="metric-val" id="m-wr"></div>
</div>
<div class="metric-card">
<div class="metric-label">Total Trades</div>
<div class="metric-val" id="m-trades"></div>
</div>
<div class="metric-card">
<div class="metric-label">MFE Capture</div>
<div class="metric-val" id="m-mfe"></div>
</div>
<div class="metric-card">
<div class="metric-label">Reversal Rate</div>
<div class="metric-val" id="m-rev"></div>
</div>
</div>
<div class="score-bar-wrap">
<div class="score-bar-label">
<span>Composite Score</span>
<span class="score-big" id="m-score"></span>
</div>
<div class="score-bar-track">
<div class="score-bar-fill" id="score-bar-fill" style="width:0%"></div>
</div>
</div>
</div>
</div>
<!-- RIGHT COLUMN -->
<div class="col-right">
<!-- Phase tracker -->
<div class="card phase-card">
<div class="phase-steps">
<div class="phase-step" id="phase-baseline">
<div class="phase-dot"></div>
<div class="phase-name">Baseline</div>
</div>
<div class="phase-line"></div>
<div class="phase-step" id="phase-analyze">
<div class="phase-dot"></div>
<div class="phase-name">Analyze</div>
</div>
<div class="phase-line"></div>
<div class="phase-step" id="phase-explore">
<div class="phase-dot"></div>
<div class="phase-name">Test</div>
</div>
<div class="phase-line"></div>
<div class="phase-step" id="phase-validate">
<div class="phase-dot"></div>
<div class="phase-name">Validate</div>
</div>
<div class="phase-line"></div>
<div class="phase-step" id="phase-oos">
<div class="phase-dot"></div>
<div class="phase-name">OOS</div>
</div>
</div>
</div>
<!-- Findings feed -->
<div class="card findings-card">
<div class="card-header">
<span class="card-title">🔍 Analysis Findings</span>
<span class="card-badge" id="findings-count">0 findings</span>
</div>
<div class="findings-feed" id="findings-feed">
<div class="finding-empty">Findings will appear here after each analysis run...</div>
</div>
</div>
<!-- Parameter changes -->
<div class="card params-card">
<div class="card-header">
<span class="card-title">🔧 Parameter Changes</span>
<span class="card-badge" id="hyp-badge">Waiting...</span>
</div>
<div class="hyp-desc" id="hyp-desc">No hypothesis being tested yet.</div>
<div class="params-table-wrap" id="params-table-wrap">
<table class="params-table">
<thead><tr><th>Parameter</th><th>Before</th><th>After</th></tr></thead>
<tbody id="params-tbody"></tbody>
</table>
</div>
</div>
<!-- Live log -->
<div class="card log-card">
<div class="card-header">
<span class="card-title">📋 Live Log</span>
<button class="clear-btn" onclick="clearLog()">Clear</button>
</div>
<div class="log-feed" id="log-feed">
<div class="log-line log-info">System ready. Click Start Optimizer to begin.</div>
</div>
</div>
</div>
</main>
<!-- ── CANDIDATES PANEL ── -->
<section class="candidates-section">
<div class="card candidates-card">
<div class="card-header">
<span class="card-title">🏆 Promoted Candidates</span>
<span class="card-badge" id="cand-count">0 candidates</span>
</div>
<div class="candidates-list" id="candidates-list">
<div class="cand-empty">Validated candidates will appear here...</div>
</div>
</div>
</section>
<!-- ── SCRIPTS ── -->
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="/static/js/dashboard.js"></script>
</body>
</html>
+192
View File
@@ -0,0 +1,192 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Run {{ run_id }} — MT5 Optimizer Report</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=JetBrains+Mono&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #0a0d14; color: #e2e8f8; font-family: 'Inter', sans-serif; font-size: 13px; padding: 32px; }
h1 { font-size: 22px; font-weight: 800; margin-bottom: 4px; }
.sub { color: #6b7fa3; font-size: 12px; font-family: 'JetBrains Mono', monospace; margin-bottom: 24px; }
.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 28px; }
.card { background: #111621; border: 1px solid #1e2740; border-radius: 10px; padding: 14px 18px; }
.card-label { font-size: 10px; color: #6b7fa3; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
.card-val { font-size: 24px; font-weight: 700; font-family: 'JetBrains Mono', monospace; }
.green { color: #22d3a5; } .red { color: #f44; } .blue { color: #4f8ef7; }
.section-title { font-size: 14px; font-weight: 700; margin: 24px 0 12px; border-left: 3px solid #4f8ef7; padding-left: 10px; }
table { width: 100%; border-collapse: collapse; font-size: 12px; margin-bottom: 24px; }
th { text-align: left; padding: 8px 12px; color: #6b7fa3; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid #1e2740; }
td { padding: 8px 12px; border-bottom: 1px solid rgba(30,39,64,0.4); font-family: 'JetBrains Mono', monospace; }
tr:hover td { background: rgba(79,142,247,0.04); }
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 700; }
.high { background: rgba(244,68,68,0.15); color: #f44; }
.medium { background: rgba(251,191,36,0.15); color: #fbbf24; }
.low { background: rgba(79,142,247,0.1); color: #4f8ef7; }
.win { color: #22d3a5; } .loss { color: #f44; } .reversal { color: #fbbf24; }
.score-big { font-size: 48px; font-weight: 800; color: #22d3a5; text-align: center; padding: 20px 0; }
.back-link { display: inline-block; margin-bottom: 20px; color: #4f8ef7; text-decoration: none; font-size: 12px; }
.back-link:hover { text-decoration: underline; }
</style>
</head>
<body>
<a class="back-link" href="javascript:history.back()">← Back to Dashboard</a>
<h1>Run Report: {{ run_id }}</h1>
<div class="sub">Generated: {{ generated }} · Phase: {{ summary.phase }}</div>
<!-- Score -->
<div class="score-big">{{ "%.4f"|format(summary.score) }}</div>
<!-- Key Metrics -->
<div class="grid">
<div class="card">
<div class="card-label">Net Profit</div>
<div class="card-val {% if metrics.net_profit >= 0 %}green{% else %}red{% endif %}">
${{ "%.2f"|format(metrics.net_profit) }}
</div>
</div>
<div class="card">
<div class="card-label">Calmar Ratio</div>
<div class="card-val blue">{{ "%.3f"|format(metrics.calmar_ratio) }}</div>
</div>
<div class="card">
<div class="card-label">Profit Factor</div>
<div class="card-val {% if metrics.profit_factor >= 1.3 %}green{% else %}red{% endif %}">
{{ "%.3f"|format(metrics.profit_factor) }}
</div>
</div>
<div class="card">
<div class="card-label">Max Drawdown</div>
<div class="card-val red">{{ "%.1f"|format(metrics.max_drawdown_pct * 100) }}%</div>
</div>
<div class="card">
<div class="card-label">Win Rate</div>
<div class="card-val">{{ "%.1f"|format(metrics.win_rate * 100) }}%</div>
</div>
<div class="card">
<div class="card-label">Total Trades</div>
<div class="card-val">{{ metrics.total_trades }}</div>
</div>
<div class="card">
<div class="card-label">Sharpe Ratio</div>
<div class="card-val">{{ "%.3f"|format(metrics.sharpe_ratio) }}</div>
</div>
<div class="card">
<div class="card-label">Recovery Factor</div>
<div class="card-val">{{ "%.2f"|format(metrics.recovery_factor) }}</div>
</div>
</div>
{% if metrics.avg_mfe_capture is not none %}
<div class="grid">
<div class="card">
<div class="card-label">MFE Capture</div>
<div class="card-val green">{{ "%.1f"|format(metrics.avg_mfe_capture * 100) }}%</div>
</div>
<div class="card">
<div class="card-label">Reversal Rate</div>
<div class="card-val {% if (metrics.reversal_rate or 0) > 0.2 %}red{% else %}green{% endif %}">
{{ "%.1f"|format((metrics.reversal_rate or 0) * 100) }}%
</div>
</div>
<div class="card">
<div class="card-label">Avg MFE Pips</div>
<div class="card-val">{{ "%.1f"|format(metrics.avg_mfe_pips or 0) }}</div>
</div>
<div class="card">
<div class="card-label">Avg MAE Pips</div>
<div class="card-val">{{ "%.1f"|format(metrics.avg_mae_pips or 0) }}</div>
</div>
</div>
{% endif %}
<!-- Score vs Baseline -->
{% if baseline_score %}
<div class="card" style="margin-bottom:24px">
<div class="card-label">Score vs Baseline</div>
<div style="font-size:18px;font-weight:700;font-family:'JetBrains Mono',monospace;margin-top:6px">
{{ "%.4f"|format(baseline_score) }} →
<span class="{% if summary.score > baseline_score %}green{% else %}red{% endif %}">
{{ "%.4f"|format(summary.score) }}
({{ "%+.4f"|format(summary.score - baseline_score) }})
</span>
</div>
</div>
{% endif %}
<!-- Hypothesis info -->
{% if hypothesis %}
<div class="section-title">Hypothesis</div>
<div class="card" style="margin-bottom:24px">
<div style="margin-bottom:8px;font-weight:600">{{ hypothesis.description }}</div>
<div style="color:#6b7fa3;font-size:11px">Strategy: {{ hypothesis.strategy }} · Rule: {{ hypothesis.kb_rule_id }}</div>
</div>
{% endif %}
<!-- Findings -->
{% if findings %}
<div class="section-title">Analysis Findings ({{ findings|length }})</div>
<table>
<thead>
<tr><th>Analyzer</th><th>Severity</th><th>Confidence</th><th>Finding</th><th>Est. Impact</th></tr>
</thead>
<tbody>
{% for f in findings %}
<tr>
<td>{{ f.analyzer }}</td>
<td><span class="badge {{ f.severity }}">{{ f.severity.upper() }}</span></td>
<td>{{ "%.0f"|format(f.confidence * 100) }}%</td>
<td style="max-width:400px;white-space:normal;font-family:'Inter',sans-serif">{{ f.description }}</td>
<td>${{ "%.0f"|format(f.impact_estimate_pnl) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<!-- Parameters -->
{% if params %}
<div class="section-title">Parameter Configuration</div>
<table>
<thead><tr><th>Parameter</th><th>Value</th><th>Changed</th></tr></thead>
<tbody>
{% for p in params %}
<tr>
<td {% if p.changed %}class="green"{% endif %}>{{ p.name }}</td>
<td {% if p.changed %}class="green"{% endif %}>{{ p.new }}</td>
<td>{% if p.changed %}✅ {{ p.old }} → {{ p.new }}{% else %}—{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<!-- Trade sample -->
{% if trades_sample %}
<div class="section-title">Trade Sample (first 50)</div>
<table>
<thead>
<tr><th>#</th><th>Open</th><th>Close</th><th>Dir</th><th>Net $</th><th>MFE pip</th><th>MAE pip</th><th>Session</th><th>Result</th></tr>
</thead>
<tbody>
{% for t in trades_sample %}
<tr>
<td>{{ loop.index }}</td>
<td>{{ t.open_time }}</td>
<td>{{ t.close_time }}</td>
<td>{{ t.direction }}</td>
<td class="{% if t.net_money >= 0 %}win{% else %}loss{% endif %}">
${{ "%.2f"|format(t.net_money) }}
</td>
<td>{{ t.mfe_pips or '—' }}</td>
<td>{{ t.mae_pips or '—' }}</td>
<td>{{ t.session or '—' }}</td>
<td class="{{ t.result_class or '' }}">{{ t.result_class or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</body>
</html>
+93
View File
@@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Reports — MT5 Optimizer</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #0a0d14; color: #e2e8f8; font-family: 'Inter', sans-serif; font-size: 13px; padding: 32px; }
header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 32px; }
h1 { font-size: 22px; font-weight: 800; }
.sub { color: #6b7fa3; font-size: 12px; margin-top: 4px; }
.back-link { color: #4f8ef7; text-decoration: none; font-size: 12px; }
.back-link:hover { text-decoration: underline; }
.empty { color: #3d4f70; text-align: center; padding: 80px 0; font-size: 16px; }
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
.run-card {
background: #111621; border: 1px solid #1e2740; border-radius: 12px; padding: 18px 20px;
transition: border-color 0.2s, transform 0.2s;
text-decoration: none; color: inherit; display: block;
}
.run-card:hover { border-color: #4f8ef7; transform: translateY(-2px); }
.run-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
.run-id { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: #6b7fa3; }
.run-score { font-size: 28px; font-weight: 800; font-family: 'JetBrains Mono', monospace; color: #22d3a5; }
.run-phase { font-size: 10px; padding: 2px 8px; border-radius: 4px; background: rgba(79,142,247,0.15); color: #4f8ef7; font-weight: 700; text-transform: uppercase; }
.metrics-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 10px; }
.m { text-align: center; background: #161c2a; border-radius: 6px; padding: 6px 8px; }
.m-label { font-size: 9px; color: #6b7fa3; text-transform: uppercase; letter-spacing: 0.5px; }
.m-val { font-size: 14px; font-weight: 700; font-family: 'JetBrains Mono', monospace; margin-top: 2px; }
.delta-up { color: #22d3a5; } .delta-dn { color: #f44; }
.run-ts { font-size: 10px; color: #3d4f70; margin-top: 10px; font-family: 'JetBrains Mono', monospace; }
</style>
</head>
<body>
<header>
<div>
<h1>📁 Optimization Reports</h1>
<div class="sub">{{ runs|length }} run(s) recorded · Click any card to open the full report</div>
</div>
<a class="back-link" href="/">← Back to Dashboard</a>
</header>
{% if not runs %}
<div class="empty">No reports yet.<br>Start the optimizer to generate your first run report.</div>
{% else %}
<div class="cards">
{% for r in runs %}
<a class="run-card" href="/reports/{{ r.run_id }}/summary.html" target="_blank">
<div class="run-header">
<div>
<div class="run-id">{{ r.run_id }}</div>
<div class="run-score">{{ "%.4f"|format(r.score) }}</div>
</div>
<span class="run-phase">{{ r.phase }}</span>
</div>
<div class="metrics-row">
<div class="m">
<div class="m-label">Net Profit</div>
<div class="m-val {% if r.net_profit >= 0 %}delta-up{% else %}delta-dn{% endif %}">
${{ "%.0f"|format(r.net_profit) }}
</div>
</div>
<div class="m">
<div class="m-label">Calmar</div>
<div class="m-val">{{ "%.2f"|format(r.calmar) }}</div>
</div>
<div class="m">
<div class="m-label">Drawdown</div>
<div class="m-val delta-dn">{{ "%.1f"|format(r.drawdown_pct) }}%</div>
</div>
<div class="m">
<div class="m-label">PF</div>
<div class="m-val">{{ "%.2f"|format(r.profit_factor) }}</div>
</div>
<div class="m">
<div class="m-label">Trades</div>
<div class="m-val">{{ r.total_trades }}</div>
</div>
<div class="m">
<div class="m-label">Score Δ</div>
<div class="m-val {% if r.score_delta >= 0 %}delta-up{% else %}delta-dn{% endif %}">
{{ "%+.4f"|format(r.score_delta) }}
</div>
</div>
</div>
<div class="run-ts">{{ r.ts[:19].replace("T"," ") }} UTC</div>
</a>
{% endfor %}
</div>
{% endif %}
</body>
</html>
View File
+209
View File
@@ -0,0 +1,209 @@
"""
validation/gate.py
IS / Walk-Forward / OOS validation pipeline.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
import uuid
import yaml
from loguru import logger
from data.models import GateResult, RunMetrics
@dataclass
class WFVResult:
passed: bool
oos_is_ratio: float
fold_results: list[dict]
details: dict
class ValidationGate:
"""
Three-phase validation pipeline:
1. IS check — minimum thresholds on in-sample metrics
2. Walk-Forward Validation — split training period, test metric consistency
3. OOS test — held-out period, called explicitly by orchestrator
"""
def __init__(self, config_path: str | Path = "config.yaml"):
with open(config_path) as f:
cfg = yaml.safe_load(f)
self.thresh = cfg["thresholds"]
self.per = cfg["periods"]
# ── Phase 1: IS Check ─────────────────────────────────────────────────────
def run_is_check(self, metrics: RunMetrics) -> GateResult:
"""
Hard minimum thresholds. All must pass.
"""
checks = {
"min_trades": metrics.total_trades >= self.thresh["min_trades"],
"min_pf": metrics.profit_factor >= self.thresh["min_profit_factor"],
"min_calmar": metrics.calmar_ratio >= self.thresh["min_calmar"],
}
passed = all(checks.values())
reason = None
if not passed:
failed = [k for k, v in checks.items() if not v]
reason = f"Failed gates: {', '.join(failed)}"
logger.info(f"IS check {'PASSED' if passed else 'FAILED'}: {checks}")
return GateResult(passed=passed, details=checks, reason=reason)
# ── Phase 2: Walk-Forward Validation ─────────────────────────────────────
def run_walk_forward(
self,
params: dict[str, Any],
cfg: dict,
store, # DataStore
builder, # IniBuilder
runner, # MT5Runner
parser, # ReportParser
log_rdr, # TradeLogReader
analyzers, # list[BaseAnalyzer]
scorer, # CompositeScorer
n_folds: int = 2,
) -> WFVResult:
"""
Split training period into n_folds sub-periods.
Run the same params on each sub-period.
Accept if mean OOS Calmar >= min_wfv_ratio × IS Calmar.
In MVP we use n_folds=2 (first half / second half).
v2 will use full rolling window WFV.
"""
from datetime import datetime, timedelta
train_start = datetime.strptime(self.per["train_start"], "%Y.%m.%d")
train_end = datetime.strptime(self.per["train_end"], "%Y.%m.%d")
total_days = (train_end - train_start).days
fold_days = total_days // n_folds
fold_metrics: list[RunMetrics] = []
for i in range(n_folds):
fold_start = train_start + timedelta(days=i * fold_days)
fold_end = fold_start + timedelta(days=fold_days)
if i == n_folds - 1:
fold_end = train_end # last fold gets remainder
fold_id = f"wfv_fold{i+1}_{uuid.uuid4().hex[:6]}"
logger.info(f"WFV fold {i+1}/{n_folds}: {fold_start.date()}{fold_end.date()}")
# Import here to avoid circular
from main import execute_run
fm, _ = execute_run(
run_id=fold_id,
params=params,
period_start=fold_start.strftime("%Y.%m.%d"),
period_end=fold_end.strftime("%Y.%m.%d"),
phase="wfv",
hypothesis_id=None,
cfg=cfg, store=store, builder=builder, runner=runner,
parser=parser, log_rdr=log_rdr, analyzers=analyzers, scorer=scorer,
)
if fm:
fold_metrics.append(fm)
if not fold_metrics:
return WFVResult(passed=False, oos_is_ratio=0.0, fold_results=[], details={})
fold_calmars = [fm.calmar_ratio for fm in fold_metrics]
mean_oos_calmar = sum(fold_calmars) / len(fold_calmars)
# Get IS calmar (best score so far) for comparison
is_calmar = max((fm.calmar_ratio for fm in fold_metrics), default=0)
if is_calmar <= 0:
ratio = 0.0
else:
ratio = mean_oos_calmar / is_calmar
passed = ratio >= self.thresh["min_wfv_ratio"]
return WFVResult(
passed=passed,
oos_is_ratio=ratio,
fold_results=[
{"fold": i+1, "calmar": fm.calmar_ratio, "trades": fm.total_trades}
for i, fm in enumerate(fold_metrics)
],
details={
"mean_fold_calmar": round(mean_oos_calmar, 4),
"ratio": round(ratio, 4),
"threshold": self.thresh["min_wfv_ratio"],
},
)
# ── Parameter Sensitivity Check ───────────────────────────────────────────
def check_sensitivity(
self,
params: dict[str, Any],
key_params: list[str],
cfg: dict,
store,
builder,
runner,
parser,
log_rdr,
analyzers,
scorer,
perturbation: float = 0.10,
) -> tuple[bool, dict]:
"""
For each key parameter, perturb by ±10% and measure Calmar change.
Reject if any parameter causes > tolerance% degradation.
In MVP this is optional — add to v2 workflow.
"""
from main import execute_run
import uuid
tolerance = self.thresh["sensitivity_tolerance"]
degradations = {}
base_metrics, _ = execute_run(
run_id=f"sens_base_{uuid.uuid4().hex[:6]}",
params=params,
period_start=self.per["train_start"],
period_end=self.per["train_end"],
phase="validate",
hypothesis_id=None,
cfg=cfg, store=store, builder=builder, runner=runner,
parser=parser, log_rdr=log_rdr, analyzers=analyzers, scorer=scorer,
)
if base_metrics is None or base_metrics.calmar_ratio <= 0:
return True, {} # can't test sensitivity — skip
for param in key_params:
if param not in params:
continue
base_val = params[param]
if not isinstance(base_val, (int, float)):
continue
perturbed = {**params, param: base_val * (1 + perturbation)}
pm, _ = execute_run(
run_id=f"sens_{param[:8]}_{uuid.uuid4().hex[:6]}",
params=perturbed,
period_start=self.per["train_start"],
period_end=self.per["train_end"],
phase="validate",
hypothesis_id=None,
cfg=cfg, store=store, builder=builder, runner=runner,
parser=parser, log_rdr=log_rdr, analyzers=analyzers, scorer=scorer,
)
if pm and base_metrics.calmar_ratio > 0:
deg = (base_metrics.calmar_ratio - pm.calmar_ratio) / base_metrics.calmar_ratio
degradations[param] = round(deg, 4)
max_deg = max(degradations.values(), default=0)
passed = max_deg <= tolerance
return passed, degradations