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
+27 -3
View File
@@ -24,9 +24,33 @@ 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
# ── Sensitive Config (DO NOT COMMIT — contains API keys) ────────────────────
# Users copy config.example.yaml config.yaml and fill in their key locally.
config.yaml
.env
.env.local
*.secret.yaml
*.secret.yml
config.secret.yaml
# ── EA Registry (machine-specific paths) ─────────────────────────────────────
ea_registry.yaml
# ── Generated set files ──────────────────────────────────────────────────────
*.set
!**/templates/*.set
# ── Screenshots scratch (Playwright debug + health-test artifacts) ──────────
screenshots/health_*.png
screenshots/health_*.json
screenshots/debug*.png
screenshots/playwright_*.png
screenshots/*.py
screenshots/crop_*.png
# ── Claude session artifacts ─────────────────────────────────────────────────
Claude_Code_session*
.claude/sessions/
# ── Logs ──────────────────────────────────────────────────────────────────────
*.log
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 APEX MT5 Optimizer contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+207
View File
@@ -0,0 +1,207 @@
# APEX — AIPowered MT5 EA Optimizer
> **An AI trader thinking out loud while it tests, fails, and improves a strategy.**
APEX is an autonomous optimizer for MetaTrader 5 Expert Advisors. Instead of bruteforcing
parameters with grid search, an LLM reads each backtest result, decides which parameter to
change and why, then runs the next backtest — iterating toward profitfactor / drawdown /
Calmar targets you set. Every reasoning step streams live to a dashboard.
---
## Why this is different
| Traditional optimizers | APEX |
| --- | --- |
| Bruteforce grid / genetic search | AI reads each result, **decides** what to change |
| Black box — see only final winner | Live **thinking feed** + periteration param diffs |
| No notion of *why* a config works | Stores AI analysis next to every run |
| Stops after N iterations | Stops when **quality targets are met** (early exit) |
| Oneshot validation | Outofsample **+ sensitivity** with live progress |
---
## Demo
![Dashboard](screenshots/dashboard.png)
The dashboard shows three live phases — **Exploration → Iteration → Validation** — with the
AI's reasoning streaming on the right, parameter changes per iteration in the centre, and an
outofsample/sensitivity validation panel that updates as MT5 finishes each test.
Other views: [setup wizard](screenshots/setup.png) · [settings modal](screenshots/settings_modal.png)
---
## How it works
```
┌──────────────────────────────────────────────────────────────────────────┐
│ APEX OPTIMIZATION LOOP │
│ │
│ Phase 1: EXPLORATION │
│ LatinHypercube sample N parameter sets → run in MT5 Strategy Tester │
│ → score with Calmar / PF / MFE / sessionstability / recovery │
│ │
│ Phase 2: AI ITERATION (autonomous loop) │
│ ┌──► Claude reads full history + targets + parameter schema │
│ │ ↓ │
│ │ Claude returns: { changes:[{param,value,reason}], confidence } │
│ │ ↓ │
│ │ Apply changes (clamped to schema bounds), dedupe, run backtest │
│ │ ↓ │
│ │ Stream `ai_thinking` + `param_changes` events to UI │
│ │ ↓ │
│ └──── Targets met? → exit early. Stuck? → random escape. │
│ │
│ Phase 3: VALIDATION │
│ Outofsample run on unseen dates + ±20% sensitivity probe on the │
│ top parameter → verdict: RECOMMENDED / RISKY / NOT_RELIABLE │
│ │
│ Output: ranked .set file + perrun report folder + final verdict │
└──────────────────────────────────────────────────────────────────────────┘
```
The AI loop lives in [`optimizer/ai_guided_loop.py`](optimizer/ai_guided_loop.py); the
reasoner contract is in [`analysis/ai_reasoner.py`](analysis/ai_reasoner.py); event emission
to the UI flows through [`optimizer/pipeline.py`](optimizer/pipeline.py) via SocketIO.
---
## Quick start
### 1. Clone + install
```bash
git clone https://github.com/<your-user>/MT5_Optimizer.git
cd MT5_Optimizer
python -m venv .venv
.venv\Scripts\activate # Windows
# source .venv/bin/activate # macOS/Linux
pip install -r requirements.txt
```
### 2. Configure
Copy the example config and fill it in:
```bash
cp config.example.yaml config.yaml
```
Set your Anthropic API key (get one at <https://console.anthropic.com/>):
```bash
# Option A — environment variable (recommended)
setx ANTHROPIC_API_KEY "sk-ant-..." # Windows
export ANTHROPIC_API_KEY="sk-ant-..." # macOS/Linux
# Option B — paste into config.yaml under ai.anthropic_api_key
```
Edit `config.yaml` to match your local MT5 install paths under `mt5:` (terminal exe,
AppData path, MQL5 Files path).
### 3. Launch
```bash
python app.py
```
Open <http://localhost:5000>. Register your EA on the **Setup** page, set thresholds,
hit **Start**, and watch the AI think.
### Demo mode (no MT5 required)
Don't have MT5 installed? Run the offline demo that feeds synthetic backtest results
through the same AI loop and dashboard:
```bash
python -m demo.run_demo
```
This is the path to use if you're a hackathon judge — you'll see the full thinking feed,
parameterchange panel, validation phase, and verdict screen without needing a Windows
machine with MT5.
---
## Configuration cheatsheet
| Key | What it does |
| --- | --- |
| `ai.enabled` | Master toggle for the AI reasoning layer. |
| `ai.model` | `claude-opus-4-7` (best), `claude-sonnet-4-6` (balanced), `claude-haiku-4-5` (fast). |
| `thresholds.min_profit_factor` / `min_calmar` | Quality gates a result must clear. |
| `optimization.max_iterations` | Hard cap on AI loop iterations. |
| `mt5.terminal_exe` | Full path to `terminal64.exe`. |
| `periods.train_*` / `validate_*` / `oos_*` | Train + walkforward validation date ranges. |
The full schema lives in [`config.example.yaml`](config.example.yaml) with comments.
---
## Project layout
```
MT5_Optimizer/
├── app.py Flask + SocketIO server (entry point)
├── config.example.yaml Configuration template
├── analysis/
│ └── ai_reasoner.py Claude API client (analyze + suggest_next_params)
├── optimizer/
│ ├── pipeline.py 3phase pipeline orchestrator
│ ├── ai_guided_loop.py Autonomous AI iteration loop
│ ├── result_ranker.py Scoring & ranking of runs
│ └── session_config.py Perrun config dataclass
├── ea/
│ └── schema.py EA parameter schema + clamp/validation
├── mt5/ MT5 launcher, ini builder, html report parser
├── reports/
│ └── writer.py Perrun HTML/CSV/JSON output
├── ui/
│ ├── templates/ dashboard.html, setup.html, reports_index.html
│ └── static/js/dashboard.js All clientside logic
└── tests/
```
See [`PROJECT_HANDOFF.md`](PROJECT_HANDOFF.md) for a deeper architectural tour.
---
## Live events (SocketIO)
The dashboard subscribes to these — useful if you want to plug a different UI on top:
| Event | When it fires | Payload (key fields) |
| --- | --- | --- |
| `phase_start` | Each phase begins | `phase`, `total`, `mode` |
| `run_complete` | Any backtest finishes | `run_id`, `phase`, `net_profit`, `profit_factor`, `calmar`, `max_drawdown`, `score`, `params` |
| `ai_thinking` | AI narrates a decision | `msg`, `kind` (`info`/`reasoning`/`decision`/`success`/`warning`/`hypothesis`), `iteration`, `phase` |
| `ai_iteration_start` / `ai_iteration_complete` | Each AI loop iteration | `iteration`, `analysis`, `change_records`, `confidence`, `goal_status` |
| `param_changes` | Periteration parameter diff | `iteration`, `changes:[{param, from, to, reason}]`, `confidence` |
| `validation_start` / `validation_run_start` / `validation_run_complete` / `validation_done` | Phase 3 visibility | `kind` (`oos`/`sensitivity`), metrics, `passing` |
| `early_termination` | Pipeline stops before max_iterations | `reason` (`targets_met`/`no_profit`/`budget_exhausted`/`stuck_escape`/`user_stop`), `message`, `details` |
| `optimization_complete` | Run finished | `verdict`, `best_run_id`, `set_file_url`, full metrics |
---
## Contributing
Bug reports + PRs welcome. The codebase is intentionally small enough to read in an hour:
- `optimizer/pipeline.py` orchestrates phases.
- `optimizer/ai_guided_loop.py` is the autonomous loop.
- `ui/static/js/dashboard.js` is one file; no frontend build step.
Run tests with `pytest`. There's no CI yet — fix that and we'll merge it.
---
## License
MIT — see [LICENSE](LICENSE). Use it, fork it, ship it.
Built with [Anthropic Claude](https://claude.com/) for the reasoning layer and
[MetaTrader 5](https://www.metatrader5.com/) for the backtests. APEX is independent of and
not endorsed by either.
+459
View File
@@ -0,0 +1,459 @@
"""
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,
)
+46
View File
@@ -0,0 +1,46 @@
"""
analysis/ai_reasoner_config.py
Loads the Anthropic API key from config.yaml or environment.
Add this to config.yaml:
ai:
anthropic_api_key: "sk-ant-..."
enabled: true
"""
from __future__ import annotations
import os
from pathlib import Path
import yaml
def load_api_key(config_path: str | Path = "config.yaml") -> str:
"""
Load the Anthropic API key. Priority:
1. ANTHROPIC_API_KEY environment variable
2. config.yaml ai.anthropic_api_key
3. Empty string (fallback mode)
"""
env_key = os.environ.get("ANTHROPIC_API_KEY", "")
if env_key:
return env_key
try:
with open(config_path) as f:
cfg = yaml.safe_load(f)
return cfg.get("ai", {}).get("anthropic_api_key", "")
except Exception:
return ""
def is_ai_enabled(config_path: str | Path = "config.yaml") -> bool:
"""Check if AI reasoning is enabled in config."""
env_key = os.environ.get("ANTHROPIC_API_KEY", "")
if env_key:
return True
try:
with open(config_path) as f:
cfg = yaml.safe_load(f)
ai_cfg = cfg.get("ai", {})
return ai_cfg.get("enabled", False) and bool(ai_cfg.get("anthropic_api_key", ""))
except Exception:
return False
+332 -8
View File
@@ -124,17 +124,28 @@ def stop():
@app.route("/api/history")
def history():
"""Score history for chart — built from pipeline results."""
"""Full run history for chart/table restoration — includes in-progress runs."""
if pipeline is None:
return jsonify([])
if hasattr(pipeline, '_completed_runs') and pipeline._completed_runs:
# Sort newest first by timestamp (ts field added in _make_run_dict)
runs = sorted(pipeline._completed_runs,
key=lambda r: r.get("ts", ""), reverse=True)
return jsonify(runs)
# Fallback: post-phase ranked results
results = pipeline.phase1_results + pipeline.phase2_results
return jsonify([
{
"run_id": r.run_id,
"score": round(r.score, 4),
"calmar": round(r.calmar, 3),
"passing": r.passing,
"phase": r.phase,
"run_id": r.run_id,
"score": round(r.score, 4),
"net_profit": round(r.net_profit, 2),
"calmar": round(r.calmar, 3),
"profit_factor": round(r.profit_factor, 3),
"max_drawdown": round(r.max_drawdown, 2),
"total_trades": r.total_trades,
"win_rate": round(r.win_rate, 1),
"passing": r.passing,
"phase": r.phase,
}
for r in results
])
@@ -203,7 +214,7 @@ def runs_list():
import json, re
runs = []
if REPORTS_DIR.exists():
for run_dir in sorted(REPORTS_DIR.iterdir(), reverse=True):
for run_dir in REPORTS_DIR.iterdir():
if not run_dir.is_dir():
continue
summary = run_dir / "summary.json"
@@ -218,9 +229,108 @@ def runs_list():
runs.append(data)
except Exception:
pass
# Sort by timestamp field (newest first)
runs.sort(key=lambda r: r.get("ts", ""), reverse=True)
return jsonify(runs[:50])
@app.route("/api/run/<run_id>")
def run_detail(run_id):
"""Return full detail for one run: metrics + params + AI insight + .set link."""
import json, re
run_dir = REPORTS_DIR / run_id
if not run_dir.exists():
return jsonify({"error": "Run not found"}), 404
def read_json(path):
try:
txt = path.read_text(encoding="utf-8")
txt = re.sub(r'\bNaN\b', 'null', txt)
txt = re.sub(r'\bInfinity\b', 'null', txt)
return json.loads(txt)
except Exception:
return None
summary = read_json(run_dir / "summary.json") or {}
params = read_json(run_dir / "parameters.json") or {}
ai_insight = read_json(run_dir / "ai_insight.json")
# Also check live pipeline for AI insight (current session, not yet on disk)
if ai_insight is None and pipeline and hasattr(pipeline, '_run_insights'):
ai_insight = pipeline._run_insights.get(run_id)
# Detect .set file
set_files = list(run_dir.glob("*.set"))
set_url = f"/download_set/{run_id}" if set_files else None
return jsonify({
**summary,
"params": params,
"ai_insight": ai_insight,
"set_url": set_url,
"has_set": bool(set_files),
})
@app.route("/api/best_result")
def best_result():
"""
Return the current best run plus its evolution path — the ordered sequence
of AI iterations that led to it (so the user can see how the AI arrived).
"""
if not pipeline:
return jsonify({"error": "No best result yet — optimization has not started."}), 404
best_run = None
if getattr(pipeline, "final_result", None):
best_run = pipeline.final_result
elif getattr(pipeline, "_live_best", None):
best_run = pipeline._live_best
if best_run is None:
return jsonify({"error": "No best result yet — no passing run found so far."}), 404
# Build evolution path: walk through _completed_runs up to (and including) the best
evolution = []
for r in getattr(pipeline, "_completed_runs", []):
phase = r.get("phase", "")
if not (phase.startswith("phase1") or phase.startswith("phase2")):
continue
ai_insight = r.get("ai_insight") or {}
evolution.append({
"run_id": r.get("run_id"),
"phase": phase,
"ts": r.get("ts"),
"score": r.get("score"),
"net_profit": r.get("net_profit"),
"profit_factor": r.get("profit_factor"),
"calmar": r.get("calmar"),
"max_drawdown": r.get("max_drawdown"),
"passing": r.get("passing"),
"changes": ai_insight.get("changes") or [],
"analysis": ai_insight.get("analysis") or ai_insight.get("diagnosis") or "",
"is_best": r.get("run_id") == best_run.run_id,
})
if r.get("run_id") == best_run.run_id:
break
return jsonify({
"run_id": best_run.run_id,
"score": round(best_run.score, 4),
"net_profit": round(best_run.net_profit, 2),
"profit_factor": round(best_run.profit_factor, 3),
"calmar": round(best_run.calmar, 3),
"max_drawdown": round(best_run.max_drawdown, 2),
"win_rate": round(best_run.win_rate, 1),
"total_trades": best_run.total_trades,
"passing": best_run.passing,
"phase": getattr(best_run, "phase", "phase2_ai"),
"params": best_run.params,
"evolution": evolution,
"set_url": f"/download_set/{best_run.run_id}",
})
# ── SocketIO ──────────────────────────────────────────────────────────────────
@socketio.on("connect")
@@ -229,6 +339,220 @@ def on_connect():
emit("status_sync", pipeline.get_status())
@app.route("/api/ai_insight/latest")
def ai_insight_latest():
"""Return the latest AI insight from the running pipeline."""
if pipeline and hasattr(pipeline, 'get_latest_insight'):
insight = pipeline.get_latest_insight()
if insight:
return jsonify(insight)
return jsonify(None)
@app.route("/api/ai_insights")
def ai_insights_all():
"""Return all AI insights from this session."""
if pipeline and hasattr(pipeline, 'get_all_insights'):
return jsonify(pipeline.get_all_insights())
return jsonify([])
@app.route("/api/settings", methods=["GET"])
def get_settings():
import yaml
config_path = BASE_DIR / "config.yaml"
try:
with open(config_path, encoding="utf-8") as f:
cfg = yaml.safe_load(f) or {}
ai_cfg = cfg.get("ai", {})
mt5_cfg = cfg.get("mt5", {})
broker_cfg = cfg.get("broker", {})
thresh_cfg = cfg.get("thresholds", {})
return jsonify({
"ai": {
"enabled": ai_cfg.get("enabled", True),
"anthropic_api_key": ai_cfg.get("anthropic_api_key", ""),
"model": ai_cfg.get("model", "claude-opus-4-7"),
"timeout_seconds": ai_cfg.get("timeout_seconds", 30),
},
"mt5": {
"terminal_exe": mt5_cfg.get("terminal_exe", ""),
"appdata_path": mt5_cfg.get("appdata_path", ""),
"mql5_files_path": mt5_cfg.get("mql5_files_path", ""),
"tester_timeout_seconds": mt5_cfg.get("tester_timeout_seconds", 120),
"tester_model": mt5_cfg.get("tester_model", 1),
},
"broker": {
"timezone_offset_hours": broker_cfg.get("timezone_offset_hours", 3),
"deposit": broker_cfg.get("deposit", 10000),
"leverage": broker_cfg.get("leverage", 500),
},
"thresholds": {
"min_trades": thresh_cfg.get("min_trades", 30),
"min_profit_factor": thresh_cfg.get("min_profit_factor", 1.2),
"min_calmar": thresh_cfg.get("min_calmar", 0.5),
"max_oos_degradation": thresh_cfg.get("max_oos_degradation", 0.3),
"sensitivity_tolerance": thresh_cfg.get("sensitivity_tolerance", 0.15),
},
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/settings", methods=["POST"])
def save_settings():
import yaml
config_path = BASE_DIR / "config.yaml"
data = request.get_json(silent=True) or {}
try:
with open(config_path, encoding="utf-8") as f:
cfg = yaml.safe_load(f) or {}
for section in ["ai", "mt5", "broker", "thresholds"]:
if section in data and isinstance(data[section], dict):
if section not in cfg:
cfg[section] = {}
cfg[section].update(data[section])
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(cfg, f, default_flow_style=False, allow_unicode=True)
return jsonify({"ok": True})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.route("/api/ea/register", methods=["POST"])
def ea_register():
"""Register a new EA profile."""
from ea.registry import EARegistry, EAProfile
data = request.get_json(silent=True) or {}
try:
reg = EARegistry(str(BASE_DIR / "config.yaml"))
profile = EAProfile(
name=data["name"],
ex5_file=data.get("ex5_file", data["name"]),
set_template=data["set_template"],
symbol=data.get("symbol", "XAUUSD"),
timeframe=data.get("timeframe", "H1"),
mode=data.get("mode", "generic"),
)
reg.register(profile)
return jsonify({"ok": True, "name": profile.name})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 400
@app.route("/api/ea/list")
def ea_list():
"""List all registered EAs."""
from ea.registry import EARegistry
try:
reg = EARegistry(str(BASE_DIR / "config.yaml"))
profiles = reg.list_all()
return jsonify([{
"name": p.name,
"symbol": p.symbol,
"timeframe": p.timeframe,
"mode": p.mode,
"set_template": p.set_template,
} for p in profiles])
except Exception as e:
return jsonify([])
@app.route("/ai_insights")
def ai_insights_page():
"""Legacy alias — AI insights now live inline on the dashboard."""
return redirect("/dashboard")
@app.route("/api/ea/scan")
def ea_scan():
"""Scan common MT5 locations for .ex5 and .set files."""
import glob as _glob
import os
home = Path(os.path.expanduser("~"))
appdata = Path(os.environ.get("APPDATA", home / "AppData" / "Roaming"))
desktop = home / "Desktop"
# Directories to scan for .ex5 files — MetaQuotes terminal data dirs
ex5_dirs = []
mq_base = appdata / "MetaQuotes" / "Terminal"
if mq_base.exists():
for td in mq_base.iterdir():
if td.is_dir():
ex5_dirs.append(td / "MQL5" / "Experts")
ex5_dirs.append(Path("C:/Program Files/MetaTrader 5/MQL5/Experts"))
ex5_dirs.append(Path("C:/Program Files (x86)/MetaTrader 5/MQL5/Experts"))
# Scan .ex5 files (skip Examples / Advisors / Free Robots subfolders — likely default)
SKIP_DIRS = {"Examples", "Advisors", "Free Robots", "Market"}
ex5_found = []
seen_names = set()
for d in ex5_dirs:
if not d.exists():
continue
for f in d.rglob("*.ex5"):
if any(part in SKIP_DIRS for part in f.parts):
continue
name = f.stem
if name not in seen_names:
seen_names.add(name)
ex5_found.append({
"name": name,
"path": str(f).replace("\\", "/"),
"dir": str(f.parent).replace("\\", "/"),
})
# Scan .set files — Desktop, Desktop subfolders, MT5 tester agents
set_dirs = [desktop]
# Desktop subfolders (1 level deep)
for item in desktop.iterdir() if desktop.exists() else []:
if item.is_dir():
set_dirs.append(item)
# MT5 tester agent MQL5/Files
tester_base = appdata / "MetaQuotes" / "Tester"
if tester_base.exists():
for td in tester_base.rglob("MQL5/Files"):
set_dirs.append(td)
set_found = []
seen_set = set()
for d in set_dirs:
if not d.exists():
continue
for f in d.glob("*.set"):
key = f.name
if key not in seen_set:
seen_set.add(key)
set_found.append({
"name": f.stem,
"filename": f.name,
"path": str(f).replace("\\", "/"),
})
# Build best-match hints: for each ex5, find the most likely .set file
def best_set_for(ea_name):
ea_lower = ea_name.lower()
# exact match first
for s in set_found:
if s["name"].lower() == ea_lower:
return s["path"]
# prefix/suffix match
for s in set_found:
sl = s["name"].lower()
if ea_lower in sl or sl in ea_lower:
return s["path"]
return ""
for ea in ex5_found:
ea["suggested_set"] = best_set_for(ea["name"])
return jsonify({
"ex5": ex5_found,
"set": set_found,
})
# ── Launch ────────────────────────────────────────────────────────────────────
def open_browser():
@@ -242,4 +566,4 @@ if __name__ == "__main__":
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)
socketio.run(app, host="0.0.0.0", port=5000, debug=False, use_reloader=False, allow_unsafe_werkzeug=True)
+122
View File
@@ -0,0 +1,122 @@
# ── APEX MT5 Optimizer — example config ─────────────────────────────────────
# Copy this file to `config.yaml` and fill in your values. config.yaml is
# git-ignored so your secrets stay local.
#
# cp config.example.yaml config.yaml
#
# Or set ANTHROPIC_API_KEY as an environment variable and the app will pick it
# up automatically (overrides whatever is in config.yaml).
ai:
enabled: true
# Get your key from https://console.anthropic.com/
# Leave as-is to use the ANTHROPIC_API_KEY environment variable.
anthropic_api_key: ${ANTHROPIC_API_KEY}
model: claude-opus-4-7 # claude-opus-4-7 | claude-sonnet-4-6 | claude-haiku-4-5
timeout_seconds: 45
analyze_every_n_iterations: 1
# ── Strategy thresholds — quality gates a result must clear ────────────────
thresholds:
min_trades: 50
min_profit_factor: 1.2
min_calmar: 0.35
min_wfv_ratio: 0.7
max_oos_degradation: 0.3
sensitivity_tolerance: 0.3
# ── Scoring weights — how the ranker combines metrics ──────────────────────
scoring:
significance_trades: 150
weights:
calmar: 0.35
profit_factor: 0.20
mfe_capture: 0.20
session_stability: 0.15
recovery_factor: 0.10
normalization:
calmar: { lo: 0.0, hi: 4.0 }
profit_factor: { lo: 1.0, hi: 3.5 }
recovery_factor: { lo: 0.0, hi: 6.0 }
mfe_capture: { lo: 0.0, hi: 1.0 }
session_stability: { lo: 0.0, hi: 1.0 }
# ── Broker / market context ────────────────────────────────────────────────
broker:
currency: USD
deposit: 10000
leverage: 100
timezone_offset_hours: 2
sessions:
Asian: { start: 0, end: 9 }
London: { start: 9, end: 18 }
LondonNY: { start: 15, end: 18 }
NY: { start: 15, end: 24 }
# ── EA selection (auto-populated when you register an EA in /setup) ────────
ea:
name: LEGSTECH_EA_V2
file: LEGSTECH_EA_V2
symbol: XAUUSD
timeframe: H1
# ── MetaTrader 5 paths ─────────────────────────────────────────────────────
# Adjust these to match your local MT5 installation.
mt5:
terminal_exe: C:/Program Files/MetaTrader 5/terminal64.exe
appdata_path: C:/Users/<YOU>/AppData/Roaming/MetaQuotes/Terminal/<HASH>
mql5_files_path: C:/Users/<YOU>/AppData/Roaming/MetaQuotes/Tester/<HASH>/Agent-127.0.0.1-3000/MQL5/Files
report_subdir: runs
tester_model: 4 # 1=Every tick, 2=Real ticks, 3=OHLC, 4=Open prices
tester_timeout_seconds: 1800
data_readiness_wait_seconds: 10
kill_on_start: true
shutdown_terminal: 1
# ── Backtest periods ───────────────────────────────────────────────────────
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
oos_end: 2024.12.31
# ── Optimization tuning ────────────────────────────────────────────────────
optimization:
max_iterations: 50
convergence_threshold: 0.04
convergence_window: 3
mutation:
max_hypotheses_per_cycle: 3
dedup_lookback_runs: 10
explore_fallback_after: 5
analysis:
equity_curve:
min_r_squared: 0.7
max_flatness_score: 0.5
reversal:
mfe_threshold_pips: 15.0
min_reversal_rate: 0.15
permutation_n: 500
time_performance:
min_trades_per_bucket: 10
permutation_n: 1000
z_score_threshold: -1.5
entry_exit:
poor_entry_quality: 0.4
poor_exit_quality: 0.55
# ── Paths ──────────────────────────────────────────────────────────────────
paths:
db: optimizer.db
reports_dir: reports
runs_dir: runs
ea_registry: ea_registry.yaml
log_file: optimizer.log
logging:
level: INFO
file: optimizer.log
-104
View File
@@ -1,104 +0,0 @@
# MT5 EA Strategy Optimizer — Master Configuration
# EA identity is managed in ea_registry.yaml (source of truth for new code).
# The ea: block below is kept as a bridge for legacy code paths.
# ─────────────────────────────────────────────
ea:
name: "LEGSTECH_EA_V2"
file: "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 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"
ea_registry: "ea_registry.yaml" # EA profiles (symbol, .set path, mode)
View File
+142
View File
@@ -0,0 +1,142 @@
"""
demo/run_demo.py
APEX offline demo runner.
Spins up the Flask + SocketIO app with APEX_DEMO_MODE=1 so the optimizer
generates synthetic backtest results instead of calling MT5. Lets judges
without a Windows + MT5 install see the full live AI loop, validation,
and verdict flow.
Usage:
python -m demo.run_demo
# or
python demo/run_demo.py
"""
from __future__ import annotations
import os
import sys
import textwrap
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parent.parent
DEMO_DIR = Path(__file__).resolve().parent
DEMO_SET = DEMO_DIR / "demo_ea.set"
REGISTRY = ROOT / "ea_registry.yaml"
CONFIG = ROOT / "config.yaml"
EXAMPLE_CONFIG = ROOT / "config.example.yaml"
DEMO_PROFILE = {
"name": "APEX_DEMO_EA",
"ex5_file": "APEX_DEMO_EA",
"set_template": str(DEMO_SET).replace("\\", "/"),
"symbol": "XAUUSD",
"timeframe": "H1",
"mode": "advanced",
"registered_at": "2026-01-01T00:00:00+00:00",
"optimize_params": {
"InpRiskPercent": True,
"InpMaxDailyLossPct": True,
"InpRRRatio": True,
"InpStopLossPips": True,
"InpTakeProfitPips": True,
"InpATRMultiplier": True,
"InpUseTrailing": True,
"InpTrailStartPips": True,
"InpUseBreakeven": True,
"InpBEPips": True,
"InpMinScore": True,
},
"automation_overrides": {},
}
def ensure_demo_registry() -> None:
"""Make sure the demo EA profile exists in ea_registry.yaml."""
if REGISTRY.exists():
try:
data = yaml.safe_load(REGISTRY.read_text()) or {"profiles": []}
except Exception:
data = {"profiles": []}
else:
data = {"profiles": []}
profiles = data.get("profiles") or []
if not any(p.get("name") == DEMO_PROFILE["name"] for p in profiles):
profiles.append(DEMO_PROFILE)
data["profiles"] = profiles
REGISTRY.write_text(yaml.safe_dump(data, sort_keys=False))
print(f" [ok] Registered demo EA in {REGISTRY.name}")
else:
print(f" [ok] Demo EA already in {REGISTRY.name}")
def ensure_config() -> None:
"""If config.yaml is missing, copy config.example.yaml as a starting point."""
if not CONFIG.exists():
if EXAMPLE_CONFIG.exists():
CONFIG.write_text(EXAMPLE_CONFIG.read_text())
print(f" [ok] Created {CONFIG.name} from template")
else:
print(f" [!] No config.yaml or config.example.yaml — app may fail to start")
def banner() -> None:
bar = "=" * 72
print(textwrap.dedent(f"""
{bar}
APEX -- DEMO MODE (offline / no MT5)
{bar}
* Backtests are synthetic (deterministic from params + jitter)
* The AI loop, validation, and verdict flow are 100% real
* Set ANTHROPIC_API_KEY to see live AI reasoning
Open http://localhost:5000 in your browser, hit "New Run", and
watch the AI think.
{bar}
"""))
def main() -> int:
banner()
print("Bootstrapping demo environment...")
ensure_config()
ensure_demo_registry()
# Set the demo flag — the pipeline checks this in _execute_run.
os.environ["APEX_DEMO_MODE"] = "1"
# Per-run latency tunable — keep small so demo feels snappy.
os.environ.setdefault("APEX_DEMO_RUN_SECONDS", "1.2")
if "ANTHROPIC_API_KEY" not in os.environ:
print(" [!] ANTHROPIC_API_KEY not set — AI reasoning will be skipped (synthetic metrics still flow).")
print()
print("Launching APEX server at http://localhost:5000 ...")
sys.path.insert(0, str(ROOT))
# Import after env vars are set so the pipeline picks them up.
import threading
import webbrowser
from app import app as flask_app, socketio
def _open_browser():
import time as _t
_t.sleep(1.5)
try:
webbrowser.open("http://localhost:5000")
except Exception:
pass
threading.Thread(target=_open_browser, daemon=True).start()
socketio.run(
flask_app, host="0.0.0.0", port=5000,
debug=False, use_reloader=False, allow_unsafe_werkzeug=True,
)
return 0
if __name__ == "__main__":
sys.exit(main())
-35
View File
@@ -1,35 +0,0 @@
profiles:
- name: LEGSTECH_EA_V2
ex5_file: LEGSTECH_EA_V2
set_template: C:/Users/DELL/Desktop/MT5 Set files/LEGSTECH_EA_V2.set
symbol: XAUUSD
timeframe: H1
mode: advanced
registered_at: '2026-04-13T19:52:30.824439+00:00'
optimize_params:
InpRiskPercent: true
InpMaxDailyLossPct: true
InpMaxTradesPerDay: true
InpRRRatio: true
InpUseTrailing: true
InpTrailStartPips: true
InpTrailStepPips: true
InpUseBreakeven: true
InpBEPips: true
InpBEBufferPips: true
InpUseSession: true
InpSessionStart: true
InpSessionEnd: true
InpMinScore: true
InpATRMultiplier: true
InpBotMode: false
InpRiskType: false
InpSLType: false
InpSLBuffer: false
InpFixedSLPips: false
InpUseSpreadGuard: true
InpMaxSpreadPips: true
automation_overrides:
InpShowPanel: 0
InpTesterMode: 1
InpTesterInitDeposit: 10000.0
+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
+48
View File
@@ -30,6 +30,8 @@ from scoring.composite import CompositeScorer
from mutation.engine import MutationEngine
from validation.gate import ValidationGate
from reports.writer import ReportWriter
from analysis.ai_reasoner import AIReasoner
from analysis.ai_reasoner_config import load_api_key
import pandas as pd
@@ -70,6 +72,11 @@ class OptimizerLoop:
self.run_start_ts: Optional[float] = None
self.session_tested_deltas: list[dict] = [] # dedup within this session only
# AI Reasoning Layer
api_key = load_api_key(config_path)
self.ai_reasoner = AIReasoner(api_key=api_key)
self._run_history: list[dict] = [] # accumulates across iterations for AI context
with open(config_path) as f:
self.cfg = yaml.safe_load(f)
@@ -143,6 +150,26 @@ class OptimizerLoop:
# Write baseline report
findings = self._run_analysis(baseline_id, baseline_trades, baseline_metrics,
analyzers, store)
# AI Reasoning — baseline
self._emit("log", {"level": "info", "msg": "🤖 AI Reasoner analyzing baseline..."})
ai_insight = self.ai_reasoner.analyze(
findings=findings,
metrics=baseline_metrics,
run_history=self._run_history,
current_params=default_params,
)
self._run_history.append({
"run_id": baseline_id,
"score": round(baseline_metrics.composite_score, 4),
"calmar": round(baseline_metrics.calmar_ratio, 4),
"pf": round(baseline_metrics.profit_factor, 4),
"phase": "baseline",
"params": default_params,
})
self._emit("ai_insight", ai_insight.to_dict())
self._emit("log", {"level": "info", "msg": f"🤖 AI: {ai_insight.headline}"})
writer.write(baseline_id, baseline_metrics, baseline_trades, findings, default_params)
self.best_score = baseline_metrics.composite_score
@@ -184,6 +211,17 @@ class OptimizerLoop:
self._emit("log", {"level": "warn", "msg": "No actionable findings. Stopping."})
break
# AI Reasoning — per iteration
self._emit("log", {"level": "info", "msg": f"🤖 AI Reasoner analyzing iteration {self.iteration}..."})
ai_insight = self.ai_reasoner.analyze(
findings=findings,
metrics=current_metrics,
run_history=self._run_history,
current_params=current_params,
)
self._emit("ai_insight", ai_insight.to_dict())
self._emit("log", {"level": "info", "msg": f"🤖 AI: {ai_insight.headline}"})
# Mutation proposals — only dedup within this session
hypotheses = mutator.propose(
findings=findings,
@@ -345,6 +383,16 @@ class OptimizerLoop:
else:
no_improve_count += 1
# Track run history for AI context
self._run_history.append({
"run_id": iteration_best.run_id,
"score": round(iteration_best.composite_score, 4),
"calmar": round(iteration_best.calmar_ratio, 4),
"pf": round(iteration_best.profit_factor, 4),
"phase": "explore",
"params": iteration_best_params,
})
# Update score chart
self.score_history.append({
"iteration": self.iteration,
+35 -17
View File
@@ -1,17 +1,35 @@
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
# ── Core data + numerics ─────────────────────────────────────────────────
pandas>=2.1,<3.0
numpy>=1.26,<2.0
pyarrow>=14.0,<19.0
scipy>=1.11,<2.0
# ── Validation + serialization ───────────────────────────────────────────
pydantic>=2.5,<3.0
pyyaml>=6.0,<7.0
lxml>=4.9,<6.0
beautifulsoup4>=4.12,<5.0
# ── Web app + realtime ───────────────────────────────────────────────────
flask>=3.0,<4.0
flask-socketio>=5.3,<6.0
jinja2>=3.1,<4.0
# ── Storage ──────────────────────────────────────────────────────────────
sqlalchemy>=2.0,<3.0
# ── Logging + CLI polish ─────────────────────────────────────────────────
loguru>=0.7,<1.0
rich>=13.0,<14.0
# ── HTTP client (used by AI reasoner) ────────────────────────────────────
requests>=2.31,<3.0
# ── Process control (MT5 process supervision) ────────────────────────────
psutil>=5.9,<6.0
# ── Visualisation (optional plotting) ────────────────────────────────────
plotly>=5.18,<6.0
# ── Test ─────────────────────────────────────────────────────────────────
pytest>=7.4,<9.0
Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

+107
View File
@@ -324,3 +324,110 @@ body {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── AI Insight Panel ─────────────────────────────────────────────────────── */
.ai-insight-card {
border: 1px solid rgba(0, 212, 170, 0.25);
background: linear-gradient(135deg, rgba(0,212,170,0.04) 0%, rgba(124,109,250,0.04) 100%);
animation: slideIn 0.4s ease both;
}
.ai-confidence-badge {
font-size: 0.72rem;
font-weight: 600;
padding: 0.2rem 0.7rem;
border-radius: 20px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.ai-confidence-badge.high { background: rgba(0,212,170,0.15); color: #00d4aa; }
.ai-confidence-badge.medium { background: rgba(251,191,36,0.15); color: #fbbf24; }
.ai-confidence-badge.low { background: rgba(100,116,139,0.15); color: #94a3b8; }
.ai-confidence-badge.analyzing { background: rgba(124,109,250,0.15); color: #7c6dfa; }
.ai-headline {
font-size: 1.05rem;
font-weight: 700;
color: #e2e8f0;
margin: 0.75rem 0 0.5rem;
line-height: 1.4;
}
.ai-diagnosis {
font-size: 0.875rem;
color: #94a3b8;
line-height: 1.7;
margin-bottom: 1rem;
}
.ai-sections {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.ai-section {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 10px;
padding: 0.85rem 1rem;
}
.ai-section-label {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #64748b;
margin-bottom: 0.6rem;
}
.ai-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.ai-list li {
font-size: 0.83rem;
color: #cbd5e1;
padding-left: 1.1rem;
position: relative;
line-height: 1.5;
}
.ai-list li::before {
content: "→";
position: absolute;
left: 0;
color: #00d4aa;
font-size: 0.75rem;
}
.ai-list-warn li::before { color: #fbbf24; content: "⚠"; }
.ai-suggestions {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.ai-suggestion-row {
display: grid;
grid-template-columns: 180px 80px 80px 1fr;
align-items: center;
gap: 0.75rem;
font-size: 0.82rem;
padding: 0.4rem 0.5rem;
border-radius: 6px;
background: rgba(255,255,255,0.02);
}
.ai-param-name {
font-family: 'JetBrains Mono', monospace;
font-size: 0.78rem;
color: #7c6dfa;
}
.ai-param-from { color: #64748b; text-align: center; }
.ai-param-to {
color: #00d4aa;
font-weight: 600;
text-align: center;
}
.ai-param-reason {
color: #94a3b8;
font-size: 0.78rem;
line-height: 1.4;
}
+1694 -407
View File
File diff suppressed because it is too large Load Diff
+2328 -241
View File
File diff suppressed because it is too large Load Diff
+425 -236
View File
@@ -1,267 +1,456 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MT5 Smart Optimizer</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;900&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>APEX — AI-Powered EA Optimizer</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
:root {
--bg: #080d1a;
--accent: #4f46e5;
--accent2: #7c6dfa;
--teal: #00d4aa;
--text: #e2e8f0;
--muted: #64748b;
}
:root {
--bg: #07090f;
--bg2: #0d1117;
--bg3: #141b27;
--border: rgba(255,255,255,0.07);
--accent: #00d4aa;
--accent2: #7c6dfa;
--warn: #f59e0b;
--text: #e2e8f0;
--muted: #64748b;
--glow: 0 0 40px rgba(0,212,170,0.15);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font-family: 'Inter', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
overflow-x: hidden;
}
body {
font-family: 'Inter', sans-serif;
background: var(--bg);
color: var(--text);
display: grid;
place-items: center;
min-height: 100vh;
overflow: hidden;
}
/* Grid overlay */
body::before {
content: '';
position: fixed; inset: 0;
background-image:
linear-gradient(rgba(0,212,170,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,212,170,0.03) 1px, transparent 1px);
background-size: 50px 50px;
pointer-events: none;
z-index: 0;
}
/* Animated background grid */
body::before {
content: '';
position: fixed; inset: 0;
background-image:
linear-gradient(rgba(0,212,170,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,212,170,0.03) 1px, transparent 1px);
background-size: 60px 60px;
animation: gridScroll 20s linear infinite;
pointer-events: none;
z-index: 0;
}
@keyframes gridScroll { to { background-position: 60px 60px; } }
/* Glow orb */
body::after {
content: '';
position: fixed;
top: 20%;
left: 10%;
width: 600px;
height: 600px;
background: radial-gradient(circle, rgba(79,70,229,0.08) 0%, transparent 70%);
pointer-events: none;
z-index: 0;
}
/* Glowing orbs */
.orb {
position: fixed;
border-radius: 50%;
filter: blur(120px);
opacity: 0.12;
pointer-events: none;
z-index: 0;
animation: orb-float 8s ease-in-out infinite;
}
.orb-1 { width: 600px; height: 600px; background: var(--accent); top: -200px; left: -100px; animation-delay: 0s; }
.orb-2 { width: 500px; height: 500px; background: var(--accent2); bottom: -200px; right: -100px; animation-delay: 4s; }
@keyframes orb-float { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-30px)} }
.page {
position: relative;
z-index: 1;
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.landing {
position: relative;
z-index: 1;
text-align: center;
max-width: 700px;
padding: 2rem;
animation: fadeUp 0.8s ease both;
}
@keyframes fadeUp { from{opacity:0;transform:translateY(30px)} to{opacity:1;transform:translateY(0)} }
/* Navbar */
.navbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.5rem 0;
margin-bottom: 4rem;
}
.logo {
display: inline-flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 2.5rem;
}
.logo-icon {
width: 52px; height: 52px;
background: linear-gradient(135deg, var(--accent), var(--accent2));
border-radius: 14px;
display: grid; place-items: center;
font-size: 1.6rem;
box-shadow: var(--glow);
}
.logo-name {
font-size: 1.4rem;
font-weight: 700;
letter-spacing: -0.02em;
color: white;
}
.logo-sub { font-size: 0.75rem; color: var(--muted); margin-top: 2px; }
.nav-logo {
display: flex;
align-items: center;
gap: 0.75rem;
}
h1 {
font-size: clamp(2.2rem, 5vw, 3.5rem);
font-weight: 900;
letter-spacing: -0.04em;
line-height: 1.1;
margin-bottom: 1.25rem;
background: linear-gradient(135deg, #ffffff 0%, var(--accent) 60%, var(--accent2) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.nav-logo-text {
font-size: 1.25rem;
font-weight: 800;
letter-spacing: 0.05em;
background: linear-gradient(135deg, #fff 0%, #94a3b8 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle {
font-size: 1.1rem;
color: var(--muted);
line-height: 1.7;
margin-bottom: 3rem;
font-weight: 400;
}
.status-pill {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 1rem;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 999px;
font-size: 0.8rem;
color: var(--muted);
}
.actions {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.status-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--muted);
flex-shrink: 0;
}
.status-dot.running {
background: var(--teal);
box-shadow: 0 0 8px var(--teal);
animation: pulse 1.5s infinite;
}
.status-dot.idle { background: #475569; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }
.btn {
display: inline-flex;
align-items: center;
gap: 0.6rem;
padding: 0.9rem 2rem;
border-radius: 12px;
font-size: 1rem;
font-weight: 600;
text-decoration: none;
transition: all 0.2s ease;
cursor: pointer;
border: none;
font-family: inherit;
}
/* Hero */
.hero {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4rem;
align-items: center;
min-height: 70vh;
}
.btn-primary {
background: linear-gradient(135deg, var(--accent), #00b890);
color: #051a14;
box-shadow: 0 4px 24px rgba(0,212,170,0.35);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 36px rgba(0,212,170,0.5);
}
.hero-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.9rem;
background: rgba(79,70,229,0.15);
border: 1px solid rgba(79,70,229,0.3);
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
color: var(--accent2);
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 1.5rem;
}
.btn-secondary {
background: var(--bg3);
color: var(--text);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: #1e2a3f;
border-color: rgba(255,255,255,0.15);
transform: translateY(-2px);
}
.hero-logo-row {
display: flex;
align-items: center;
gap: 1.25rem;
margin-bottom: 0.5rem;
}
.stats {
display: flex;
gap: 2rem;
justify-content: center;
margin-top: 3.5rem;
padding-top: 2.5rem;
border-top: 1px solid var(--border);
}
.stat { text-align: center; }
.stat-val {
font-size: 1.6rem;
font-weight: 800;
color: var(--accent);
letter-spacing: -0.03em;
}
.stat-label { font-size: 0.75rem; color: var(--muted); margin-top: 0.2rem; text-transform: uppercase; letter-spacing: 0.08em; }
.hero-title {
font-size: 5rem;
font-weight: 900;
letter-spacing: -0.04em;
line-height: 1;
background: linear-gradient(135deg, #ffffff 0%, #c7d2fe 50%, #00d4aa 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0.8rem;
background: rgba(0,212,170,0.1);
border: 1px solid rgba(0,212,170,0.2);
border-radius: 999px;
font-size: 0.78rem;
color: var(--accent);
margin-bottom: 2rem;
}
.pulse {
width: 7px; height: 7px;
background: var(--accent);
border-radius: 50%;
animation: pulse 2s ease infinite;
}
@keyframes pulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:0.5;transform:scale(0.8)} }
</style>
.hero-subtitle-row {
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 1.5rem;
}
.hero-tagline {
font-size: 1.5rem;
font-weight: 300;
color: #94a3b8;
margin-bottom: 2.5rem;
line-height: 1.5;
}
.hero-tagline strong {
color: var(--teal);
font-weight: 600;
}
.hero-ctas {
display: flex;
gap: 1rem;
margin-bottom: 3rem;
flex-wrap: wrap;
}
.btn-primary {
display: inline-flex; align-items: center; gap: 0.5rem;
padding: 0.9rem 2rem;
background: linear-gradient(135deg, var(--accent), var(--accent2));
color: white; border: none; border-radius: 10px;
font-size: 0.95rem; font-weight: 700; cursor: pointer;
text-decoration: none; font-family: inherit;
box-shadow: 0 4px 24px rgba(79,70,229,0.4);
transition: all 0.2s;
}
.btn-primary:hover { transform: translateY(-2px); box-shadow: 0 8px 36px rgba(79,70,229,0.5); }
.btn-secondary {
display: inline-flex; align-items: center; gap: 0.5rem;
padding: 0.9rem 1.75rem;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.12);
color: var(--text); border-radius: 10px;
font-size: 0.95rem; font-weight: 600;
text-decoration: none; font-family: inherit;
transition: all 0.2s;
}
.btn-secondary:hover { background: rgba(255,255,255,0.1); }
.hero-stats {
display: flex;
gap: 2rem;
flex-wrap: wrap;
}
.hero-stat { display: flex; flex-direction: column; }
.hero-stat-val {
font-size: 1.5rem; font-weight: 800;
background: linear-gradient(135deg, var(--teal), var(--accent2));
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.hero-stat-lbl {
font-size: 0.72rem; color: var(--muted); margin-top: 0.1rem;
}
/* Features panel */
.features-panel {
background: rgba(15,22,41,0.8);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 20px;
padding: 2rem;
backdrop-filter: blur(10px);
position: relative;
overflow: hidden;
}
.features-panel::before {
content: 'APEX';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 4rem;
font-weight: 900;
letter-spacing: 0.1em;
background: linear-gradient(135deg, #4f46e5, #00d4aa);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
opacity: 0.15;
pointer-events: none;
white-space: nowrap;
}
.features-panel-title {
font-size: 0.75rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.15em;
color: var(--muted); margin-bottom: 1.5rem;
text-align: center;
}
.features-list { display: flex; flex-direction: column; gap: 1.25rem; }
.feature-item { display: flex; align-items: flex-start; gap: 1rem; }
.feature-icon-wrap {
width: 40px; height: 40px;
border-radius: 10px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
font-size: 1.1rem;
}
.feature-icon-wrap.purple { background: rgba(79,70,229,0.2); border: 1px solid rgba(79,70,229,0.3); }
.feature-icon-wrap.teal { background: rgba(0,212,170,0.15); border: 1px solid rgba(0,212,170,0.3); }
.feature-icon-wrap.blue { background: rgba(59,130,246,0.15); border: 1px solid rgba(59,130,246,0.3); }
.feature-icon-wrap.green { background: rgba(16,185,129,0.15); border: 1px solid rgba(16,185,129,0.3); }
.feature-name {
font-size: 0.72rem; font-weight: 700;
letter-spacing: 0.1em; text-transform: uppercase;
margin-bottom: 0.2rem;
}
.feature-name.purple { color: var(--accent2); }
.feature-name.teal { color: var(--teal); }
.feature-name.blue { color: #60a5fa; }
.feature-name.green { color: #34d399; }
.feature-desc { font-size: 0.8rem; color: #94a3b8; line-height: 1.5; }
/* Footer */
.footer {
margin-top: 4rem;
padding-top: 2rem;
border-top: 1px solid rgba(255,255,255,0.06);
display: flex;
justify-content: space-between;
align-items: center;
color: var(--muted);
font-size: 0.8rem;
}
@media (max-width: 768px) {
.hero { grid-template-columns: 1fr; min-height: auto; }
.hero-title { font-size: 3rem; }
.features-panel { display: none; }
.hero-stats { gap: 1.25rem; }
}
</style>
</head>
<body>
<div class="orb orb-1"></div>
<div class="orb orb-2"></div>
<div class="page">
<div class="landing">
<!-- Navbar -->
<nav class="navbar">
<div class="nav-logo">
<svg width="32" height="32" viewBox="0 0 36 36" fill="none">
<polygon points="18,2 34,32 2,32" fill="url(#logoGradL)" opacity="0.15"/>
<polygon points="18,8 30,30 6,30" fill="url(#logoGradL)" opacity="0.3"/>
<path d="M18 6L22 14H14L18 6Z" fill="url(#logoGradL)"/>
<rect x="11" y="22" width="3" height="8" rx="1" fill="#00d4aa"/>
<rect x="16" y="18" width="3" height="12" rx="1" fill="#00d4aa" opacity="0.8"/>
<rect x="21" y="14" width="3" height="16" rx="1" fill="#00d4aa" opacity="0.6"/>
<defs>
<linearGradient id="logoGradL" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#4f46e5"/>
<stop offset="100%" stop-color="#00d4aa"/>
</linearGradient>
</defs>
</svg>
<span class="nav-logo-text">APEX</span>
</div>
<div class="status-pill" id="status-pill">
<div class="status-dot idle" id="status-dot"></div>
<span id="status-text">Ready</span>
</div>
</nav>
<div class="logo">
<div class="logo-icon"></div>
<div>
<div class="logo-name">MT5 Smart Optimizer</div>
<div class="logo-sub">Autonomous EA Optimization Engine</div>
<!-- Hero -->
<div class="hero">
<div class="hero-left">
<div class="hero-badge">&#10022; Hackathon Edition 2026</div>
<div class="hero-logo-row">
<svg width="72" height="72" viewBox="0 0 72 72" fill="none">
<polygon points="36,4 68,64 4,64" fill="url(#heroGrad)" opacity="0.12"/>
<polygon points="36,16 60,60 12,60" fill="url(#heroGrad)" opacity="0.25"/>
<path d="M36 12L44 28H28L36 12Z" fill="url(#heroGrad)"/>
<path d="M33 20L36 13L39 20" stroke="#00d4aa" stroke-width="1.5" fill="none" stroke-linecap="round"/>
<rect x="22" y="44" width="6" height="16" rx="2" fill="#00d4aa" opacity="0.9"/>
<rect x="33" y="36" width="6" height="24" rx="2" fill="#00d4aa" opacity="0.7"/>
<rect x="44" y="28" width="6" height="32" rx="2" fill="#00d4aa" opacity="0.5"/>
<defs>
<linearGradient id="heroGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#4f46e5"/>
<stop offset="100%" stop-color="#7c6dfa"/>
</linearGradient>
</defs>
</svg>
</div>
<div class="hero-title">APEX</div>
<div class="hero-subtitle-row">AI Powered EA Optimizer</div>
<div class="hero-tagline">
Optimize. <strong>Understand.</strong> Evolve.<br>
<span style="font-size:1rem">The first optimizer that tells you <em>why</em> &mdash; not just what.</span>
</div>
<div class="hero-ctas">
<a href="/setup" class="btn-primary">&#9889; Start Optimizing &rarr;</a>
<a href="/dashboard" class="btn-secondary">&#128202; Live Dashboard</a>
<a href="/reports" class="btn-secondary">&#128203; Reports</a>
</div>
<div class="hero-stats">
<div class="hero-stat">
<div class="hero-stat-val">3</div>
<div class="hero-stat-lbl">Phase Pipeline</div>
</div>
<div class="hero-stat">
<div class="hero-stat-val">AI</div>
<div class="hero-stat-lbl">Claude-Powered</div>
</div>
<div class="hero-stat">
<div class="hero-stat-val">IS/OOS</div>
<div class="hero-stat-lbl">Walk-Forward Validated</div>
</div>
<div class="hero-stat">
<div class="hero-stat-val">Any EA</div>
<div class="hero-stat-lbl">Works with any .set file</div>
</div>
</div>
</div>
<div class="hero-right">
<div class="features-panel">
<div class="features-panel-title">What makes APEX different</div>
<div class="features-list">
<div class="feature-item">
<div class="feature-icon-wrap purple">&#129504;</div>
<div>
<div class="feature-name purple">AI Reasoning</div>
<div class="feature-desc">Analyzes results, detects patterns and explains performance in plain language using Claude AI.</div>
</div>
</div>
<div class="feature-item">
<div class="feature-icon-wrap teal">&#127919;</div>
<div>
<div class="feature-name teal">Smart Optimization</div>
<div class="feature-desc">Finds the best parameters using 3-phase search: broad discovery &rarr; refinement &rarr; out-of-sample validation.</div>
</div>
</div>
<div class="feature-item">
<div class="feature-icon-wrap blue" style="font-size:0.85rem;font-weight:700;font-family:monospace;color:#60a5fa">&lt;/&gt;</div>
<div>
<div class="feature-name blue">Strategy Evolution</div>
<div class="feature-desc">Suggests improvements and helps evolve your trading systems based on behavioral pattern analysis.</div>
</div>
</div>
<div class="feature-item">
<div class="feature-icon-wrap green">&#128737;</div>
<div>
<div class="feature-name green">Robust &amp; Reliable</div>
<div class="feature-desc">Built-in validation (IS/OOS/WFV) and sensitivity testing to ensure real market reliability.</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="status-pill" id="status-pill">
<div class="pulse"></div>
<span id="status-text">Ready</span>
</div>
<h1>Find the Best Settings<br>for Any MT5 EA</h1>
<p class="subtitle">
The Smart Optimizer tests dozens of configurations across your full parameter space,
validates the winner on unseen data, and delivers a ready-to-use .set file — in under an hour.
</p>
<div class="actions">
<a href="/setup" class="btn btn-primary">
⚡ New Optimization
</a>
<a href="/dashboard" class="btn btn-secondary" id="dashboard-btn">
📊 Live Dashboard
</a>
<a href="/reports" class="btn btn-secondary">
📁 View Reports
</a>
</div>
<div class="stats">
<div class="stat">
<div class="stat-val">3</div>
<div class="stat-label">Phases</div>
</div>
<div class="stat">
<div class="stat-val" id="stat-runs">2050</div>
<div class="stat-label">Config Tests</div>
</div>
<div class="stat">
<div class="stat-val">&lt;1hr</div>
<div class="stat-label">Typical Time</div>
</div>
<div class="stat">
<div class="stat-val">OOS</div>
<div class="stat-label">Validated</div>
</div>
</div>
<footer class="footer">
<span>APEX &mdash; AI-Powered EA Optimizer</span>
<span>Powered by Claude AI + MetaTrader 5</span>
</footer>
</div>
<script>
// Check if optimizer is running
fetch('/api/status').then(r => r.json()).then(s => {
if (s.state === 'running') {
document.getElementById('status-text').textContent = `Running — ${s.phase} (${s.run_count}/${s.total_runs})`;
document.getElementById('dashboard-btn').style.background = 'linear-gradient(135deg,#7c6dfa,#5b4fe8)';
document.getElementById('dashboard-btn').style.color = 'white';
}
}).catch(() => {});
// Fetch optimizer status and update the live pill
fetch('/api/status')
.then(function(r) { return r.json(); })
.then(function(s) {
var dot = document.getElementById('status-dot');
var txt = document.getElementById('status-text');
if (s.state === 'running') {
dot.className = 'status-dot running';
txt.textContent = 'Running: ' + (s.phase || '') + ' (' + (s.run_count || 0) + ' runs)';
} else if (s.verdict) {
dot.className = 'status-dot';
dot.style.background = '#10b981';
txt.textContent = 'Last: ' + s.verdict;
}
})
.catch(function() {});
</script>
</body>
</html>
+405 -8
View File
@@ -370,6 +370,161 @@
</div>
</div>
<!-- Add New EA -->
<div style="margin-top:0.5rem;display:flex;gap:0.75rem;align-items:center;flex-wrap:wrap">
<button type="button" onclick="showAddEAModal()"
style="background:none;border:1px dashed rgba(79,70,229,0.4);color:#7c6dfa;padding:0.4rem 1rem;border-radius:8px;font-size:0.8rem;cursor:pointer;font-family:inherit;transition:all 0.2s"
onmouseover="this.style.borderColor='#7c6dfa'" onmouseout="this.style.borderColor='rgba(79,70,229,0.4)'">
+ Register New EA
</button>
<button type="button" id="scan-btn" onclick="scanForEAs()"
style="background:none;border:1px dashed rgba(0,212,170,0.35);color:#00d4aa;padding:0.4rem 1rem;border-radius:8px;font-size:0.8rem;cursor:pointer;font-family:inherit;transition:all 0.2s;display:flex;align-items:center;gap:0.4rem"
onmouseover="this.style.borderColor='#00d4aa'" onmouseout="this.style.borderColor='rgba(0,212,170,0.35)'">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
Scan for EAs
</button>
<span id="scan-status" style="font-size:0.78rem;color:#64748b"></span>
</div>
<!-- Add EA Modal -->
<div id="add-ea-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:50;align-items:center;justify-content:center;backdrop-filter:blur(6px)">
<div style="background:#0d1117;border:1px solid rgba(255,255,255,0.1);border-radius:16px;padding:2rem;width:540px;max-width:95vw;max-height:90vh;overflow-y:auto">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1.5rem">
<div>
<h3 style="font-size:1.05rem;font-weight:700;color:#e2e8f0">Register New EA</h3>
<p style="font-size:0.78rem;color:#64748b;margin-top:0.2rem">Pick a detected EA or enter paths manually</p>
</div>
<button onclick="hideAddEAModal()" style="background:none;border:none;color:#64748b;cursor:pointer;font-size:1.4rem;line-height:1">&#10005;</button>
</div>
<!-- Detected EAs picker (shown after scan) -->
<div id="detected-section" style="display:none;margin-bottom:1.25rem">
<div style="font-size:0.78rem;font-weight:600;text-transform:uppercase;letter-spacing:0.08em;color:#00d4aa;margin-bottom:0.6rem">
Detected EAs — click to auto-fill
</div>
<div id="detected-list" style="display:flex;flex-direction:column;gap:0.4rem;max-height:180px;overflow-y:auto;padding-right:4px"></div>
<div style="margin:1rem 0;border-top:1px solid rgba(255,255,255,0.06)"></div>
</div>
<div style="display:flex;flex-direction:column;gap:1rem">
<div class="field">
<label>EA Display Name</label>
<input type="text" id="new-ea-name" placeholder="e.g. MyEA_v2">
</div>
<div class="field">
<label>EA File Name (without .ex5)</label>
<input type="text" id="new-ea-file" placeholder="e.g. MyEA_v2">
</div>
<div class="field">
<label style="display:flex;align-items:center;justify-content:space-between">
<span>.set Template Path</span>
<span id="set-match-badge" style="display:none;font-size:0.7rem;background:rgba(0,212,170,0.15);color:#00d4aa;padding:2px 8px;border-radius:999px;font-weight:600">✓ auto-matched</span>
</label>
<input type="text" id="new-ea-set" placeholder="C:\MT5 Set files\MyEA.set">
<!-- Set file picker (shown after scan) -->
<select id="set-picker" style="display:none;margin-top:0.4rem;font-size:0.82rem" onchange="onSetPick()">
<option value="">— pick from detected .set files —</option>
</select>
</div>
<div class="grid-2">
<div class="field">
<label>Symbol</label>
<select id="new-ea-symbol">
<option value="XAUUSD">XAUUSD (Gold)</option>
<option value="EURUSD">EURUSD</option>
<option value="GBPUSD">GBPUSD</option>
<option value="USDJPY">USDJPY</option>
<option value="USDCAD">USDCAD</option>
<option value="AUDUSD">AUDUSD</option>
<option value="BTCUSD">BTCUSD</option>
<option value="NASDAQ">NASDAQ</option>
<option value="US30">US30</option>
<option value="DE40">DE40</option>
</select>
</div>
<div class="field">
<label>Timeframe</label>
<select id="new-ea-tf">
<option value="M5">M5</option>
<option value="M15">M15</option>
<option value="M30">M30</option>
<option value="H1" selected>H1</option>
<option value="H4">H4</option>
<option value="D1">D1</option>
</select>
</div>
</div>
</div>
<div style="display:flex;gap:0.75rem;margin-top:1.5rem;justify-content:flex-end">
<button type="button" onclick="hideAddEAModal()"
style="padding:0.6rem 1.25rem;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.1);color:#e2e8f0;border-radius:8px;cursor:pointer;font-family:inherit">
Cancel
</button>
<button type="button" onclick="registerNewEA()"
style="padding:0.6rem 1.5rem;background:linear-gradient(135deg,#4f46e5,#7c6dfa);color:white;border:none;border-radius:8px;font-weight:700;cursor:pointer;font-family:inherit">
Register EA
</button>
</div>
</div>
</div>
<!-- Autonomous AI Loop -->
<div style="border:1px solid rgba(0,212,170,0.25);border-radius:12px;padding:1.25rem 1.5rem;background:rgba(0,212,170,0.04);margin-top:0.5rem">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem">
<div>
<div style="display:flex;align-items:center;gap:0.6rem">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#00d4aa" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
<span style="font-size:0.88rem;font-weight:700;color:#e2e8f0">Autonomous AI Loop</span>
<span style="font-size:0.62rem;background:rgba(0,212,170,0.18);color:#00d4aa;padding:2px 7px;border-radius:999px;font-weight:700;letter-spacing:0.05em">NEW</span>
</div>
<div style="font-size:0.72rem;color:#64748b;margin-top:0.25rem">
AI analyzes results and evolves parameters each iteration until targets are met
</div>
</div>
<!-- Toggle -->
<button type="button" class="settings-toggle" id="autonomous-toggle"
onclick="toggleAutonomous(this)"
style="width:44px;height:24px;background:rgba(255,255,255,0.12);border-radius:999px;border:none;cursor:pointer;position:relative;transition:background 0.2s;flex-shrink:0">
</button>
</div>
<!-- Expanded controls (hidden when off) -->
<div id="autonomous-controls" style="display:none">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:0.75rem;margin-bottom:0.75rem">
<div class="field" style="margin:0">
<label>Max Iterations</label>
<input type="number" name="autonomous_max_iterations" id="ai-max-iter"
value="10" min="3" max="50" step="1" class="input" style="margin-top:0.3rem">
</div>
<div class="field" style="margin:0">
<label>Target Profit Factor</label>
<input type="number" name="target_profit_factor" id="ai-target-pf"
value="1.5" min="1.0" max="5.0" step="0.1" class="input" style="margin-top:0.3rem">
</div>
<div class="field" style="margin:0">
<label>Target Max Drawdown %</label>
<input type="number" name="target_max_drawdown_pct" id="ai-target-dd"
value="20" min="5" max="50" step="1" class="input" style="margin-top:0.3rem">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 2fr;gap:0.75rem">
<div class="field" style="margin:0">
<label>Target Min Calmar</label>
<input type="number" name="target_min_calmar" id="ai-target-calmar"
value="0.5" min="0.1" max="5.0" step="0.05" class="input" style="margin-top:0.3rem">
</div>
<div style="background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.07);border-radius:8px;padding:0.6rem 0.9rem;font-size:0.72rem;color:#64748b;line-height:1.55">
Loop stops early when <strong style="color:#e2e8f0">ALL</strong> targets are met simultaneously.
Requires an Anthropic API key configured in Settings.
</div>
</div>
</div>
<!-- Hidden inputs for form submission -->
<input type="hidden" name="autonomous_mode" id="autonomous-mode-val" value="false">
</div>
<!-- Submit -->
<div class="submit-row">
<button type="submit" class="btn-start" id="start-btn">
@@ -419,13 +574,242 @@
body.classList.toggle('open');
}
let scannedData = null; // cache from /api/ea/scan
function showAddEAModal() {
document.getElementById('add-ea-modal').style.display = 'flex';
// If we already scanned, populate the detected section
if (scannedData) populateDetected(scannedData);
}
function hideAddEAModal() {
document.getElementById('add-ea-modal').style.display = 'none';
}
async function scanForEAs() {
const btn = document.getElementById('scan-btn');
const status = document.getElementById('scan-status');
btn.style.opacity = '0.5';
btn.style.pointerEvents = 'none';
status.textContent = 'Scanning...';
try {
const resp = await fetch('/api/ea/scan');
const data = await resp.json();
scannedData = data;
const count = data.ex5.length;
const setCount = data.set.length;
status.style.color = '#00d4aa';
status.textContent = `Found ${count} EA${count !== 1 ? 's' : ''} · ${setCount} .set file${setCount !== 1 ? 's' : ''}`;
showAddEAModal();
} catch(e) {
status.style.color = '#ef4444';
status.textContent = 'Scan failed: ' + e.message;
} finally {
btn.style.opacity = '';
btn.style.pointerEvents = '';
}
}
function populateDetected(data) {
const section = document.getElementById('detected-section');
const list = document.getElementById('detected-list');
const picker = document.getElementById('set-picker');
if (!data.ex5.length && !data.set.length) return;
// Build EA pill list
list.innerHTML = '';
data.ex5.forEach(ea => {
const pill = document.createElement('button');
pill.type = 'button';
pill.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:0.55rem 0.85rem;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);border-radius:9px;cursor:pointer;font-family:inherit;width:100%;text-align:left;transition:all 0.15s';
pill.onmouseover = () => { pill.style.background='rgba(124,109,250,0.12)'; pill.style.borderColor='rgba(124,109,250,0.4)'; };
pill.onmouseout = () => { pill.style.background='rgba(255,255,255,0.04)'; pill.style.borderColor='rgba(255,255,255,0.08)'; };
pill.innerHTML = `
<span style="font-size:0.85rem;font-weight:600;color:#e2e8f0">${ea.name}</span>
${ea.suggested_set
? '<span style="font-size:0.7rem;background:rgba(0,212,170,0.15);color:#00d4aa;padding:2px 8px;border-radius:999px;font-weight:600">.set matched</span>'
: '<span style="font-size:0.7rem;color:#64748b">no .set matched</span>'
}
`;
pill.onclick = () => autoFillFromEA(ea);
list.appendChild(pill);
});
// Build .set picker
picker.innerHTML = '<option value="">— pick from detected .set files —</option>';
data.set.forEach(s => {
const opt = new Option(s.filename, s.path);
picker.add(opt);
});
picker.style.display = data.set.length ? 'block' : 'none';
section.style.display = 'block';
}
function autoFillFromEA(ea) {
document.getElementById('new-ea-name').value = ea.name;
document.getElementById('new-ea-file').value = ea.name;
if (ea.suggested_set) {
document.getElementById('new-ea-set').value = ea.suggested_set;
document.getElementById('set-match-badge').style.display = 'inline';
} else {
document.getElementById('new-ea-set').value = '';
document.getElementById('set-match-badge').style.display = 'none';
}
// Scroll to bottom of modal to show form fields
document.getElementById('add-ea-modal').querySelector('div').scrollTop = 999;
}
function onSetPick() {
const val = document.getElementById('set-picker').value;
if (val) {
document.getElementById('new-ea-set').value = val;
document.getElementById('set-match-badge').style.display = 'inline';
}
}
async function registerNewEA() {
const name = document.getElementById('new-ea-name').value.trim();
const file = document.getElementById('new-ea-file').value.trim();
const set = document.getElementById('new-ea-set').value.trim();
const symbol = document.getElementById('new-ea-symbol').value;
const timeframe = document.getElementById('new-ea-tf').value;
if (!name || !set) {
alert('EA Name and .set file path are required.');
return;
}
try {
const resp = await fetch('/api/ea/register', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ name, ex5_file: file || name, set_template: set, symbol, timeframe, mode: 'generic' }),
});
const result = await resp.json();
if (result.ok) {
hideAddEAModal();
// Add to dropdown immediately without reload
const sel = document.getElementById('ea_name');
const existing = [...sel.options].find(o => o.value === name);
if (!existing) {
const opt = new Option(name, name);
sel.add(opt);
}
sel.value = name;
document.getElementById('scan-status').textContent = `"${name}" registered ✓`;
document.getElementById('scan-status').style.color = '#00d4aa';
// Reload params for this EA
if (typeof loadEAParams === 'function') loadEAParams(name);
} else {
alert('Error: ' + (result.error || 'Registration failed'));
}
} catch(e) {
alert('Network error: ' + e.message);
}
}
async function loadEAParams(eaName) {
const grid = document.getElementById('param-grid');
if (!grid) return;
grid.innerHTML = '<span style="color:#64748b;font-size:0.82rem">Loading parameters...</span>';
try {
const resp = await fetch('/api/ea_params?ea=' + encodeURIComponent(eaName));
const params = await resp.json();
if (params.error) { grid.innerHTML = `<span style="color:#ef4444;font-size:0.82rem">${params.error}</span>`; return; }
if (!params.length) { grid.innerHTML = '<span style="color:#64748b;font-size:0.82rem">No optimizable parameters found in .set file.</span>'; return; }
grid.innerHTML = params.map(p => `
<label class="param-check">
<input type="checkbox" name="selected_params" value="${p.name}" ${p.optimize ? 'checked' : ''}>
<span title="${p.range || ''}">${p.name.replace('Inp','')}</span>
</label>
`).join('');
} catch(e) {
grid.innerHTML = `<span style="color:#ef4444;font-size:0.82rem">Error loading params: ${e.message}</span>`;
}
}
// Reload params + symbol/timeframe when EA selection changes
document.getElementById('ea_name').addEventListener('change', function() {
loadEAParams(this.value);
});
function toggleAutonomous(btn) {
const isOn = btn.style.background === 'rgb(79, 70, 229)' || btn.classList.contains('on');
if (isOn) {
btn.style.background = 'rgba(255,255,255,0.12)';
btn.classList.remove('on');
btn.querySelector('span') && (btn.querySelector('span').style.transform = '');
document.getElementById('autonomous-controls').style.display = 'none';
document.getElementById('autonomous-mode-val').value = 'false';
} else {
btn.style.background = '#4f46e5';
btn.classList.add('on');
document.getElementById('autonomous-controls').style.display = 'block';
document.getElementById('autonomous-mode-val').value = 'true';
}
// Maintain the ::after pseudo-element position via class
btn.style.setProperty('--toggle-x', isOn ? '0px' : '20px');
}
// ── Toast helper for inline validation feedback ────────────────────────
function showSetupError(msg) {
let toast = document.getElementById('setup-toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'setup-toast';
toast.style.cssText = 'position:fixed;top:20px;right:20px;background:rgba(239,68,68,0.95);color:white;padding:0.8rem 1.2rem;border-radius:8px;font-size:0.85rem;font-weight:600;z-index:9999;box-shadow:0 4px 12px rgba(0,0,0,0.3);max-width:380px;line-height:1.4;';
document.body.appendChild(toast);
}
toast.textContent = msg;
toast.style.display = 'block';
clearTimeout(toast._timer);
toast._timer = setTimeout(() => { toast.style.display = 'none'; }, 5000);
}
function validateSetup(data) {
if (!data.ea_name || !data.ea_name.trim()) {
return 'Please select an EA before starting.';
}
if (!data.symbol || !data.timeframe) {
return 'Symbol and timeframe are required.';
}
const ds = (s) => new Date((s || '').replace(/\./g, '-'));
const ts = ds(data.train_start), te = ds(data.train_end);
const vs = ds(data.val_start), ve = ds(data.val_end);
if (isNaN(ts) || isNaN(te) || isNaN(vs) || isNaN(ve)) {
return 'All four dates must be valid (YYYY-MM-DD).';
}
if (ts >= te) return 'Training start must come before training end.';
if (vs >= ve) return 'Validation start must come before validation end.';
if (vs < te) return 'Validation period should start after the training period (walk-forward).';
if (data.budget_minutes < 1) return 'Time budget must be at least 1 minute.';
if (!data.selected_params || data.selected_params.length === 0) {
return 'Pick at least one parameter to optimize.';
}
if (data.autonomous_mode) {
if (data.autonomous_max_iterations < 1 || data.autonomous_max_iterations > 100) {
return 'AI max iterations must be between 1 and 100.';
}
if (data.target_profit_factor < 1) return 'Target profit factor must be 1.';
if (data.target_max_drawdown_pct <= 0 || data.target_max_drawdown_pct > 100) {
return 'Target max drawdown must be between 0 and 100 (%).';
}
}
return null; // valid
}
document.getElementById('setup-form').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('start-btn');
btn.disabled = true;
btn.innerHTML = '<span></span> Starting...';
const restoreBtn = () => {
btn.disabled = false;
btn.innerHTML = '<span></span> Start Optimization';
};
const form = e.target;
const autonomousOn = document.getElementById('autonomous-mode-val').value === 'true';
const data = {
ea_name: form.ea_name.value,
symbol: form.symbol.value,
@@ -438,8 +822,23 @@
budget_minutes: parseInt(form.budget_minutes.value),
selected_params: [...form.querySelectorAll('input[name=selected_params]:checked')]
.map(cb => cb.value),
// Autonomous loop
autonomous_mode: autonomousOn,
autonomous_max_iterations: parseInt(document.getElementById('ai-max-iter').value) || 10,
target_profit_factor: parseFloat(document.getElementById('ai-target-pf').value) || 1.5,
target_max_drawdown_pct: parseFloat(document.getElementById('ai-target-dd').value) || 20.0,
target_min_calmar: parseFloat(document.getElementById('ai-target-calmar').value) || 0.5,
};
const validationError = validateSetup(data);
if (validationError) {
showSetupError(validationError);
return;
}
btn.disabled = true;
btn.innerHTML = '<span></span> Starting...';
try {
const resp = await fetch('/api/start', {
method: 'POST',
@@ -450,14 +849,12 @@
if (result.ok) {
window.location.href = '/dashboard';
} else {
alert('Could not start: ' + (result.msg || 'Unknown error'));
btn.disabled = false;
btn.innerHTML = '<span></span> Start Optimization';
showSetupError('Could not start: ' + (result.msg || 'Unknown error'));
restoreBtn();
}
} catch(err) {
alert('Network error: ' + err.message);
btn.disabled = false;
btn.innerHTML = '<span></span> Start Optimization';
showSetupError('Network error: ' + err.message);
restoreBtn();
}
});
</script>