Files
Apex_AI_MT5_EA_Optimizer/analysis/ai_reasoner.py
T
LEGSTECH Optimizer 6caafdb794 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
2026-04-25 11:39:17 +00:00

460 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
analysis/ai_reasoner.py
AI Reasoning Layer — uses Claude API to interpret backtest results,
identify patterns across runs, and suggest intelligent parameter changes.
Two modes:
1. analyze() → AIInsight (per-run diagnostic, display-only)
2. suggest_next_params() → AIParamSuggestion (autonomous loop — drives next test)
Usage:
reasoner = AIReasoner(api_key="sk-ant-...")
insight = reasoner.analyze(findings, metrics, run_history)
suggest = reasoner.suggest_next_params(current_params, schema_info, history, targets)
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from typing import Optional
import requests
from loguru import logger
from data.models import Finding, RunMetrics
# ── Output models ─────────────────────────────────────────────────────────────
@dataclass
class AIInsight:
"""Structured AI reasoning output for one optimization run."""
headline: str # One-sentence diagnosis
diagnosis: str # 2-3 sentence explanation of WHY
patterns: list[str] # Key patterns in plain English
suggestions: list[dict] # [{param, from, to, reason}]
confidence: str # "high" | "medium" | "low"
risk_flags: list[str] # Warnings (overfitting risk, data issues etc)
run_id: str = ""
error: Optional[str] = None # Set if API call failed
def to_dict(self) -> dict:
return {
"headline": self.headline,
"diagnosis": self.diagnosis,
"patterns": self.patterns,
"suggestions": self.suggestions,
"confidence": self.confidence,
"risk_flags": self.risk_flags,
"run_id": self.run_id,
"error": self.error,
}
@dataclass
class AIParamSuggestion:
"""
Output of suggest_next_params() — drives the autonomous optimization loop.
Contains concrete parameter values for the next backtest.
"""
analysis: str # What the AI observed and why it's making these changes
changes: list[dict] # [{param, value, reason}] — specific values, not deltas
confidence: float # 0.01.0
goal_status: dict # {profit_factor_met, drawdown_ok, calmar_met}
error: Optional[str] = None
# ── Reasoner ──────────────────────────────────────────────────────────────────
class AIReasoner:
"""
Calls Claude API to reason about backtest results.
Falls back gracefully if API key is missing or call fails.
"""
MODEL = "claude-sonnet-4-6"
API_URL = "https://api.anthropic.com/v1/messages"
TIMEOUT = 30 # seconds
def __init__(self, api_key: Optional[str] = None):
# If the caller passes a placeholder like "${ANTHROPIC_API_KEY}" or an
# empty string, treat it as missing and fall back to the env var.
candidate = (api_key or "").strip()
if not candidate or candidate.startswith("${") or candidate in ("YOUR_API_KEY", "sk-ant-..."):
candidate = os.environ.get("ANTHROPIC_API_KEY", "").strip()
self.api_key = candidate
self.enabled = bool(self.api_key)
if not self.enabled:
logger.warning(
"AIReasoner: No API key found. Set ANTHROPIC_API_KEY in your environment "
"or in config.yaml under ai.anthropic_api_key. AI insights will be skipped."
)
# ── Public ────────────────────────────────────────────────────────────────
def analyze(
self,
findings: list[Finding],
metrics: RunMetrics,
run_history: list[dict], # list of {run_id, score, calmar, pf, params, phase}
current_params: dict = {},
) -> AIInsight:
"""
Main entry point. Returns an AIInsight.
Never raises — always returns something useful.
"""
if not self.enabled:
return self._fallback_insight(findings, metrics)
prompt = self._build_prompt(findings, metrics, run_history, current_params)
try:
raw = self._call_claude(prompt)
insight = self._parse_response(raw, metrics.run_id)
insight.run_id = metrics.run_id
logger.info(f"AIReasoner: insight generated for {metrics.run_id}{insight.confidence} confidence")
return insight
except Exception as e:
logger.error(f"AIReasoner API error: {e}")
fallback = self._fallback_insight(findings, metrics)
fallback.error = str(e)
return fallback
# ── Prompt builder ────────────────────────────────────────────────────────
def _build_prompt(
self,
findings: list[Finding],
metrics: RunMetrics,
run_history: list[dict],
current_params: dict,
) -> str:
# Serialize findings
findings_text = "\n".join([
f"- [{f.severity.upper()}] {f.analyzer}: {f.description} "
f"(confidence={f.confidence:.2f}, est. impact=${f.impact_estimate_pnl:.0f})"
for f in findings[:8] # cap at 8 to stay within context
]) or "No findings generated."
# Serialize run history (last 5 runs)
history_text = ""
if run_history:
history_text = "\n".join([
f"- {r.get('run_id','?')} | score={r.get('score',0):.4f} | "
f"calmar={r.get('calmar',0):.3f} | pf={r.get('pf',0):.3f} | phase={r.get('phase','?')}"
for r in run_history[-5:]
])
else:
history_text = "This is the first run."
# Serialize key current params
key_params = {k: v for k, v in current_params.items()
if any(kw in k.lower() for kw in
["risk", "trail", "sl", "tp", "rr", "session", "atr", "spread", "be"])}
params_text = json.dumps(key_params, indent=2) if key_params else "{}"
return f"""You are an expert algorithmic trading system analyst specializing in MetaTrader 5 Expert Advisors and systematic trading strategy optimization.
You are analyzing the results of an automated EA optimization run. Your job is to:
1. Diagnose WHY the EA is performing the way it is
2. Identify the most important patterns
3. Suggest specific, actionable parameter changes with clear reasoning
## Current Run Metrics
- Run ID: {metrics.run_id}
- Net Profit: ${metrics.net_profit:.2f}
- Profit Factor: {metrics.profit_factor:.3f}
- Calmar Ratio: {metrics.calmar_ratio:.3f}
- Max Drawdown: {metrics.max_drawdown_pct*100:.1f}%
- Total Trades: {metrics.total_trades}
- Win Rate: {metrics.win_rate*100:.1f}%
- Sharpe Ratio: {metrics.sharpe_ratio:.3f}
- Reversal Rate: {getattr(metrics, 'reversal_rate', None) and f"{metrics.reversal_rate*100:.1f}%" or "N/A"}
- Avg MFE Capture: {getattr(metrics, 'avg_mfe_capture', None) and f"{metrics.avg_mfe_capture*100:.1f}%" or "N/A"}
- Composite Score: {metrics.composite_score:.4f}
## Analysis Findings
{findings_text}
## Run History (recent runs)
{history_text}
## Current Key Parameters
{params_text}
## Instructions
Respond ONLY with a valid JSON object. No preamble, no markdown, no backticks.
The JSON must have exactly these keys:
{{
"headline": "One sentence diagnosis (max 15 words)",
"diagnosis": "2-3 sentences explaining WHY the EA is performing this way. Be specific about the root cause.",
"patterns": ["pattern 1 in plain English", "pattern 2", "pattern 3"],
"suggestions": [
{{"param": "InpTrailStartPips", "from": 20, "to": 15, "reason": "Why this change helps"}},
{{"param": "InpRRRatio", "from": 1.5, "to": 2.0, "reason": "Why this change helps"}}
],
"confidence": "high|medium|low",
"risk_flags": ["any overfitting concerns, data issues, or warnings"]
}}
Be direct and technical. The user is an experienced forex trader. Max 2-3 suggestions. Focus on what will actually move the needle."""
# ── API call ──────────────────────────────────────────────────────────────
def _call_claude(self, prompt: str) -> str:
headers = {
"Content-Type": "application/json",
"x-api-key": self.api_key,
"anthropic-version": "2023-06-01",
}
body = {
"model": self.MODEL,
"max_tokens": 1024,
"messages": [{"role": "user", "content": prompt}],
}
resp = requests.post(
self.API_URL,
headers=headers,
json=body,
timeout=self.TIMEOUT,
)
if resp.status_code != 200:
raise RuntimeError(
f"Claude API returned {resp.status_code}: {resp.text[:300]}"
)
data = resp.json()
return data["content"][0]["text"]
# ── Response parser ───────────────────────────────────────────────────────
def _parse_response(self, raw: str, run_id: str) -> AIInsight:
"""Parse Claude's JSON response into an AIInsight."""
# Strip any accidental markdown fences
text = raw.strip()
if text.startswith("```"):
lines = text.split("\n")
text = "\n".join(lines[1:-1]) if lines[-1].strip() == "```" else "\n".join(lines[1:])
data = json.loads(text)
return AIInsight(
headline=data.get("headline", "Analysis complete."),
diagnosis=data.get("diagnosis", ""),
patterns=data.get("patterns", []),
suggestions=data.get("suggestions", []),
confidence=data.get("confidence", "medium"),
risk_flags=data.get("risk_flags", []),
run_id=run_id,
)
# ── Autonomous loop: parameter suggestion ─────────────────────────────────
def suggest_next_params(
self,
current_best_params: dict,
schema_info: list[dict], # [{name, type, min, max, step, current}]
iteration_history: list[dict], # [{iteration, run_id, score, pf, calmar, dd, params_changed}]
targets: dict, # {min_profit_factor, max_drawdown_pct, min_calmar}
) -> AIParamSuggestion:
"""
Ask the AI what parameter set to test next in the autonomous loop.
Returns concrete values for each parameter to change.
Never raises — returns an error suggestion on failure.
"""
if not self.enabled:
return AIParamSuggestion(
analysis="AI not available — no API key configured.",
changes=[],
confidence=0.0,
goal_status={},
error="no_api_key",
)
prompt = self._build_evolution_prompt(
current_best_params, schema_info, iteration_history, targets
)
try:
raw = self._call_claude(prompt)
return self._parse_suggestion(raw)
except Exception as e:
logger.error(f"AIReasoner.suggest_next_params failed: {e}")
return AIParamSuggestion(
analysis=f"AI call failed: {e}",
changes=[],
confidence=0.0,
goal_status={},
error=str(e),
)
def _build_evolution_prompt(
self,
current_best_params: dict,
schema_info: list[dict],
iteration_history: list[dict],
targets: dict,
) -> str:
"""Build the evolution prompt for the autonomous loop."""
# Format parameter schema table
schema_rows = []
for p in schema_info:
current_val = current_best_params.get(p["name"], p.get("default", "?"))
schema_rows.append(
f" {p['name']:<30} | {p['type']:<6} | {p.get('min','?'):>8} {p.get('max','?'):<8} "
f"| step={p.get('step','?'):<6} | CURRENT={current_val}"
)
schema_table = "\n".join(schema_rows) or " (no optimizable parameters)"
# Format iteration history
if iteration_history:
hist_rows = []
for h in iteration_history[-15:]: # last 15 to stay in context
changes_str = ", ".join(
f"{c['param']}={c['value']}" for c in h.get("changes", [])
) or "baseline"
hist_rows.append(
f" iter={h.get('iteration','?'):>3} | score={h.get('score',0):.4f} | "
f"pf={h.get('pf',0):.2f} | calmar={h.get('calmar',0):.2f} | "
f"dd={h.get('dd',0):.1f}% | trades={h.get('trades',0)} | "
f"changes=[{changes_str}]"
)
history_table = "\n".join(hist_rows)
else:
history_table = " (no iterations yet — this is the first AI suggestion)"
# Format targets
target_pf = targets.get("min_profit_factor", 1.5)
target_dd = targets.get("max_drawdown_pct", 20.0)
target_calmar = targets.get("min_calmar", 0.5)
return f"""You are an expert MetaTrader 5 EA optimization engine running in autonomous mode.
Your job is to decide EXACTLY which parameters to change and to what SPECIFIC VALUES for the next backtest.
You must reason from the history of tried configurations and move intelligently toward the targets.
## Optimization Targets (ALL must be met to stop)
- Profit Factor ≥ {target_pf}
- Max Drawdown ≤ {target_dd}%
- Calmar Ratio ≥ {target_calmar}
## Optimizable Parameter Schema
{schema_table}
## Iteration History (most recent 15, newest last)
{history_table}
## Your Task
1. Identify which metrics are furthest from their targets
2. Identify which parameter changes correlate with improvements in those metrics
3. Identify unexplored regions of the parameter space
4. Choose 13 parameters to change and provide SPECIFIC VALUES within valid range
Rules:
- Values MUST be within [min, max] and snap to valid step increments
- Do NOT suggest a combination already tested in the history above
- Make changes that are logically motivated — explain the reasoning
- If previous attempts moved in one direction and improved scores, continue that direction
- If previous attempts got stuck, try a different parameter or a larger change
Respond ONLY with valid JSON (no markdown, no extra text):
{{
"analysis": "2-3 sentences: what pattern you see in the history and WHY you are making these specific changes",
"changes": [
{{"param": "ExactParamName", "value": 1.5, "reason": "one-line reason"}},
{{"param": "AnotherParam", "value": 12, "reason": "one-line reason"}}
],
"confidence": 0.75,
"goal_status": {{
"profit_factor_met": false,
"drawdown_ok": true,
"calmar_met": false
}}
}}"""
def _parse_suggestion(self, raw: str) -> AIParamSuggestion:
"""Parse AI suggestion response into AIParamSuggestion."""
text = raw.strip()
if text.startswith("```"):
lines = text.split("\n")
text = "\n".join(lines[1:-1]) if lines[-1].strip() == "```" else "\n".join(lines[1:])
data = json.loads(text)
# Normalize confidence to float
conf = data.get("confidence", 0.5)
if isinstance(conf, str):
conf = {"high": 0.85, "medium": 0.6, "low": 0.3}.get(conf.lower(), 0.5)
conf = float(max(0.0, min(1.0, conf)))
return AIParamSuggestion(
analysis=data.get("analysis", ""),
changes=data.get("changes", []),
confidence=conf,
goal_status=data.get("goal_status", {}),
)
# ── Fallback (no API key or error) ────────────────────────────────────────
def _fallback_insight(self, findings: list[Finding], metrics: RunMetrics) -> AIInsight:
"""
Rule-based fallback when Claude API is unavailable.
Still useful — surfaces the top finding in plain language.
"""
if not findings:
return AIInsight(
headline="No significant patterns detected in this run.",
diagnosis=(
f"The EA completed {metrics.total_trades} trades with a profit factor of "
f"{metrics.profit_factor:.2f} and {metrics.win_rate*100:.0f}% win rate. "
"No statistically significant failure patterns were identified."
),
patterns=[],
suggestions=[],
confidence="low",
risk_flags=["AI reasoning unavailable — set ANTHROPIC_API_KEY for full analysis"],
)
top = findings[0]
second = findings[1] if len(findings) > 1 else None
patterns = [f.description[:120] for f in findings[:3]]
suggestions = []
for f in findings[:2]:
for param, val in (f.suggested_params or {}).items():
suggestions.append({
"param": param,
"from": "current",
"to": val,
"reason": f"Suggested by {f.analyzer} analyzer (confidence {f.confidence:.2f})"
})
risk_flags = ["AI reasoning running in fallback mode — set ANTHROPIC_API_KEY for full Opus analysis"]
if metrics.total_trades < 100:
risk_flags.append(f"Low trade count ({metrics.total_trades}) — statistical confidence is limited")
if metrics.max_drawdown_pct > 0.25:
risk_flags.append(f"High drawdown ({metrics.max_drawdown_pct*100:.0f}%) — risk parameters need review")
headline = f"{top.severity.upper()} issue: {top.description[:60]}..."
diagnosis = (
f"Primary issue ({top.analyzer}): {top.description} "
f"Estimated impact: ${top.impact_estimate_pnl:.0f}. "
)
if second:
diagnosis += f"Secondary issue ({second.analyzer}): {second.description[:100]}."
return AIInsight(
headline=headline,
diagnosis=diagnosis,
patterns=patterns,
suggestions=suggestions[:3],
confidence="medium" if findings and findings[0].confidence > 0.7 else "low",
risk_flags=risk_flags,
)