Files
Apex_AI_MT5_EA_Optimizer/optimizer/pipeline.py
T

540 lines
23 KiB
Python
Raw Normal View History

"""
optimizer/pipeline.py
The Smart 3-Phase Optimization Pipeline.
Replaces optimizer_loop.py as the core orchestration engine.
Receives a SessionConfig → runs Phase 1, 2, 3 → emits SocketIO events.
Architecture:
Phase 1: Broad Discovery (LHS samples, 2030 runs)
Phase 2: Deep Refinement (neighbor search around top 3)
Phase 3: Validation (OOS backtest + sensitivity)
Decision: RECOMMENDED / RISKY / NOT_RELIABLE + .set file download
"""
from __future__ import annotations
import time
import uuid
import threading
from datetime import datetime
from pathlib import Path
from typing import Any, Optional, Callable
import yaml
from loguru import logger
from ea.registry import EARegistry, EAProfile
from ea.schema import ParameterSchema
from mt5.ini_builder import IniBuilder
from mt5.runner import MT5Runner
from mt5.report_parser import ReportParser
from data.models import Run
from data.store import DataStore
from reports.writer import ReportWriter
from optimizer.session_config import SessionConfig
from optimizer.lhs_sampler import LatinHypercubeSampler
from optimizer.result_ranker import ResultRanker, RankedResult
from optimizer.budget import BudgetManager
import pandas as pd
BASE_DIR = Path(__file__).parent.parent
RUNS_DIR = BASE_DIR / "runs"
DB_PATH = BASE_DIR / "optimizer.db"
class OptimizationPipeline:
"""
3-phase autonomous optimization pipeline. Run in a background thread.
Usage:
pipeline = OptimizationPipeline("config.yaml", socketio, reports_dir)
pipeline.configure(session_config)
thread = threading.Thread(target=pipeline.run, daemon=True)
thread.start()
"""
def __init__(self, config_path: str, socketio, reports_dir: Path):
self.config_path = config_path
self.socketio = socketio
self.reports_dir = reports_dir
with open(config_path) as f:
self.cfg = yaml.safe_load(f)
# Runtime state
self.session: Optional[SessionConfig] = None
self.running = False
self._stop_flag = False
self._phase = "idle"
self._run_count = 0
self._total_runs = 0
self.best_result: Optional[RankedResult] = None
self.phase1_results: list[RankedResult] = []
self.phase2_results: list[RankedResult] = []
self.final_result: Optional[RankedResult] = None
self.verdict: Optional[str] = None
self.best_set_path: Optional[Path] = None
self.run_start_ts: Optional[float] = None
# ── Public API ────────────────────────────────────────────────────────────
def configure(self, session: SessionConfig) -> None:
self.session = session
def stop(self) -> None:
self._stop_flag = True
self._emit("status_change", {"state": "stopping"})
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 "idle",
"phase": self._phase,
"run_count": self._run_count,
"total_runs": self._total_runs,
"best_score": round(self.best_result.score, 4) if self.best_result else 0.0,
"verdict": self.verdict,
"elapsed_s": elapsed,
"ea_name": self.session.ea_name if self.session else "",
"symbol": self.session.symbol if self.session else "",
"timeframe": self.session.timeframe if self.session else "",
}
# ── Main entry point ──────────────────────────────────────────────────────
def run(self) -> None:
self.running = True
self._stop_flag = False
self.run_start_ts = time.time()
try:
self._run_pipeline()
except Exception as e:
logger.exception(f"Pipeline crashed: {e}")
self._emit("error", {"msg": f"Pipeline error: {e}"})
finally:
self.running = False
self._phase = "idle"
self._emit("status_change", {"state": "idle"})
def _run_pipeline(self) -> None:
cfg = self.session
self._total_runs = cfg.total_budget_runs
self._emit("status_change", {"state": "running", "phase": "setup"})
self._log("info", f"🚀 Smart Optimizer started — {cfg.ea_name} | {cfg.symbol} | {cfg.timeframe}")
self._log("info", f"📋 Budget: {cfg.budget_minutes} min | Samples: {cfg.phase1_samples} Phase1 + {cfg.phase2_samples} Phase2 + {cfg.phase3_samples} Phase3")
# ── Build components ─────────────────────────────────────────────────
reg = EARegistry(self.config_path)
profile = reg.get(cfg.ea_name)
schema = reg.get_schema(profile)
# Apply user's param selection if specified
if cfg.selected_params:
for p in schema.parameters.values():
if p.type != "fixed":
p.optimize = p.name in cfg.selected_params
builder = IniBuilder(self.config_path, schema=schema)
runner = MT5Runner(self.config_path)
parser = ReportParser()
store = DataStore(DB_PATH, RUNS_DIR)
writer = ReportWriter(self.reports_dir)
sampler = LatinHypercubeSampler(seed=int(time.time()))
ranker = ResultRanker(weights=cfg.scoring_weights)
budget = BudgetManager(cfg.budget_minutes)
budget.start()
# ── Phase 1: Broad Discovery ─────────────────────────────────────────
if self._stop_flag:
return
self._phase = "phase1"
self._emit("phase_start", {"phase": "phase1", "total": cfg.phase1_samples})
self._log("info", f"━━ Phase 1: Broad Discovery ({cfg.phase1_samples} configurations) ━━")
samples = sampler.sample(schema, cfg.phase1_samples)
phase1_raw: list[RankedResult] = []
for i, params in enumerate(samples):
if self._stop_flag:
break
run_id = f"p1_{i+1:02d}_{datetime.utcnow().strftime('%H%M%S')}"
self._log("info", f"[{i+1}/{cfg.phase1_samples}] Testing configuration {i+1}...")
t0 = time.time()
result = self._execute_run(
run_id, params, cfg.train_start, cfg.train_end,
"phase1", builder, runner, parser, store, writer, ranker, profile
)
budget.record_run(time.time() - t0)
phase1_raw.append(result)
self._run_count += 1
# Emit progress after each run
self._emit("run_complete", {
"run_id": run_id,
"phase": "phase1",
"run_number": i + 1,
"total": cfg.phase1_samples,
"net_profit": round(result.net_profit, 2),
"calmar": round(result.calmar, 3),
"profit_factor": round(result.profit_factor, 3),
"win_rate": round(result.win_rate, 1),
"max_drawdown": round(result.max_drawdown, 2),
"total_trades": result.total_trades,
"passing": result.passing,
"progress_pct": round((i + 1) / cfg.phase1_samples * 100),
"budget_summary": budget.summary(),
})
if budget.is_exhausted():
self._log("warning", "⏱ Time budget reached during Phase 1")
break
# Rank Phase 1 results
self.phase1_results = ranker.rank(phase1_raw)
top5 = ranker.top_n(self.phase1_results, 5)
n_passing = sum(1 for r in self.phase1_results if r.passing)
self._log("info" if n_passing > 0 else "warning",
f"Phase 1 complete: {n_passing}/{len(self.phase1_results)} profitable configurations found"
)
# Emit Phase 1 summary for checkpoint UI
self._emit("phase1_complete", {
"total_tested": len(self.phase1_results),
"n_passing": n_passing,
"top_results": [self._result_to_dict(r) for r in top5],
})
if not top5:
self._emit("no_profitable_config", {
"msg": (
"Phase 1 found no profitable configuration. "
"Suggestions: try a different date range, check EA settings, "
"or try a different timeframe."
)
})
self._log("error", "❌ No profitable configuration found in Phase 1. Stopping.")
return
if self._stop_flag:
return
# ── Phase 2: Deep Refinement ─────────────────────────────────────────
if not budget.can_fit(3):
self._log("warning", "⏱ Not enough budget for Phase 2 — using Phase 1 winner directly")
self.final_result = top5[0]
else:
self._phase = "phase2"
self._emit("phase_start", {"phase": "phase2", "total": cfg.phase2_samples})
self._log("info", f"━━ Phase 2: Deep Refinement (refining top {min(3, len(top5))} configs) ━━")
top3 = top5[:3]
phase2_raw = []
neighbors_per = cfg.phase2_samples // max(1, len(top3))
for rank_i, base_result in enumerate(top3):
if self._stop_flag:
break
self._log("info", f" Refining config #{rank_i+1}: {base_result.run_id}")
neighbors = sampler.sample_neighbors(
base_result.params, schema,
n_neighbors=neighbors_per, step_pct=0.20
)
for j, params in enumerate(neighbors):
if self._stop_flag or budget.is_exhausted():
break
run_id = f"p2_{rank_i+1}_{j+1:02d}_{datetime.utcnow().strftime('%H%M%S')}"
t0 = time.time()
result = self._execute_run(
run_id, params, cfg.train_start, cfg.train_end,
"phase2", builder, runner, parser, store, writer, ranker, profile
)
budget.record_run(time.time() - t0)
phase2_raw.append(result)
self._run_count += 1
self._emit("run_complete", {
"run_id": run_id,
"phase": "phase2",
"net_profit": round(result.net_profit, 2),
"calmar": round(result.calmar, 3),
"passing": result.passing,
"progress_pct": round(self._run_count / self._total_runs * 100),
})
# Best from Phase 1 + Phase 2 combined
all_results = list(self.phase1_results) + ranker.rank(phase2_raw)
all_ranked = ranker.rank(
[r for r in all_results if r.passing]
or list(self.phase1_results) # fallback to Phase 1 if P2 all fail
)
self.phase2_results = ranker.rank(phase2_raw)
self.final_result = all_ranked[0] if all_ranked else top5[0]
self._emit("phase2_complete", {
"best_run_id": self.final_result.run_id,
"best_score": round(self.final_result.score, 4),
"best_profit": round(self.final_result.net_profit, 2),
"best_calmar": round(self.final_result.calmar, 3),
})
self._log("info",
f"Phase 2 complete. Best config: {self.final_result.run_id} "
f"(profit=${self.final_result.net_profit:.0f}, calmar={self.final_result.calmar:.2f})"
)
if self._stop_flag:
return
# ── Phase 3: Validation ───────────────────────────────────────────────
if not budget.can_fit(2):
self._log("warning", "⏱ Not enough budget for Phase 3 validation — skipping OOS test")
self.verdict = "RISKY"
oos_result = None
else:
self._phase = "phase3"
self._emit("phase_start", {"phase": "phase3", "total": cfg.phase3_samples})
self._log("info", "━━ Phase 3: Validation (out-of-sample + sensitivity) ━━")
# OOS test
oos_id = f"oos_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}"
self._log("info", f" OOS test: {cfg.val_start}{cfg.val_end}")
t0 = time.time()
oos_result = self._execute_run(
oos_id, self.final_result.params,
cfg.val_start, cfg.val_end,
"phase3_oos", builder, runner, parser, store, writer, ranker, profile
)
budget.record_run(time.time() - t0)
self._run_count += 1
self._emit("run_complete", {
"run_id": oos_id,
"phase": "phase3_oos",
"net_profit": round(oos_result.net_profit, 2),
"calmar": round(oos_result.calmar, 3),
"passing": oos_result.passing,
})
# Sensitivity test (2 runs: nudge top param up and down)
sens_results = []
opts = schema.optimizable()
if opts and budget.can_fit(2):
top_param = opts[0] # first optimizable param
for direction in [1, -1]:
if budget.is_exhausted() or self._stop_flag:
break
nudged = dict(self.final_result.params)
current = float(nudged.get(top_param.name, top_param.default))
span = float(top_param.max) - float(top_param.min)
nudged[top_param.name] = top_param.clamp(current + direction * span * 0.20)
sens_id = f"sens_{direction}_{datetime.utcnow().strftime('%H%M%S')}"
t0 = time.time()
sr = self._execute_run(
sens_id, nudged, cfg.train_start, cfg.train_end,
"phase3_sens", builder, runner, parser, store, writer, ranker, profile
)
budget.record_run(time.time() - t0)
sens_results.append(sr)
self._run_count += 1
# Determine verdict
self.verdict = self._determine_verdict(self.final_result, oos_result, sens_results)
# ── Generate .set output ─────────────────────────────────────────────
self.best_set_path = self._write_set_file(self.final_result, schema, cfg)
# ── Final emit ───────────────────────────────────────────────────────
self._emit("optimization_complete", {
"verdict": self.verdict,
"best_run_id": self.final_result.run_id,
"net_profit": round(self.final_result.net_profit, 2),
"calmar": round(self.final_result.calmar, 3),
"profit_factor": round(self.final_result.profit_factor, 3),
"win_rate": round(self.final_result.win_rate, 1),
"max_drawdown": round(self.final_result.max_drawdown, 2),
"total_trades": self.final_result.total_trades,
"oos_profit": round(oos_result.net_profit, 2) if oos_result else None,
"oos_calmar": round(oos_result.calmar, 3) if oos_result else None,
"set_file_url": f"/download_set/{self.final_result.run_id}" if self.best_set_path else None,
"total_runs": self._run_count,
"elapsed_min": round((time.time() - self.run_start_ts) / 60, 1),
})
verdict_icon = {"RECOMMENDED": "✅", "RISKY": "⚠️", "NOT_RELIABLE": "❌"}.get(self.verdict, "?")
self._log("success" if self.verdict == "RECOMMENDED" else "warning",
f"{verdict_icon} VERDICT: {self.verdict} | "
f"Profit: ${self.final_result.net_profit:.0f} | "
f"Calmar: {self.final_result.calmar:.2f}"
)
# ── Single run executor ───────────────────────────────────────────────────
def _execute_run(
self, run_id, params, period_start, period_end, phase,
builder, runner, parser, store, writer, ranker, profile
) -> RankedResult:
"""Execute one MT5 backtest and return a RankedResult."""
run_dir = RUNS_DIR / run_id
run_dir.mkdir(parents=True, exist_ok=True)
try:
ini_path = builder.build(
run_id=run_id, params=params,
period_start=period_start, period_end=period_end,
output_dir=run_dir, phase=phase,
ea_file=profile.ex5_file,
ea_symbol=profile.symbol,
ea_timeframe=profile.timeframe,
)
run = Run(
run_id=run_id,
ea_name=profile.name, symbol=profile.symbol,
timeframe=profile.timeframe,
period_start=period_start, period_end=period_end,
params=params, phase=phase,
tester_model=self.cfg["mt5"]["tester_model"],
ini_snapshot=ini_path.read_text(),
)
store.save_run(run)
result = runner.run(
run_id, ini_path, run_dir / "report",
log_csv_search_dir=Path(self.cfg["mt5"]["mql5_files_path"]),
profile=profile,
)
if not result.success:
return ranker.make_result(run_id, params, phase, None,
error=result.error_message)
metrics, _ = parser.parse(result.report_xml, result.report_html)
if metrics is None:
return ranker.make_result(run_id, params, phase, None,
error="parse_failed")
metrics.run_id = run_id
writer.write(run_id, metrics, pd.DataFrame(), [], params)
return ranker.make_result(run_id, params, phase, metrics)
except Exception as e:
logger.warning(f"[{run_id}] Run error: {e}")
return ranker.make_result(run_id, params, phase, None, error=str(e))
# ── Verdict logic ─────────────────────────────────────────────────────────
def _determine_verdict(
self,
best: RankedResult,
oos: Optional[RankedResult],
sens: list[RankedResult],
) -> str:
"""
RECOMMENDED: IS profitable + OOS profitable + not fragile
RISKY: IS profitable but OOS weak OR fragile
NOT_RELIABLE: IS marginal or OOS loss
"""
if best.calmar < 0.1 or best.net_profit <= 0:
return "NOT_RELIABLE"
oos_ok = False
if oos and oos.net_profit > 0:
# OOS degradation: acceptable if OOS calmar ≥ 50% of IS calmar
oos_ratio = oos.calmar / max(best.calmar, 0.001)
oos_ok = oos_ratio >= 0.50
elif oos is None:
oos_ok = True # No OOS test — can't penalize
# Sensitivity: fragile if any nudge drops Calmar by >50%
fragile = any(
s.passing and s.calmar < best.calmar * 0.50
for s in sens
) if sens else False
if oos_ok and not fragile and best.calmar >= 0.30:
return "RECOMMENDED"
if oos_ok or (not fragile and best.calmar >= 0.20):
return "RISKY"
return "NOT_RELIABLE"
# ── .set file output ──────────────────────────────────────────────────────
def _write_set_file(
self, result: RankedResult, schema: ParameterSchema, cfg: SessionConfig
) -> Optional[Path]:
"""Write a clean .set file for MT5 import."""
try:
out_dir = self.reports_dir / result.run_id
out_dir.mkdir(parents=True, exist_ok=True)
out_path = out_dir / f"{cfg.ea_name}_optimized_{cfg.symbol}_{cfg.timeframe}.set"
header = (
f"Optimized by MT5 Smart Optimizer\n"
f"EA: {cfg.ea_name} | Symbol: {cfg.symbol} | TF: {cfg.timeframe}\n"
f"Training: {cfg.train_start} {cfg.train_end}\n"
f"Verdict: {self.verdict}\n"
f"Net Profit: ${result.net_profit:.2f} | Calmar: {result.calmar:.2f} | "
f"Win Rate: {result.win_rate:.1f}%\n"
f"Generated: {datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')}"
)
content = schema.to_set_file(result.params, header_comment=header)
out_path.write_text(content, encoding="utf-8")
logger.info(f"Optimized .set file written: {out_path}")
return out_path
except Exception as e:
logger.warning(f"Could not write .set file: {e}")
return None
# ── Helpers ───────────────────────────────────────────────────────────────
def _result_to_dict(self, r: RankedResult) -> dict:
return {
"run_id": r.run_id,
"rank": r.rank,
"score": round(r.score, 4),
"net_profit": round(r.net_profit, 2),
"calmar": round(r.calmar, 3),
"profit_factor": round(r.profit_factor, 3),
"win_rate": round(r.win_rate, 1),
"max_drawdown": round(r.max_drawdown, 2),
"total_trades": r.total_trades,
"passing": r.passing,
"params_summary": self._params_summary(r.params),
}
@staticmethod
def _params_summary(params: dict) -> str:
"""Show a few key params for display."""
keys = ["InpRiskPercent", "InpRRRatio", "InpMaxDailyLossPct",
"InpTrailStartPips", "InpMinScore", "InpSessionStart", "InpSessionEnd"]
parts = []
for k in keys:
if k in params:
short = k.replace("Inp", "")
parts.append(f"{short}={params[k]}")
return " | ".join(parts[:4])
def _log(self, level: str, msg: str) -> None:
getattr(logger, level, logger.info)(msg)
self._emit("log", {"level": level, "msg": msg})
def _emit(self, event: str, data: dict = {}) -> None:
try:
self.socketio.emit(event, data)
except Exception as e:
logger.debug(f"Emit error ({event}): {e}")