feat: open-source release — AI-driven autonomous optimization with live visibility

Major upgrade making the AI loop visible and the project ready for public release.

UI / UX
- Live AI Thinking Feed: streams reasoning, decisions, and outcomes per iteration
- Parameter Changes panel: prev → new + reason for every AI-driven edit
- Validation Activity panel: out-of-sample + sensitivity runs with live metrics
- Early Termination banner: surfaces why optimization stopped (targets met, no profit, budget, stuck, user stop)
- 3-phase tracker renamed Exploration / Iteration / Validation with live N/total
- Best Result modal exposes Evolution Path showing how the AI arrived at the winner
- Run-detail modal accessible from every recent run row
- Setup form validation (dates, walk-forward order, params selection, AI targets)
- Pause button removed; misleading sidebar nav consolidated to Dashboard / New Run / Reports / Source

Backend
- AIGuidedLoop streams ai_thinking, param_changes, ai_targets_met, ai_stuck
- Pipeline emits validation_start / validation_run_start / validation_run_complete / validation_done
- Pipeline emits early_termination on every early-stop path
- /api/best_result returns best run + full evolution chain
- /api/run/<id> + /api/runs sorted by ts
- AIReasoner falls back to ANTHROPIC_API_KEY env var when config is a placeholder
- Demo mode (APEX_DEMO_MODE=1) generates deterministic synthetic backtests so judges can run end-to-end without MT5

