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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,207 @@
|
||||
# APEX — AI‑Powered 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 brute‑forcing
|
||||
parameters with grid search, an LLM reads each backtest result, decides which parameter to
|
||||
change and why, then runs the next backtest — iterating toward profit‑factor / drawdown /
|
||||
Calmar targets you set. Every reasoning step streams live to a dashboard.
|
||||
|
||||
---
|
||||
|
||||
## Why this is different
|
||||
|
||||
| Traditional optimizers | APEX |
|
||||
| --- | --- |
|
||||
| Brute‑force grid / genetic search | AI reads each result, **decides** what to change |
|
||||
| Black box — see only final winner | Live **thinking feed** + per‑iteration 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) |
|
||||
| One‑shot validation | Out‑of‑sample **+ sensitivity** with live progress |
|
||||
|
||||
---
|
||||
|
||||
## Demo
|
||||
|
||||

|
||||
|
||||
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
|
||||
out‑of‑sample/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 │
|
||||
│ Latin‑Hypercube sample N parameter sets → run in MT5 Strategy Tester │
|
||||
│ → score with Calmar / PF / MFE / session‑stability / 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 │
|
||||
│ Out‑of‑sample run on unseen dates + ±20% sensitivity probe on the │
|
||||
│ top parameter → verdict: RECOMMENDED / RISKY / NOT_RELIABLE │
|
||||
│ │
|
||||
│ Output: ranked .set file + per‑run 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,
|
||||
parameter‑change 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 + walk‑forward 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 3‑phase pipeline orchestrator
|
||||
│ ├── ai_guided_loop.py Autonomous AI iteration loop
|
||||
│ ├── result_ranker.py Scoring & ranking of runs
|
||||
│ └── session_config.py Per‑run config dataclass
|
||||
├── ea/
|
||||
│ └── schema.py EA parameter schema + clamp/validation
|
||||
├── mt5/ MT5 launcher, ini builder, html report parser
|
||||
├── reports/
|
||||
│ └── writer.py Per‑run HTML/CSV/JSON output
|
||||
├── ui/
|
||||
│ ├── templates/ dashboard.html, setup.html, reports_index.html
|
||||
│ └── static/js/dashboard.js All client‑side 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` | Per‑iteration 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.
|
||||
@@ -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.0–1.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 1–3 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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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 0–1
|
||||
# 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.7–2.5
|
||||
calmar = round(true_score * 1.4, 3) # 0.0–1.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) # 80–300
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 85 KiB |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">✦ 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> — not just what.</span>
|
||||
</div>
|
||||
|
||||
<div class="hero-ctas">
|
||||
<a href="/setup" class="btn-primary">⚡ Start Optimizing →</a>
|
||||
<a href="/dashboard" class="btn-secondary">📊 Live Dashboard</a>
|
||||
<a href="/reports" class="btn-secondary">📋 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">🧠</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">🎯</div>
|
||||
<div>
|
||||
<div class="feature-name teal">Smart Optimization</div>
|
||||
<div class="feature-desc">Finds the best parameters using 3-phase search: broad discovery → refinement → 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"></></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">🛡</div>
|
||||
<div>
|
||||
<div class="feature-name green">Robust & 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">20–50</div>
|
||||
<div class="stat-label">Config Tests</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-val"><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 — 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>
|
||||
|
||||
@@ -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">✕</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>
|
||||
|
||||