feat: open-source release — AI-driven autonomous optimization with live visibility
Major upgrade making the AI loop visible and the project ready for public release. UI / UX - Live AI Thinking Feed: streams reasoning, decisions, and outcomes per iteration - Parameter Changes panel: prev → new + reason for every AI-driven edit - Validation Activity panel: out-of-sample + sensitivity runs with live metrics - Early Termination banner: surfaces why optimization stopped (targets met, no profit, budget, stuck, user stop) - 3-phase tracker renamed Exploration / Iteration / Validation with live N/total - Best Result modal exposes Evolution Path showing how the AI arrived at the winner - Run-detail modal accessible from every recent run row - Setup form validation (dates, walk-forward order, params selection, AI targets) - Pause button removed; misleading sidebar nav consolidated to Dashboard / New Run / Reports / Source Backend - AIGuidedLoop streams ai_thinking, param_changes, ai_targets_met, ai_stuck - Pipeline emits validation_start / validation_run_start / validation_run_complete / validation_done - Pipeline emits early_termination on every early-stop path - /api/best_result returns best run + full evolution chain - /api/run/<id> + /api/runs sorted by ts - AIReasoner falls back to ANTHROPIC_API_KEY env var when config is a placeholder - Demo mode (APEX_DEMO_MODE=1) generates deterministic synthetic backtests so judges can run end-to-end without MT5 Open-source readiness - README.md with pitch, demo flow, architecture diagram, quickstart, event reference - LICENSE (MIT) - config.example.yaml template (config.yaml now git-ignored) - requirements.txt: added anthropic / requests / psutil / beautifulsoup4, capped majors - .gitignore: secrets, *.set, scratch screenshots, ea_registry.yaml - demo/run_demo.py: one-command offline demo runner - 10 polished screenshots for README + judge review
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user