Open-source readiness
- README.md with pitch, demo flow, architecture diagram, quickstart, event reference
- LICENSE (MIT)
- config.example.yaml template (config.yaml now git-ignored)
- requirements.txt: added anthropic / requests / psutil / beautifulsoup4, capped majors
- .gitignore: secrets, *.set, scratch screenshots, ea_registry.yaml
- demo/run_demo.py: one-command offline demo runner
- 10 polished screenshots for README + judge review
This commit is contained in:
LEGSTECH Optimizer
2026-04-25 11:39:17 +00:00
parent e5dd9550b7
commit 6caafdb794
31 changed files with 7546 additions and 1113 deletions
+576
View File
@@ -0,0 +1,576 @@
"""
optimizer/ai_guided_loop.py
AI-Guided Autonomous Optimization Loop.
Replaces Phase 2's blind random neighbor search with directed,
AI-driven parameter evolution. Each iteration:
1. Build rich context: parameter schema + full history
2. Ask AI: "what parameter values should I try next?"
3. Apply changes with bounds checking
4. Deduplicate (don't re-test seen param sets)
5. Run backtest via existing pipeline._execute_run()
6. Check stop conditions (targets met OR max iterations)
7. Emit progress to frontend, update pipeline state
8. Loop
The loop terminates when:
- All quality targets are met by the current best result
- Max iterations reached
- Stop flag set externally (user clicked Stop)
- Budget exhausted
"""
from __future__ import annotations
import hashlib
import json
import random
import time
from datetime import datetime
from typing import Optional
from loguru import logger
from ea.schema import ParameterSchema
from optimizer.result_ranker import RankedResult, ResultRanker
from optimizer.session_config import SessionConfig
from analysis.ai_reasoner import AIReasoner, AIParamSuggestion
class AIGuidedLoop:
"""
Autonomous AI-driven parameter search.
Usage (from inside OptimizationPipeline._run_pipeline):
loop = AIGuidedLoop(pipeline, schema, cfg, builder, runner,
parser, store, writer, ranker, profile, budget)
loop.run(seed_results, max_iterations, targets)
# Results available in loop.all_results, loop.best_result
"""
# If last N iterations show less than this score improvement → escape
STUCK_WINDOW = 3
STUCK_THRESHOLD = 0.005
def __init__(
self,
pipeline, # OptimizationPipeline — for _execute_run / _emit / _log
schema: ParameterSchema,
cfg: SessionConfig,
builder, runner, parser, store, writer,
ranker: ResultRanker,
profile,
budget,
):
self.pipeline = pipeline
self.schema = schema
self.cfg = cfg
self.builder = builder
self.runner = runner
self.parser = parser
self.store = store
self.writer = writer
self.ranker = ranker
self.profile = profile
self.budget = budget
# Public results — populated during run()
self.all_results: list[RankedResult] = []
self.best_result: Optional[RankedResult] = None
# Internal state
self._iteration_history: list[dict] = [] # rich history for AI prompt
self._seen_hashes: set[str] = set()
self._rng = random.Random(int(time.time()))
# ── Public entry point ────────────────────────────────────────────────────
def run(
self,
seed_results: list[RankedResult],
max_iterations: int,
targets: dict,
) -> RankedResult:
"""
Run the autonomous loop. Returns the best result found.
seed_results: Phase 1 ranked results (provides initial best + seen params)
max_iterations: hard cap on AI-directed iterations
targets: {min_profit_factor, max_drawdown_pct, min_calmar}
"""
self._initialize_from_seeds(seed_results)
self._log("info", f"━━ AI-Guided Loop: up to {max_iterations} iterations ━━")
self._log("info",
f" Targets → PF≥{targets.get('min_profit_factor',1.5)} | "
f"DD≤{targets.get('max_drawdown_pct',20)}% | "
f"Calmar≥{targets.get('min_calmar',0.5)}"
)
self._think(
f"Targets set — PF≥{targets.get('min_profit_factor',1.5)}, "
f"DD≤{targets.get('max_drawdown_pct',20)}%, Calmar≥{targets.get('min_calmar',0.5)}. "
f"I'll stop as soon as I hit them, or after {max_iterations} iterations.",
kind="reasoning",
)
schema_info = self._build_schema_info()
for iteration in range(1, max_iterations + 1):
if self.pipeline._stop_flag:
self._log("info", "Loop stopped by user.")
break
if self.budget.is_exhausted():
self._log("warning", "⏱ Time budget exhausted — stopping AI loop.")
break
# Check if current best already satisfies all targets
if self.best_result and self._targets_met(self.best_result, targets):
self._log("info",
f"✅ All targets met after iteration {iteration - 1}! "
f"PF={self.best_result.profit_factor:.2f}, "
f"DD={self.best_result.max_drawdown:.1f}%, "
f"Calmar={self.best_result.calmar:.2f}"
)
self._think(
f"All quality targets reached after iteration {iteration - 1}. "
f"Best config: PF={self.best_result.profit_factor:.2f}, "
f"DD={self.best_result.max_drawdown:.1f}%, "
f"Calmar={self.best_result.calmar:.2f}. Stopping early — no need to keep iterating.",
kind="success",
)
self._emit("ai_targets_met", {
"iteration": iteration - 1,
"profit_factor": round(self.best_result.profit_factor, 3),
"max_drawdown": round(self.best_result.max_drawdown, 2),
"calmar": round(self.best_result.calmar, 3),
})
self.pipeline._emit_early_termination(
reason_code="targets_met",
message=f"All targets met at iteration {iteration - 1}. Optimization complete.",
details={
"iteration": iteration - 1,
"profit_factor": round(self.best_result.profit_factor, 3),
"max_drawdown": round(self.best_result.max_drawdown, 2),
"calmar": round(self.best_result.calmar, 3),
},
)
break
self._log("info",
f"[AI Loop {iteration}/{max_iterations}] "
f"Best so far: PF={self.best_result.profit_factor:.2f}, "
f"Calmar={self.best_result.calmar:.2f}, "
f"DD={self.best_result.max_drawdown:.1f}%"
if self.best_result else f"[AI Loop {iteration}/{max_iterations}] Starting..."
)
self._think(
f"Iteration {iteration}: reviewing history and deciding what to change next...",
kind="info", iteration=iteration,
)
# Get AI suggestion for next params
suggestion = self._get_suggestion(schema_info, targets)
# Surface the AI's reasoning as its own thinking message
if suggestion.analysis:
self._think(suggestion.analysis, kind="reasoning", iteration=iteration)
# Apply changes to best params → candidate param set
base_params = self.best_result.params if self.best_result else self.schema.defaults()
next_params = self._apply_changes(
base=base_params,
changes=suggestion.changes,
)
# Escape if stuck or AI returned no changes
is_stuck = self._check_stuck()
if is_stuck or not suggestion.changes:
if is_stuck:
self._log("warning", f" ⚠ Stuck detected — applying random escape at iteration {iteration}")
self._think(
f"Recent scores are flat — the AI is stuck in a local optimum. "
f"Applying a random ±30% perturbation to escape and explore a new region.",
kind="warning", iteration=iteration,
)
else:
self._log("warning", f" ⚠ AI returned no changes — applying random escape")
self._think(
"AI returned no changes — falling back to a random perturbation so we keep exploring.",
kind="warning", iteration=iteration,
)
next_params = self._random_escape(next_params)
self._emit("ai_stuck", {"iteration": iteration})
# Deduplicate — ensure we're not re-testing an identical config
next_params = self._ensure_unique(next_params, max_attempts=5)
# Build rich param change records (prev → new + reason) for the UI
change_records = self._build_change_records(base_params, next_params, suggestion.changes)
# Narrate the actual parameter changes
for c in change_records[:4]: # cap noise
self._think(
f"{c['param']}: {c['from']}{c['to']}{c['reason']}",
kind="decision",
iteration=iteration,
meta=c,
)
# Emit iteration start
self._emit("ai_iteration_start", {
"iteration": iteration,
"max_iterations": max_iterations,
"analysis": suggestion.analysis,
"changes": suggestion.changes,
"change_records": change_records,
"confidence": round(suggestion.confidence, 2),
"is_stuck_escape": is_stuck or not suggestion.changes,
})
# Dedicated richer event that the dashboard subscribes to
self._emit("param_changes", {
"iteration": iteration,
"run_id": None, # filled in after run below via complete event
"analysis": suggestion.analysis,
"changes": change_records,
"confidence": round(suggestion.confidence, 2),
})
# Run the backtest
run_id = f"ai_{iteration:02d}_{datetime.utcnow().strftime('%H%M%S')}"
t0 = time.time()
result = self.pipeline._execute_run(
run_id, next_params,
self.cfg.train_start, self.cfg.train_end,
"phase2_ai",
self.builder, self.runner, self.parser,
self.store, self.writer, self.ranker, self.profile,
)
elapsed = time.time() - t0
self.budget.record_run(elapsed)
# Register result
self.all_results.append(result)
self._mark_seen(next_params)
self._update_best(result)
self.pipeline._run_count += 1
# Update pipeline live state
if (self.pipeline._live_best is None
or result.score > self.pipeline._live_best.score):
self.pipeline._live_best = result
run_dict = self.pipeline._make_run_dict(run_id, result, "phase2_ai")
self.pipeline._completed_runs.append(run_dict)
# Record iteration for AI history
targets_met = self.best_result and self._targets_met(self.best_result, targets)
self._record_iteration(iteration, run_id, result, suggestion)
goal_status = {
"profit_factor_met": result.profit_factor >= targets.get("min_profit_factor", 1.5),
"drawdown_ok": result.max_drawdown <= targets.get("max_drawdown_pct", 20.0),
"calmar_met": result.calmar >= targets.get("min_calmar", 0.5),
}
# Narrate the outcome of this iteration
improved = (self.best_result is self.all_results[-1]) if self.all_results else False
if result.passing and improved:
self._think(
f"✓ Iteration {iteration} improved the best score to {result.score:.3f} "
f"(PF={result.profit_factor:.2f}, Calmar={result.calmar:.2f}, "
f"DD={result.max_drawdown:.1f}%). Keeping these params as the new baseline.",
kind="success", iteration=iteration,
)
elif result.passing:
self._think(
f"Iteration {iteration} passed thresholds but didn't beat the best — "
f"score {result.score:.3f} vs best {self.best_result.score:.3f}.",
kind="info", iteration=iteration,
)
else:
diagnosis = self._diagnose_failure(result, targets)
self._think(
f"✗ Iteration {iteration} failed: {diagnosis} "
f"(PF={result.profit_factor:.2f}, DD={result.max_drawdown:.1f}%). "
f"Will adjust in the next step.",
kind="warning", iteration=iteration,
)
# Emit iteration complete
self._emit("ai_iteration_complete", {
"iteration": iteration,
"max_iterations": max_iterations,
"run_id": run_id,
"score": round(result.score, 4),
"profit_factor": round(result.profit_factor, 3),
"calmar": round(result.calmar, 3),
"max_drawdown": round(result.max_drawdown, 2),
"net_profit": round(result.net_profit, 2),
"total_trades": result.total_trades,
"passing": result.passing,
"best_score": round(self.best_result.score, 4) if self.best_result else 0,
"best_pf": round(self.best_result.profit_factor, 3) if self.best_result else 0,
"best_calmar": round(self.best_result.calmar, 3) if self.best_result else 0,
"goal_status": goal_status,
"targets_met": bool(targets_met),
"improved": bool(improved),
"confidence": round(suggestion.confidence, 2),
"analysis": suggestion.analysis,
"change_records": change_records,
})
self._emit("run_complete", {
"run_id": run_id,
"phase": "phase2_ai",
"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,
"score": round(result.score, 4),
"progress_pct": round(self.pipeline._run_count / max(self.pipeline._total_runs, 1) * 100),
})
status = "" if result.passing else ""
self._log(
"info" if result.passing else "warning",
f" {status} iter={iteration} | PF={result.profit_factor:.2f} | "
f"Calmar={result.calmar:.2f} | DD={result.max_drawdown:.1f}% | "
f"trades={result.total_trades} | confidence={suggestion.confidence:.2f}"
)
return self.best_result
# ── Initialization ────────────────────────────────────────────────────────
def _initialize_from_seeds(self, seed_results: list[RankedResult]) -> None:
"""Register Phase 1 results as seen and find initial best."""
for r in seed_results:
self._mark_seen(r.params)
passing = [r for r in seed_results if r.passing]
if passing:
self.best_result = max(passing, key=lambda r: r.score)
self._log("info",
f"AI loop seed: best Phase 1 result is {self.best_result.run_id} "
f"(PF={self.best_result.profit_factor:.2f}, score={self.best_result.score:.4f})"
)
# Populate initial iteration history from Phase 1 top results
top_seeds = sorted(passing, key=lambda r: r.score, reverse=True)[:5]
for i, r in enumerate(top_seeds):
self._iteration_history.append({
"iteration": f"p1_top{i+1}",
"run_id": r.run_id,
"score": round(r.score, 4),
"pf": round(r.profit_factor, 3),
"calmar": round(r.calmar, 3),
"dd": round(r.max_drawdown, 2),
"trades": r.total_trades,
"changes": [], # LHS seeds have no "changes"
"params": r.params,
})
# ── AI interaction ────────────────────────────────────────────────────────
def _get_suggestion(
self, schema_info: list[dict], targets: dict
) -> AIParamSuggestion:
"""Ask AIReasoner for the next parameter set."""
reasoner: AIReasoner = self.pipeline._ai_reasoner
if not reasoner or not reasoner.enabled:
return AIParamSuggestion(
analysis="AI unavailable — using random escape.",
changes=[], confidence=0.0, goal_status={}, error="no_ai",
)
current_params = self.best_result.params if self.best_result else self.schema.defaults()
return reasoner.suggest_next_params(
current_best_params=current_params,
schema_info=schema_info,
iteration_history=self._iteration_history,
targets=targets,
)
# ── Parameter manipulation ────────────────────────────────────────────────
def _build_schema_info(self) -> list[dict]:
"""Convert schema optimizable params to serializable dicts for the AI prompt."""
return [
{
"name": p.name,
"type": p.type,
"min": p.min,
"max": p.max,
"step": p.step,
"default": p.default,
"enum_values": p.enum_values if p.type == "enum" else [],
}
for p in self.schema.optimizable()
]
def _apply_changes(self, base: dict, changes: list[dict]) -> dict:
"""
Apply AI-suggested changes to base params.
Uses ParameterDef.clamp() to enforce valid ranges and types.
"""
result = dict(base)
param_map = {p.name: p for p in self.schema.optimizable()}
for change in changes:
name = change.get("param", "")
value = change.get("value")
if name not in param_map or value is None:
continue
pdef = param_map[name]
try:
result[name] = pdef.clamp(float(value) if pdef.type in ("float", "int") else value)
except Exception as e:
logger.debug(f"AIGuidedLoop: skipping change {name}={value}: {e}")
return result
def _build_change_records(
self, before: dict, after: dict, ai_changes: list[dict]
) -> list[dict]:
"""
Produce a list of {param, from, to, reason} records for the UI.
The AI's suggested changes may include a `reason` per change; we match
those by name. Parameters that differ without a matching reason still
get recorded (labelled "random perturbation").
"""
reason_by_param = {
c.get("param"): c.get("reason", "").strip()
for c in (ai_changes or [])
if c.get("param")
}
records = []
for name, new_val in after.items():
old_val = before.get(name)
if old_val == new_val:
continue
records.append({
"param": name,
"from": old_val,
"to": new_val,
"reason": reason_by_param.get(name) or "random perturbation (escape from stuck region)",
})
return records
def _random_escape(self, base: dict) -> dict:
"""Random perturbation when stuck — perturbs 2-4 random optimizable params by ±30% of range."""
opts = self.schema.optimizable()
if not opts:
return dict(base)
candidate = dict(base)
n_perturb = min(len(opts), self._rng.randint(2, 4))
to_perturb = self._rng.sample(opts, n_perturb)
for p in to_perturb:
if p.type == "bool":
candidate[p.name] = not candidate.get(p.name, p.default)
elif p.type == "enum":
candidate[p.name] = self._rng.choice(p.enum_values)
else:
span = float(p.max) - float(p.min)
delta = span * 0.30 * self._rng.choice([-1, 1])
candidate[p.name] = p.clamp(float(candidate.get(p.name, p.default)) + delta)
return candidate
def _ensure_unique(self, params: dict, max_attempts: int = 5) -> dict:
"""If params already seen, perturb until unique (or give up)."""
for _ in range(max_attempts):
if self._hash(params) not in self._seen_hashes:
return params
params = self._random_escape(params)
return params # best effort
def _mark_seen(self, params: dict) -> None:
self._seen_hashes.add(self._hash(params))
@staticmethod
def _hash(params: dict) -> str:
key = json.dumps(params, sort_keys=True, default=str)
return hashlib.md5(key.encode()).hexdigest()
# ── Best tracking ─────────────────────────────────────────────────────────
def _update_best(self, result: RankedResult) -> None:
if result.passing:
if self.best_result is None or result.score > self.best_result.score:
self.best_result = result
# ── Stop conditions ───────────────────────────────────────────────────────
def _diagnose_failure(self, result: RankedResult, targets: dict) -> str:
"""Human-readable reason this iteration didn't pass quality gates."""
reasons = []
if result.max_drawdown > targets.get("max_drawdown_pct", 20.0):
reasons.append(f"drawdown too high ({result.max_drawdown:.1f}%)")
if result.profit_factor < targets.get("min_profit_factor", 1.5):
reasons.append(f"profit factor too low ({result.profit_factor:.2f})")
if result.calmar < targets.get("min_calmar", 0.5):
reasons.append(f"Calmar too low ({result.calmar:.2f})")
if result.net_profit <= 0:
reasons.append(f"unprofitable (${result.net_profit:.0f})")
if result.total_trades < 10:
reasons.append(f"too few trades ({result.total_trades})")
return ", ".join(reasons) or "result below quality threshold"
def _targets_met(self, result: RankedResult, targets: dict) -> bool:
if not result or not result.passing:
return False
return (
result.profit_factor >= targets.get("min_profit_factor", 1.5)
and result.max_drawdown <= targets.get("max_drawdown_pct", 20.0)
and result.calmar >= targets.get("min_calmar", 0.5)
)
def _check_stuck(self) -> bool:
"""Return True if last STUCK_WINDOW iterations improved less than STUCK_THRESHOLD."""
ai_iters = [h for h in self._iteration_history if str(h.get("iteration", "")).startswith(("1","2","3","4","5","6","7","8","9"))]
if len(ai_iters) < self.STUCK_WINDOW:
return False
recent_scores = [h["score"] for h in ai_iters[-self.STUCK_WINDOW:]]
return (max(recent_scores) - min(recent_scores)) < self.STUCK_THRESHOLD
# ── History tracking ──────────────────────────────────────────────────────
def _record_iteration(
self,
iteration: int,
run_id: str,
result: RankedResult,
suggestion: AIParamSuggestion,
) -> None:
self._iteration_history.append({
"iteration": iteration,
"run_id": run_id,
"score": round(result.score, 4),
"pf": round(result.profit_factor, 3),
"calmar": round(result.calmar, 3),
"dd": round(result.max_drawdown, 2),
"trades": result.total_trades,
"changes": suggestion.changes,
"params": result.params,
})
# ── Pipeline helpers ──────────────────────────────────────────────────────
def _emit(self, event: str, data: dict = {}) -> None:
self.pipeline._emit(event, data)
def _log(self, level: str, msg: str) -> None:
self.pipeline._log(level, msg)
def _think(self, msg: str, kind: str = "info", iteration: Optional[int] = None, meta: Optional[dict] = None) -> None:
"""Stream an AI-thinking message to the dashboard."""
self.pipeline._emit_thinking(msg, kind=kind, iteration=iteration, meta=meta)
+549 -52
View File
@@ -13,17 +13,18 @@ Architecture:
"""
from __future__ import annotations
import hashlib
import os
import random
import time
import uuid
import threading
from datetime import datetime
from pathlib import Path
from typing import Any, Optional, Callable
from typing import Optional
import yaml
from loguru import logger
from ea.registry import EARegistry, EAProfile
from ea.registry import EARegistry
from ea.schema import ParameterSchema
from mt5.ini_builder import IniBuilder
from mt5.runner import MT5Runner
@@ -39,6 +40,11 @@ from optimizer.budget import BudgetManager
import pandas as pd
from analysis.equity_curve import EquityCurveAnalyzer
from analysis.time_performance import TimePerformanceAnalyzer
from analysis.ai_reasoner import AIReasoner
from analysis.ai_reasoner_config import load_api_key
BASE_DIR = Path(__file__).parent.parent
RUNS_DIR = BASE_DIR / "runs"
DB_PATH = BASE_DIR / "optimizer.db"
@@ -78,6 +84,18 @@ class OptimizationPipeline:
self.best_set_path: Optional[Path] = None
self.run_start_ts: Optional[float] = None
# AI & analysis state
self._ai_reasoner: Optional[AIReasoner] = None
self._ai_insights: list[dict] = []
self._run_findings: dict[str, list] = {}
self._run_insights: dict[str, dict] = {}
self._baseline_metrics: Optional[dict] = None
# Running best (updated each run so status can serve it live)
self._live_best: Optional[RankedResult] = None
# All completed runs (for history restoration)
self._completed_runs: list[dict] = []
# ── Public API ────────────────────────────────────────────────────────────
def configure(self, session: SessionConfig) -> None:
@@ -86,20 +104,36 @@ class OptimizationPipeline:
def stop(self) -> None:
self._stop_flag = True
self._emit("status_change", {"state": "stopping"})
self._emit_early_termination(
reason_code="user_stop",
message="User requested stop. Finishing current run and exiting.",
details={"phase": self._phase},
)
def get_status(self) -> dict:
elapsed = int(time.time() - self.run_start_ts) if self.run_start_ts else 0
# Use final_result if set, otherwise best seen so far during the run
best = self.final_result or self._live_best
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,
"best_score": round(best.score, 4) if best 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 "",
"has_insight": bool(self._ai_insights),
"insight_count": len(self._ai_insights),
"latest_insight": self._ai_insights[-1] if self._ai_insights else None,
"best_net_profit": round(best.net_profit, 2) if best else None,
"best_calmar": round(best.calmar, 3) if best else None,
"best_pf": round(best.profit_factor, 3) if best else None,
"best_win_rate": round(best.win_rate, 1) if best else None,
"best_max_drawdown": round(best.max_drawdown, 2) if best else None,
"best_total_trades": best.total_trades if best else None,
}
# ── Main entry point ──────────────────────────────────────────────────────
@@ -147,6 +181,13 @@ class OptimizationPipeline:
ranker = ResultRanker(weights=cfg.scoring_weights)
budget = BudgetManager(cfg.budget_minutes)
# Initialize AI reasoning layer
api_key = load_api_key(self.config_path)
self._ai_reasoner = AIReasoner(api_key=api_key)
self._ai_insights.clear()
self._run_findings.clear()
self._run_insights.clear()
budget.start()
# ── Phase 1: Broad Discovery ─────────────────────────────────────────
@@ -155,6 +196,11 @@ class OptimizationPipeline:
self._phase = "phase1"
self._emit("phase_start", {"phase": "phase1", "total": cfg.phase1_samples})
self._emit_thinking(
f"Starting broad discovery with {cfg.phase1_samples} Latin-Hypercube samples. "
f"Scanning parameter space to find profitable regions before focused refinement.",
kind="reasoning",
)
self._log("info", f"━━ Phase 1: Broad Discovery ({cfg.phase1_samples} configurations) ━━")
samples = sampler.sample(schema, cfg.phase1_samples)
@@ -176,19 +222,17 @@ class OptimizationPipeline:
phase1_raw.append(result)
self._run_count += 1
# Track live best and completed runs for status/history APIs
if self._live_best is None or result.score > self._live_best.score:
self._live_best = result
run_dict = self._make_run_dict(run_id, result, "phase1")
self._completed_runs.append(run_dict)
# Emit progress after each run
self._emit("run_complete", {
"run_id": run_id,
"phase": "phase1",
**run_dict,
"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(),
})
@@ -221,19 +265,101 @@ class OptimizationPipeline:
"or try a different timeframe."
)
})
self._emit_early_termination(
reason_code="no_profit",
message="Optimization stopped early: Phase 1 found no profitable configuration.",
details={
"phase": "phase1",
"total_tested": len(self.phase1_results),
"suggestions": [
"Try a different date range",
"Check EA settings for issues",
"Try a different timeframe",
],
},
)
self._emit_thinking(
"No profitable configuration found across the broad scan. "
"Strategy is unstable under current EA settings — stopping optimization.",
kind="warning",
)
self._log("error", "❌ No profitable configuration found in Phase 1. Stopping.")
return
if self._stop_flag:
return
# ── Phase 2: Deep Refinement ─────────────────────────────────────────
# ── Phase 2: Refinement (AI-Guided or Random Neighbor) ───────────────
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]
elif cfg.autonomous_mode and self._ai_reasoner and self._ai_reasoner.enabled:
# ── AI-Guided Autonomous Loop ─────────────────────────────────────
self._phase = "phase2"
self._emit("phase_start", {
"phase": "phase2",
"total": cfg.autonomous_max_iterations,
"mode": "autonomous",
})
self._emit_thinking(
f"Phase 1 found {len(top5)} promising candidates. Best Calmar is "
f"{top5[0].calmar:.2f}. Switching to autonomous AI loop — I'll read each "
f"result, decide what parameters to change, and iterate toward the targets.",
kind="reasoning",
)
self._log("info",
f"━━ Phase 2: Autonomous AI Loop "
f"(up to {cfg.autonomous_max_iterations} iterations) ━━"
)
from optimizer.ai_guided_loop import AIGuidedLoop
loop = AIGuidedLoop(
pipeline=self, schema=schema, cfg=cfg,
builder=builder, runner=runner, parser=parser,
store=store, writer=writer, ranker=ranker,
profile=profile, budget=budget,
)
targets = {
"min_profit_factor": cfg.target_profit_factor,
"max_drawdown_pct": cfg.target_max_drawdown_pct,
"min_calmar": cfg.target_min_calmar,
}
loop.run(
seed_results=self.phase1_results,
max_iterations=cfg.autonomous_max_iterations,
targets=targets,
)
self.phase2_results = loop.all_results
all_candidates = [r for r in (self.phase1_results + loop.all_results) if r.passing]
all_ranked = ranker.rank(all_candidates) if all_candidates else ranker.rank(self.phase1_results)
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),
"mode": "autonomous",
"iterations": len(loop.all_results),
})
self._log("info",
f"AI Loop complete. Best: {self.final_result.run_id} "
f"(PF={self.final_result.profit_factor:.2f}, "
f"profit=${self.final_result.net_profit:.0f}, "
f"calmar={self.final_result.calmar:.2f})"
)
else:
# ── Original Random-Neighbor Phase 2 ─────────────────────────────
self._phase = "phase2"
self._emit("phase_start", {"phase": "phase2", "total": cfg.phase2_samples})
self._emit_thinking(
f"Phase 2 starting in classic mode: sampling {cfg.phase2_samples} neighbors "
f"around the top {min(3, len(top5))} Phase-1 winners (no AI loop).",
kind="info",
)
self._log("info", f"━━ Phase 2: Deep Refinement (refining top {min(3, len(top5))} configs) ━━")
top3 = top5[:3]
@@ -264,20 +390,19 @@ class OptimizationPipeline:
phase2_raw.append(result)
self._run_count += 1
run_dict_p2 = self._make_run_dict(run_id, result, "phase2")
self._completed_runs.append(run_dict_p2)
if self._live_best is None or result.score > self._live_best.score:
self._live_best = result
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,
**run_dict_p2,
"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
or list(self.phase1_results)
)
self.phase2_results = ranker.rank(phase2_raw)
self.final_result = all_ranked[0] if all_ranked else top5[0]
@@ -299,16 +424,56 @@ class OptimizationPipeline:
# ── Phase 3: Validation ───────────────────────────────────────────────
if not budget.can_fit(2):
self._log("warning", "⏱ Not enough budget for Phase 3 validation — skipping OOS test")
self._emit_early_termination(
reason_code="budget_exhausted",
message="Skipping validation: not enough time budget remaining.",
details={"phase": "phase3"},
)
self.verdict = "RISKY"
oos_result = None
else:
self._phase = "phase3"
self._emit("phase_start", {"phase": "phase3", "total": cfg.phase3_samples})
# Count the validation runs we intend to run so progress makes sense
opts = schema.optimizable()
plan_sens = bool(opts) and budget.can_fit(2)
planned_runs = 1 + (2 if plan_sens else 0) # 1 OOS + 2 sens
self._emit("validation_start", {
"best_run_id": self.final_result.run_id,
"planned_runs": planned_runs,
"oos_start": cfg.val_start,
"oos_end": cfg.val_end,
"train_start": cfg.train_start,
"train_end": cfg.train_end,
"sensitivity": plan_sens,
})
self._emit_thinking(
f"Entering validation phase. Testing best config {self.final_result.run_id} "
f"on out-of-sample data ({cfg.val_start}{cfg.val_end}) + sensitivity checks.",
kind="reasoning",
)
self._log("info", "━━ Phase 3: Validation (out-of-sample + sensitivity) ━━")
# OOS test
# ── 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}")
self._emit("validation_run_start", {
"run_id": oos_id,
"kind": "oos",
"label": "Out-of-Sample",
"description": f"Re-testing best config on unseen data: {cfg.val_start}{cfg.val_end}",
"index": 1,
"total": planned_runs,
"period_start": cfg.val_start,
"period_end": cfg.val_end,
"params": self.final_result.params,
})
self._emit_thinking(
"Running out-of-sample test: if the strategy holds up on data it wasn't "
"optimized on, the edge is real — not curve-fit noise.",
kind="hypothesis",
)
t0 = time.time()
oos_result = self._execute_run(
oos_id, self.final_result.params,
@@ -318,20 +483,47 @@ class OptimizationPipeline:
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,
oos_dict = self._make_run_dict(oos_id, oos_result, "phase3_oos")
self._completed_runs.append(oos_dict)
self._emit("run_complete", {**oos_dict, "progress_pct": round(self._run_count / self._total_runs * 100)})
self._emit("validation_run_complete", {
"run_id": oos_id,
"kind": "oos",
"label": "Out-of-Sample",
"index": 1,
"total": planned_runs,
"net_profit": round(oos_result.net_profit, 2),
"profit_factor": round(oos_result.profit_factor, 3),
"calmar": round(oos_result.calmar, 3),
"max_drawdown": round(oos_result.max_drawdown, 2),
"total_trades": oos_result.total_trades,
"passing": oos_result.passing,
})
# Thinking narration on OOS outcome
oos_ratio = (oos_result.calmar / max(self.final_result.calmar, 0.001)) if oos_result.calmar else 0
if oos_result.net_profit > 0 and oos_ratio >= 0.50:
self._emit_thinking(
f"OOS profitable: ${oos_result.net_profit:.0f} with Calmar {oos_result.calmar:.2f} "
f"(≈{oos_ratio*100:.0f}% of training performance). The edge generalizes.",
kind="success",
)
else:
self._emit_thinking(
f"OOS weak: profit ${oos_result.net_profit:.0f}, Calmar {oos_result.calmar:.2f}"
f"strategy likely overfit to the training period.",
kind="warning",
)
# Sensitivity test (2 runs: nudge top param up and down)
# ── 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]:
self._emit_thinking(
f"Sensitivity check: nudging `{top_param.name}` ±20% to see if performance "
f"survives small parameter drift.",
kind="reasoning",
)
for i, direction in enumerate([1, -1], start=2):
if budget.is_exhausted() or self._stop_flag:
break
nudged = dict(self.final_result.params)
@@ -340,6 +532,19 @@ class OptimizationPipeline:
nudged[top_param.name] = top_param.clamp(current + direction * span * 0.20)
sens_id = f"sens_{direction}_{datetime.utcnow().strftime('%H%M%S')}"
label = f"Sensitivity ({top_param.name} {'+' if direction > 0 else '-'}20%)"
self._emit("validation_run_start", {
"run_id": sens_id,
"kind": "sensitivity",
"label": label,
"description": f"Nudging `{top_param.name}` to {nudged[top_param.name]} "
f"(from {current}) to test parameter stability.",
"index": i,
"total": planned_runs,
"period_start": cfg.train_start,
"period_end": cfg.train_end,
"params": nudged,
})
t0 = time.time()
sr = self._execute_run(
sens_id, nudged, cfg.train_start, cfg.train_end,
@@ -349,9 +554,44 @@ class OptimizationPipeline:
sens_results.append(sr)
self._run_count += 1
sens_dict = self._make_run_dict(sens_id, sr, "phase3_sens")
self._completed_runs.append(sens_dict)
self._emit("run_complete", {**sens_dict, "progress_pct": round(self._run_count / max(self._total_runs, 1) * 100)})
self._emit("validation_run_complete", {
"run_id": sens_id,
"kind": "sensitivity",
"label": label,
"index": i,
"total": planned_runs,
"net_profit": round(sr.net_profit, 2),
"profit_factor": round(sr.profit_factor, 3),
"calmar": round(sr.calmar, 3),
"max_drawdown": round(sr.max_drawdown, 2),
"total_trades": sr.total_trades,
"passing": sr.passing,
})
# Determine verdict
self.verdict = self._determine_verdict(self.final_result, oos_result, sens_results)
# Validation done — narrate the conclusion
self._emit("validation_done", {
"verdict": self.verdict,
"oos_passing": bool(oos_result and oos_result.passing),
"sens_passing": sum(1 for s in sens_results if s.passing),
"sens_total": len(sens_results),
})
verdict_narration = {
"RECOMMENDED": "Validation passed on all fronts. Strategy is robust and ready for deployment.",
"RISKY": "Validation mixed. Strategy may work but has stability concerns — proceed with care.",
"NOT_RELIABLE": "Validation failed. Strategy is not reliable — likely overfit or unstable.",
}.get(self.verdict, "Validation complete.")
self._emit_thinking(
verdict_narration,
kind=("success" if self.verdict == "RECOMMENDED"
else "warning" if self.verdict == "RISKY" else "warning"),
)
# ── Generate .set output ─────────────────────────────────────────────
self.best_set_path = self._write_set_file(self.final_result, schema, cfg)
@@ -359,6 +599,7 @@ class OptimizationPipeline:
self._emit("optimization_complete", {
"verdict": self.verdict,
"best_run_id": self.final_result.run_id,
"score": round(self.final_result.score, 4),
"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),
@@ -386,6 +627,11 @@ class OptimizationPipeline:
builder, runner, parser, store, writer, ranker, profile
) -> RankedResult:
"""Execute one MT5 backtest and return a RankedResult."""
# Demo mode short-circuit — generate synthetic metrics so judges can see the
# AI loop without an MT5 install. Toggle with APEX_DEMO_MODE=1.
if os.environ.get("APEX_DEMO_MODE", "").strip() in ("1", "true", "yes"):
return self._execute_demo_run(run_id, params, phase, ranker)
run_dir = RUNS_DIR / run_id
run_dir.mkdir(parents=True, exist_ok=True)
@@ -420,20 +666,135 @@ class OptimizationPipeline:
return ranker.make_result(run_id, params, phase, None,
error=result.error_message)
metrics, _ = parser.parse(result.report_xml, result.report_html)
metrics, trades = 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)
# Run analysis & AI reasoning
findings = self._analyze_run(run_id, metrics, trades or [], params)
self._reason_about_run(run_id, metrics, findings, params)
trades_df = pd.DataFrame()
if trades:
try:
trades_df = pd.DataFrame([t.model_dump() for t in trades])
except Exception:
try:
trades_df = pd.DataFrame([vars(t) for t in trades])
except Exception:
pass
# Build RankedResult first so we have the ranked_score for summary.json
ranked = ranker.make_result(run_id, params, phase, metrics)
ai_insight = self._run_insights.get(run_id)
writer.write(
run_id, metrics, trades_df, findings, params,
phase=phase,
ranked_score=ranked.score,
ai_insight=ai_insight,
)
return ranked
except Exception as e:
logger.warning(f"[{run_id}] Run error: {e}")
return ranker.make_result(run_id, params, phase, None, error=str(e))
# ── Demo mode (synthetic backtest) ────────────────────────────────────────
def _execute_demo_run(self, run_id: str, params: dict, phase: str, ranker) -> RankedResult:
"""
Generate a synthetic, deterministic-but-realistic RankedResult from the
params hash. Lets the AI loop run end-to-end without MT5 installed.
The metrics improve as parameters approach a hidden "sweet spot" so the
AI can hill-climb in a way judges can observe. We also add small jitter
to look organic.
"""
from data.models import RunMetrics # local import to avoid cycle
# Hash params → stable seed per config
key = ",".join(f"{k}={v}" for k, v in sorted(params.items()))
seed = int(hashlib.md5(key.encode()).hexdigest()[:12], 16)
rng = random.Random(seed)
# Hidden sweet-spot hash drift: a deterministic "true score" between 01
# based on a smooth function of parameter values. Small param changes
# produce small score changes — that's what lets the AI hill-climb.
true_score = 0.0
for k, v in sorted(params.items()):
try:
fv = float(v)
# Map value into [-1,1] using a stable hash, multiply by a gentle
# bias toward "moderate" values (sweet spot is mid-range).
slot = (int(hashlib.md5(k.encode()).hexdigest()[:8], 16) % 100) / 100.0
normalized = (fv % 100) / 100.0
true_score += 1.0 - abs(normalized - slot)
except Exception:
true_score += 0.5
if params:
true_score /= len(params)
true_score = max(0.05, min(0.98, true_score))
# Add jitter for realism (±10%)
true_score *= rng.uniform(0.90, 1.10)
true_score = max(0.05, min(0.99, true_score))
# Project onto realistic metric ranges
profit_factor = round(0.7 + true_score * 1.8, 3) # 0.72.5
calmar = round(true_score * 1.4, 3) # 0.01.4
max_drawdown = round(28 - true_score * 22, 2) # 28%→6%
win_rate = round(40 + true_score * 25, 1) # 40%→65%
total_trades = int(80 + rng.random() * 220) # 80300
net_profit = round((profit_factor - 1) * 5000 * (1 + rng.uniform(-0.2, 0.2)), 2)
# Out-of-sample tends to be slightly worse (more realistic)
if phase.startswith("phase3_oos"):
profit_factor *= 0.85
calmar *= 0.80
net_profit *= 0.75
avg_trade = net_profit / max(total_trades, 1)
winners = int(total_trades * (win_rate / 100.0))
losers = max(1, total_trades - winners)
# Derive avg win/loss consistent with profit_factor: PF = (winners*avg_win)/(losers*|avg_loss|)
avg_loss = -abs(avg_trade) * (1 + 1.5 / max(profit_factor, 0.5))
avg_win = (profit_factor * losers * abs(avg_loss)) / max(winners, 1)
metrics = RunMetrics(
run_id=run_id,
net_profit=net_profit,
profit_factor=profit_factor,
calmar_ratio=calmar,
max_drawdown_pct=max_drawdown / 100.0,
max_drawdown_abs=round(net_profit * (max_drawdown / 100.0) * 1.2 + 200, 2),
win_rate=win_rate / 100.0,
total_trades=total_trades,
recovery_factor=round(profit_factor * 1.5, 2),
sharpe_ratio=round(true_score * 1.8, 2),
avg_win=round(avg_win, 2),
avg_loss=round(avg_loss, 2),
largest_loss=round(avg_loss * 3.5, 2),
expected_payoff=round(avg_trade, 2),
)
# Simulate per-run latency so the dashboard feels alive — not instant.
# Each run takes ~1.5s so a 10-iteration AI loop is ~15s total.
delay = float(os.environ.get("APEX_DEMO_RUN_SECONDS", "1.5"))
time.sleep(max(0.05, delay))
# Run AI reasoning if enabled — same flow as live mode
try:
self._reason_about_run(run_id, metrics, [], params)
except Exception:
pass
return ranker.make_result(run_id, params, phase, metrics)
# ── Verdict logic ─────────────────────────────────────────────────────────
def _determine_verdict(
@@ -501,37 +862,173 @@ class OptimizationPipeline:
# ── Helpers ───────────────────────────────────────────────────────────────
def _make_run_dict(self, run_id: str, result: RankedResult, phase: str) -> dict:
"""Canonical run dict used for both _completed_runs and run_complete emits."""
d = {
"run_id": run_id,
"phase": phase,
"ts": datetime.utcnow().isoformat(),
"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,
"score": round(result.score, 4),
"params": result.params,
}
insight = self._run_insights.get(run_id)
if insight:
d["ai_insight"] = insight
return d
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),
d = {
"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,
"win_rate": round(r.win_rate, 1),
"max_drawdown": round(r.max_drawdown, 2),
"total_trades": r.total_trades,
"passing": r.passing,
"params": r.params, # full param dict
"params_summary": self._params_summary(r.params),
}
# Attach AI insight if available
insight = self._run_insights.get(r.run_id)
if insight:
d["ai_insight"] = insight
return d
@staticmethod
def _params_summary(params: dict) -> str:
"""Show a few key params for display."""
keys = ["InpRiskPercent", "InpRRRatio", "InpMaxDailyLossPct",
"InpTrailStartPips", "InpMinScore", "InpSessionStart", "InpSessionEnd"]
"""Show a few key params for display — works with any EA."""
parts = []
for k in keys:
if k in params:
short = k.replace("Inp", "")
parts.append(f"{short}={params[k]}")
for k, v in list(params.items())[:8]:
short = k.replace("Inp", "").replace("inp", "")[:12]
parts.append(f"{short}={v}")
return " | ".join(parts[:4])
def _analyze_run(self, run_id: str, metrics, trades: list, params: dict) -> list:
"""Run Tier 1 analyzers on trade data. Always available from HTML report."""
findings = []
if not trades:
return findings
try:
trades_df = pd.DataFrame([t.model_dump() for t in trades])
except Exception:
try:
trades_df = pd.DataFrame([vars(t) for t in trades])
except Exception:
return findings
if trades_df.empty:
return findings
for analyzer_cls in (EquityCurveAnalyzer, TimePerformanceAnalyzer):
try:
az = analyzer_cls()
result = az.analyze(trades_df)
if isinstance(result, list):
findings.extend(result)
elif result is not None:
findings.append(result)
except Exception as e:
logger.debug(f"[{run_id}] {analyzer_cls.__name__} skipped: {e}")
self._run_findings[run_id] = findings
return findings
def _reason_about_run(
self, run_id: str, metrics, findings: list, params: dict
) -> Optional[dict]:
"""Call AI reasoner and emit insight via SocketIO."""
if not self._ai_reasoner or not self._ai_reasoner.enabled:
return None
try:
history = [
{
"run_id": r.run_id, "score": r.score,
"calmar": r.calmar, "pf": r.profit_factor, "phase": r.phase,
}
for r in (self.phase1_results + self.phase2_results)[-5:]
]
insight = self._ai_reasoner.analyze(
findings=findings,
metrics=metrics,
run_history=history,
current_params=params,
)
d = insight.to_dict()
d["run_id"] = run_id
self._ai_insights.append(d)
self._run_insights[run_id] = d
self._emit("ai_insight", d)
return d
except Exception as e:
logger.warning(f"[{run_id}] AI reasoning failed: {e}")
return None
def get_latest_insight(self) -> Optional[dict]:
"""Return the most recent AI insight."""
return self._ai_insights[-1] if self._ai_insights else None
def get_all_insights(self) -> list[dict]:
return list(self._ai_insights)
def _log(self, level: str, msg: str) -> None:
getattr(logger, level, logger.info)(msg)
self._emit("log", {"level": level, "msg": msg})
def _emit_thinking(
self,
msg: str,
kind: str = "info",
iteration: Optional[int] = None,
meta: Optional[dict] = None,
) -> None:
"""
Emit an AI-thinking stream event — distinct from system logs.
Shows up in the dashboard's "Live AI Thinking Feed".
kind: 'info' | 'reasoning' | 'decision' | 'warning' | 'success' | 'hypothesis'
"""
payload = {
"msg": msg,
"kind": kind,
"iteration": iteration,
"phase": self._phase,
"ts": datetime.utcnow().isoformat(),
}
if meta:
payload["meta"] = meta
self._emit("ai_thinking", payload)
def _emit_early_termination(self, reason_code: str, message: str, details: dict = None) -> None:
"""
Surface an early stop to the user.
reason_code examples:
- 'no_profit' (Phase 1 found nothing)
- 'targets_met' (AI loop hit all quality targets)
- 'budget_exhausted'(time budget used up)
- 'user_stop' (user clicked Stop)
- 'stuck_escape' (optimizer stuck, bailing)
"""
payload = {
"reason": reason_code,
"message": message,
"phase": self._phase,
"ts": datetime.utcnow().isoformat(),
}
if details:
payload["details"] = details
self._emit("early_termination", payload)
def _emit(self, event: str, data: dict = {}) -> None:
try:
self.socketio.emit(event, data)
+23 -2
View File
@@ -5,7 +5,7 @@ Passed from the /setup form → /api/start → pipeline.
"""
from __future__ import annotations
from dataclasses import dataclass, field, asdict
from typing import Literal, Optional
from typing import Literal
ObjectiveType = Literal["balanced", "max_profit", "min_drawdown"]
@@ -42,6 +42,13 @@ class SessionConfig:
# Phase 1 sample count (derived from budget, not user-set directly)
phase1_samples: int = 20
# ── Autonomous AI Loop settings ───────────────────────────────────────────
autonomous_mode: bool = False # Replace Phase 2 with AI-guided loop
autonomous_max_iterations: int = 10 # Max AI-directed iterations
target_profit_factor: float = 1.5 # Stop when PF ≥ this
target_max_drawdown_pct: float = 20.0 # Stop when DD ≤ this %
target_min_calmar: float = 0.5 # Stop when Calmar ≥ this
# ── Derived helpers ───────────────────────────────────────────────────────
def derive_samples(self, seconds_per_run: float = 75.0) -> None:
@@ -66,7 +73,8 @@ class SessionConfig:
@property
def total_budget_runs(self) -> int:
return self.phase1_samples + self.phase2_samples + self.phase3_samples
phase2 = self.autonomous_max_iterations if self.autonomous_mode else self.phase2_samples
return self.phase1_samples + phase2 + self.phase3_samples
# ── Scoring weights based on objective ───────────────────────────────────
@@ -99,6 +107,13 @@ class SessionConfig:
def i(key, default=0):
try: return int(form.get(key, default))
except (ValueError, TypeError): return default
def f(key, default=0.0):
try: return float(form.get(key, default))
except (ValueError, TypeError): return default
def b(key):
v = form.get(key, False)
if isinstance(v, bool): return v
return str(v).lower() in ("true", "1", "yes", "on")
cfg = cls(
ea_name = s("ea_name", "LEGSTECH_EA_V2"),
@@ -111,6 +126,12 @@ class SessionConfig:
objective = s("objective", "balanced"),
budget_minutes = i("budget_minutes", 60),
selected_params = form.get("selected_params", []),
# Autonomous loop
autonomous_mode = b("autonomous_mode"),
autonomous_max_iterations = i("autonomous_max_iterations", 10),
target_profit_factor = f("target_profit_factor", 1.5),
target_max_drawdown_pct = f("target_max_drawdown_pct", 20.0),
target_min_calmar = f("target_min_calmar", 0.5),
)
cfg.derive_samples()
return cfg