Files
mt5-quant/server/main.py
T

2461 lines
90 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""
MT5-Quant MCP Server
Exposes MT5 backtest and optimization tools via the Model Context Protocol.
Run with: python3 server/main.py
Add to Claude Code:
claude mcp add MT5-Quant -- python3 /path/to/mt5-quant/server/main.py
"""
import asyncio
import difflib
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any
try:
import mcp.server.stdio
import mcp.types as types
from mcp.server import Server
except ImportError:
print("ERROR: mcp package not installed. Run: pip install mcp", file=sys.stderr)
sys.exit(1)
# Config/ROOT_DIR resolution (priority order):
# 1. MT5_MCP_HOME env var (explicit override)
# 2. ~/.config/mt5-quant/ (installed-package default)
# 3. parent of this file (development / run-from-repo)
def _resolve_root() -> Path:
env_home = os.environ.get('MT5_MCP_HOME')
if env_home:
return Path(env_home).expanduser().resolve()
user_cfg = Path.home() / '.config' / 'mt5-quant'
if (user_cfg / 'config' / 'mt5-quant.yaml').exists():
return user_cfg
return Path(__file__).parent.parent
ROOT_DIR = _resolve_root()
# SCRIPTS_DIR always points to the package's scripts/ (adjacent to server/main.py),
# not the config dir. Scripts are never copied to ~/.config/mt5-quant.
SCRIPTS_DIR = Path(__file__).parent.parent / 'scripts'
if not SCRIPTS_DIR.exists():
# Installed via pip — scripts landed in the package root via pyproject.toml include
SCRIPTS_DIR = ROOT_DIR / 'scripts'
sys.path.insert(0, str(ROOT_DIR))
# Also ensure analytics imports resolve from the package dir
_pkg_dir = str(Path(__file__).parent.parent)
if _pkg_dir not in sys.path:
sys.path.insert(0, _pkg_dir)
from analytics.extract import detect_format, parse_html, parse_xml, write_outputs
from analytics.analyze import (
load_deals, load_metrics, monthly_pnl, reconstruct_dd_events,
grid_depth_histogram, top_losses, loss_sequences, build_summary
)
from analytics.optimize_parser import (
detect_format as opt_detect_format,
parse_html as opt_parse_html,
parse_xml as opt_parse_xml,
normalize, convergence_analysis
)
app = Server("MT5-Quant")
# ── Config ────────────────────────────────────────────────────────────────────
def load_config() -> dict:
config_path = ROOT_DIR / 'config' / 'mt5-quant.yaml'
if not config_path.exists():
return {}
config = {}
with open(config_path) as f:
# Simple YAML key: value parser (no nested support needed for basic config)
for line in f:
line = line.strip()
if line.startswith('#') or ':' not in line:
continue
key, _, val = line.partition(':')
val = val.strip().strip('"').strip("'")
if val and val not in ('null', '~', ''):
config[key.strip()] = val
return config
CONFIG = load_config()
def cfg(key: str, default: str = '') -> str:
return CONFIG.get(key, default) or default
REPORTS_DIR = ROOT_DIR / cfg('reports_dir', 'reports')
HISTORY_FILE = ROOT_DIR / 'config' / 'backtest_history.json'
BASELINE_FILE = ROOT_DIR / 'config' / 'baseline.json'
def _validate_environment() -> dict | None:
"""Fast pre-flight check — returns error dict if environment is broken, None if OK."""
config_path = ROOT_DIR / 'config' / 'mt5-quant.yaml'
missing = []
if not config_path.exists():
missing.append('config/mt5-quant.yaml not found')
wine = cfg('wine_executable')
if not wine:
missing.append('wine_executable not set in config')
elif not os.access(wine, os.X_OK):
missing.append(f'wine_executable not found or not executable: {wine}')
terminal_dir = cfg('terminal_dir')
if not terminal_dir:
missing.append('terminal_dir not set in config')
elif not Path(terminal_dir).is_dir():
missing.append(f'terminal_dir not found: {terminal_dir}')
if missing:
return {
'success': False,
'error': 'SETUP_REQUIRED',
'missing': missing,
'hint': 'Run: bash scripts/setup.sh',
}
return None
def _check_symbol(symbol: str) -> tuple[str | None, list[str]]:
"""Check if symbol exists in MT5 history dir. Returns (warning, suggestions)."""
terminal_dir = cfg('terminal_dir')
if not terminal_dir:
return None, []
history_dir = Path(terminal_dir) / 'history'
if not history_dir.is_dir():
return None, []
known = [d.name for d in history_dir.iterdir() if d.is_dir()]
if not known or symbol in known:
return None, []
suggestions = difflib.get_close_matches(symbol, known, n=3, cutoff=0.6)
sample = ', '.join(known[:5]) + ('...' if len(known) > 5 else '')
warning = f"Symbol '{symbol}' not found in MT5 history. Available: {sample}"
return warning, suggestions
# ── History helpers ───────────────────────────────────────────────────────────
def load_history() -> list[dict]:
if not HISTORY_FILE.exists():
return []
with open(HISTORY_FILE) as f:
return json.load(f)
def save_history(entries: list[dict]) -> None:
HISTORY_FILE.parent.mkdir(exist_ok=True)
with open(HISTORY_FILE, 'w') as f:
json.dump(entries, f, indent=2)
def _build_history_entry(report_dir: str) -> dict | None:
"""Build a compact, self-contained history entry from a report directory."""
from datetime import datetime, timezone
d = Path(report_dir)
metrics = read_json(str(d / 'metrics.json'))
analysis = read_json(str(d / 'analysis.json'))
if not metrics and not analysis:
return None
entry: dict = {
'id': d.name,
'report_dir': str(d),
'report_dir_deleted': False,
'archived_at': datetime.now(timezone.utc).isoformat(),
'ea': metrics.get('expert') or metrics.get('ea') or '',
'symbol': metrics.get('symbol', ''),
'timeframe': metrics.get('timeframe', ''),
'from_date': metrics.get('from_date') or metrics.get('testing_from', ''),
'to_date': metrics.get('to_date') or metrics.get('testing_to', ''),
'metrics': {
'net_profit': metrics.get('net_profit'),
'profit_factor': metrics.get('profit_factor'),
'max_dd_pct': metrics.get('max_dd_pct'),
'sharpe_ratio': metrics.get('sharpe_ratio'),
'total_trades': metrics.get('total_trades'),
'recovery_factor': metrics.get('recovery_factor'),
'win_rate_pct': metrics.get('win_rate_pct'),
'expected_payoff': metrics.get('expected_payoff'),
},
'verdict': None,
'notes': '',
'tags': [],
'promoted_to_baseline': False,
}
if analysis:
summary = analysis.get('summary', {})
entry['summary'] = {k: summary.get(k) for k in (
'green_months', 'total_months', 'worst_month', 'worst_month_pnl',
'worst_dd_event_pct', 'max_grid_depth', 'l5_plus_count',
'dominant_exit', 'max_win_streak', 'max_loss_streak',
'current_streak', 'current_streak_type',
) if summary.get(k) is not None}
monthly = analysis.get('monthly_pnl', [])
if monthly:
entry['monthly_pnl'] = monthly
dd_events = analysis.get('dd_events', [])
if dd_events:
entry['worst_dd_event'] = dd_events[0]
return entry
# ── Tool helpers ──────────────────────────────────────────────────────────────
def run_script(cmd: list[str], timeout: int = 900) -> tuple[bool, str]:
"""Run a shell script synchronously and return (success, output)."""
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
cwd=str(ROOT_DIR),
)
output = result.stdout + result.stderr
return result.returncode == 0, output
except subprocess.TimeoutExpired:
return False, f"Timeout after {timeout}s"
except Exception as e:
return False, str(e)
def latest_report_dir() -> str | None:
"""Find most recently created report directory."""
REPORTS_DIR.mkdir(exist_ok=True)
dirs = sorted(REPORTS_DIR.iterdir(), reverse=True)
for d in dirs:
if d.is_dir() and not d.name.endswith('_opt'):
return str(d)
return None
def read_json(path: str) -> dict:
if not os.path.exists(path):
return {}
with open(path) as f:
return json.load(f)
def format_result(data: dict) -> str:
return json.dumps(data, indent=2)
# ── Tool definitions ──────────────────────────────────────────────────────────
@app.list_tools()
async def list_tools() -> list[types.Tool]:
return [
types.Tool(
name="run_backtest",
description=(
"Run a complete MT5 backtest pipeline: compile → clean cache → "
"backtest → extract → analyze. Returns profit, DD%, Sharpe, monthly P/L, "
"and drawdown event reconstruction. Always compiles and clears cache unless "
"skip flags are set."
),
inputSchema={
"type": "object",
"required": ["expert"],
"properties": {
"expert": {
"type": "string",
"description": "EA name without path or extension. e.g. 'MyEA_v1.2'"
},
"symbol": {
"type": "string",
"description": "Trading symbol. Use your broker's exact name. e.g. 'XAUUSD'"
},
"from_date": {
"type": "string",
"description": "Start date in YYYY.MM.DD format"
},
"to_date": {
"type": "string",
"description": "End date in YYYY.MM.DD format"
},
"preset": {
"type": "string",
"enum": ["last_month", "last_3months", "ytd", "last_year"],
"description": "Date preset (alternative to from/to)"
},
"timeframe": {
"type": "string",
"enum": ["M1", "M5", "M15", "M30", "H1", "H4", "D1"],
"description": "Chart timeframe (default: M5)"
},
"deposit": {
"type": "number",
"description": "Initial deposit (default: from config)"
},
"model": {
"type": "integer",
"enum": [0, 1, 2],
"description": "0=every tick (default), 1=1min OHLC, 2=open price"
},
"set_file": {
"type": "string",
"description": "Path to .set parameter file"
},
"skip_compile": {
"type": "boolean",
"description": "Skip compilation (use existing .ex5)"
},
"skip_clean": {
"type": "boolean",
"description": "Skip cache clean (faster, risks stale results)"
},
"skip_analyze": {
"type": "boolean",
"description": "Extract only, skip deal analysis"
},
"deep": {
"type": "boolean",
"description": "Run deep analysis (grid + regime breakdown)"
},
"gui": {
"type": "boolean",
"description": "Show MT5 visual mode (chart animation). Default: false (headless). Use for debugging or demo."
},
"timeout": {
"type": "integer",
"description": "Backtest timeout in seconds (default: 900)"
},
"shutdown": {
"type": "boolean",
"description": "Close MT5 after backtest completes. Default: false — MT5 stays open and report is detected via file watching. Set true for CI/headless environments."
},
"kill_existing": {
"type": "boolean",
"description": "Kill a running MT5 instance before launching. Default: false — passes config to the running instance (MT5 single-instance passthrough). Set true if passthrough does not work on your Wine setup."
},
},
},
),
types.Tool(
name="run_optimization",
description=(
"Launch MT5 genetic parameter optimization as a detached background process. "
"Returns immediately — MT5 runs for 2-6 hours. "
"Always uses model=0 (every tick). Call get_optimization_results only after "
"user confirms MT5 has finished."
),
inputSchema={
"type": "object",
"required": ["expert", "set_file", "from_date", "to_date"],
"properties": {
"expert": {"type": "string"},
"set_file": {
"type": "string",
"description": "Path to optimization .set file with ||Y sweep flags"
},
"from_date": {"type": "string"},
"to_date": {"type": "string"},
"symbol": {"type": "string"},
"deposit": {"type": "number"},
},
},
),
types.Tool(
name="get_optimization_results",
description=(
"Parse completed MT5 optimization results. Call only after user signals "
"that MT5 optimization has finished. Returns top passes sorted by profit "
"with convergence analysis."
),
inputSchema={
"type": "object",
"properties": {
"job_id": {
"type": "string",
"description": "Job ID from run_optimization response"
},
"report_file": {
"type": "string",
"description": "Direct path to optimization.htm or .htm.xml"
},
"top_n": {
"type": "integer",
"description": "Number of top results to return (default: 20)"
},
"dd_threshold": {
"type": "number",
"description": "Flag results above this DD% as high-risk (default: 20)"
},
},
},
),
types.Tool(
name="analyze_report",
description=(
"Read and summarize a completed backtest report. Does not re-run MT5. "
"Returns monthly P/L, drawdown events, grid depth histogram, and top losses."
),
inputSchema={
"type": "object",
"properties": {
"report_dir": {
"type": "string",
"description": "Path to report directory. If omitted, uses latest."
},
},
},
),
types.Tool(
name="compare_baseline",
description=(
"Compare a backtest report against a baseline. Returns winner/loser verdict "
"and delta metrics. Baseline must include net_profit and max_dd_pct."
),
inputSchema={
"type": "object",
"required": ["baseline"],
"properties": {
"report_dir": {
"type": "string",
"description": "Report to evaluate. If omitted, uses latest."
},
"baseline": {
"type": "object",
"required": ["net_profit", "max_dd_pct"],
"properties": {
"net_profit": {"type": "number"},
"max_dd_pct": {"type": "number"},
"total_trades": {"type": "integer"},
"label": {"type": "string"},
},
},
"promote_dd_limit": {
"type": "number",
"description": "Auto-promote only if DD < this % (default: 20)"
},
},
},
),
types.Tool(
name="compile_ea",
description="Compile an MQL5 Expert Advisor via MetaEditor (Wine/CrossOver).",
inputSchema={
"type": "object",
"required": ["expert_path"],
"properties": {
"expert_path": {
"type": "string",
"description": "Path to .mq5 source file"
},
},
},
),
types.Tool(
name="verify_setup",
description=(
"Verify the MT5-Quant environment without launching MT5. "
"Checks Wine executable, MT5 installation paths, and config file. "
"Run this first if other tools return SETUP_REQUIRED errors."
),
inputSchema={"type": "object", "properties": {}},
),
types.Tool(
name="get_backtest_status",
description=(
"Check progress of a running or recently completed backtest pipeline. "
"Returns current stage (COMPILE/CLEAN/BACKTEST/EXTRACT/ANALYZE/DONE), "
"elapsed time, and whether the pipeline has finished."
),
inputSchema={
"type": "object",
"properties": {
"report_dir": {
"type": "string",
"description": "Report directory path. If omitted, uses latest.",
},
},
},
),
types.Tool(
name="get_optimization_status",
description=(
"Check whether a background optimization job is still running. "
"Returns process alive status, elapsed time, last 20 log lines, "
"and whether the report file has appeared (definitive completion signal)."
),
inputSchema={
"type": "object",
"required": ["job_id"],
"properties": {
"job_id": {
"type": "string",
"description": "Job ID returned by run_optimization.",
},
},
},
),
types.Tool(
name="prune_reports",
description=(
"Delete old backtest report directories, keeping only the N most recent. "
"Optimization result directories (_opt suffix) are never deleted."
),
inputSchema={
"type": "object",
"properties": {
"keep_last": {
"type": "integer",
"description": "Number of most recent reports to keep (default: 20).",
},
},
},
),
types.Tool(
name="list_reports",
description=(
"List all backtest report directories with compact key metrics "
"(profit, DD%, trades, date). Much cheaper than calling analyze_report "
"repeatedly. Use this to survey what runs exist before drilling in."
),
inputSchema={
"type": "object",
"properties": {
"include_opt": {
"type": "boolean",
"description": "Include optimization result dirs (_opt suffix). Default: false.",
},
"limit": {
"type": "integer",
"description": "Max reports to return, newest first (default: 30).",
},
},
},
),
types.Tool(
name="tail_log",
description=(
"Read the last N lines of a backtest progress log or optimization log. "
"Use filter='errors' to get only ERROR/WARN lines. Cheaper than "
"get_optimization_status when you only need log content."
),
inputSchema={
"type": "object",
"properties": {
"report_dir": {
"type": "string",
"description": "Backtest report dir (reads progress.log). Omit for latest.",
},
"job_id": {
"type": "string",
"description": "Optimization job ID (reads its log file).",
},
"log_file": {
"type": "string",
"description": "Absolute path to any log file.",
},
"n": {
"type": "integer",
"description": "Number of lines to return (default: 50).",
},
"filter": {
"type": "string",
"enum": ["all", "errors", "warnings"],
"description": "Line filter (default: all).",
},
},
},
),
types.Tool(
name="cache_status",
description=(
"Show MT5 tester cache size breakdown by symbol/timeframe directory. "
"Call before clean_cache to understand what will be deleted."
),
inputSchema={"type": "object", "properties": {}},
),
types.Tool(
name="clean_cache",
description=(
"Delete MT5 tester cache files to force fresh price data on next backtest. "
"Optionally target a specific symbol. Returns bytes freed."
),
inputSchema={
"type": "object",
"properties": {
"symbol": {
"type": "string",
"description": "Delete only cache for this symbol. Omit to delete all.",
},
"dry_run": {
"type": "boolean",
"description": "Report what would be deleted without deleting. Default: false.",
},
},
},
),
types.Tool(
name="read_set_file",
description=(
"Parse an MT5 .set parameter file (UTF-16LE or UTF-8) into structured JSON. "
"Returns each parameter with its value and optimization sweep config. "
"Use this instead of reading raw .set files."
),
inputSchema={
"type": "object",
"required": ["path"],
"properties": {
"path": {
"type": "string",
"description": "Path to .set file.",
},
},
},
),
types.Tool(
name="write_set_file",
description=(
"Write an MT5 .set parameter file in UTF-16LE encoding (required by MT5). "
"Accepts a dict of params. For optimization sweeps include from/to/step keys. "
"Existing file is overwritten and chmod 444 is applied."
),
inputSchema={
"type": "object",
"required": ["path", "params"],
"properties": {
"path": {
"type": "string",
"description": "Output path for .set file.",
},
"params": {
"type": "object",
"description": (
"Dict of param_name → value or dict with keys: "
"value, from, to, step, optimize (bool)."
),
},
},
},
),
types.Tool(
name="patch_set_file",
description=(
"Modify specific parameters in an existing .set file in-place. "
"Preserves all other params, comments, and sweep config. "
"Returns a diff of what changed. "
"Use instead of read_set_file → edit → write_set_file (saves 2 round-trips)."
),
inputSchema={
"type": "object",
"required": ["path", "patches"],
"properties": {
"path": {
"type": "string",
"description": "Path to the .set file to modify.",
},
"patches": {
"type": "object",
"description": (
"Params to update. Each key is a param name. "
"Value can be a scalar (just updates value) or a dict "
"with keys: value, from, to, step, optimize."
),
},
},
},
),
types.Tool(
name="clone_set_file",
description=(
"Copy a .set file to a new path, applying optional overrides. "
"One call instead of read → modify → write. "
"Useful for creating variant .set files from a base config."
),
inputSchema={
"type": "object",
"required": ["source", "destination"],
"properties": {
"source": {
"type": "string",
"description": "Path to source .set file.",
},
"destination": {
"type": "string",
"description": "Output path for the cloned .set file.",
},
"overrides": {
"type": "object",
"description": (
"Optional param overrides to apply in the clone. "
"Same format as patch_set_file patches."
),
},
},
},
),
types.Tool(
name="set_from_optimization",
description=(
"Generate a .set file directly from an optimization result's params dict. "
"Strips all sweep flags (||Y) to produce a clean backtest .set. "
"Optionally uses a template .set for params not in the optimization result. "
"Optionally re-adds sweep ranges to selected params for follow-on optimization. "
"Use immediately after get_optimization_results — params dict comes from results[0].params."
),
inputSchema={
"type": "object",
"required": ["path", "params"],
"properties": {
"path": {
"type": "string",
"description": "Output path for the generated .set file.",
},
"params": {
"type": "object",
"description": (
"Flat dict of param_name → value from optimization result. "
"e.g. {'TP_Pips': 400, 'Min_Confidence': 0.61}"
),
},
"template": {
"type": "string",
"description": (
"Optional path to an existing .set file. "
"Params not in 'params' are filled from the template as fixed values."
),
},
"sweep": {
"type": "object",
"description": (
"Optional: re-add sweep ranges to specific params after applying opt values. "
"Dict of param_name → {from, to, step, optimize: true}. "
"Use to create a narrowed follow-on optimization .set."
),
},
},
},
),
types.Tool(
name="diff_set_files",
description=(
"Compare two .set files and return only the differences: "
"params added, removed, or changed (value or sweep flag). "
"Use instead of reading both files and comparing manually."
),
inputSchema={
"type": "object",
"required": ["path_a", "path_b"],
"properties": {
"path_a": {"type": "string", "description": "First .set file (baseline/old)."},
"path_b": {"type": "string", "description": "Second .set file (candidate/new)."},
},
},
),
types.Tool(
name="describe_sweep",
description=(
"Show a .set file's sweep configuration: which params are swept, "
"their ranges, value counts, and total optimization combinations. "
"Use before run_optimization to verify scope."
),
inputSchema={
"type": "object",
"required": ["path"],
"properties": {
"path": {"type": "string", "description": "Path to .set file."},
},
},
),
types.Tool(
name="list_set_files",
description=(
"List all .set files in the MT5 tester profiles directory with "
"param counts, swept param counts, and total optimization combinations. "
"Use instead of reading each file individually to find the right .set."
),
inputSchema={
"type": "object",
"properties": {
"ea": {
"type": "string",
"description": "Filter by EA name substring (case-insensitive).",
},
},
},
),
types.Tool(
name="list_jobs",
description=(
"List all optimization jobs with compact status (alive/done/failed, elapsed). "
"Cheaper than calling get_optimization_status for each job individually."
),
inputSchema={
"type": "object",
"properties": {
"include_done": {
"type": "boolean",
"description": "Include completed jobs (default: true).",
},
},
},
),
types.Tool(
name="archive_report",
description=(
"Convert a backtest report directory into a compact JSON entry appended to "
"config/backtest_history.json. Captures all metrics, analysis summary, monthly P/L, "
"and worst DD event. Optionally deletes the source directory to reclaim disk space. "
"Skips if the report is already in history (idempotent)."
),
inputSchema={
"type": "object",
"properties": {
"report_dir": {
"type": "string",
"description": "Report directory to archive. If omitted, uses latest.",
},
"delete_after": {
"type": "boolean",
"description": "Delete source directory after archiving (default: false).",
},
"verdict": {
"type": "string",
"enum": ["winner", "loser", "marginal", "reference"],
"description": "Optional verdict to attach to the entry.",
},
"notes": {
"type": "string",
"description": "Free-text notes to attach to the entry.",
},
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "Tags to attach (e.g. ['tight-sl', 'new-entry-filter']).",
},
},
},
),
types.Tool(
name="archive_all_reports",
description=(
"Bulk-archive all backtest report directories into config/backtest_history.json, "
"then optionally delete the source directories. Skips dirs already in history. "
"Optimization dirs (_opt suffix) are never deleted. "
"Use this to clean up disk space while preserving all results as JSON."
),
inputSchema={
"type": "object",
"properties": {
"delete_after": {
"type": "boolean",
"description": "Delete source directories after archiving (default: false).",
},
"keep_last": {
"type": "integer",
"description": "Keep this many newest dirs even if delete_after=true (default: 5).",
},
"dry_run": {
"type": "boolean",
"description": "Report what would happen without making changes (default: false).",
},
},
},
),
types.Tool(
name="get_history",
description=(
"Query config/backtest_history.json with filters. Returns compact entries sorted "
"newest-first by default. Use this to compare past runs, find regressions, or "
"pick a candidate to promote to baseline."
),
inputSchema={
"type": "object",
"properties": {
"ea": {
"type": "string",
"description": "Filter by EA name (substring match).",
},
"symbol": {
"type": "string",
"description": "Filter by symbol (exact match).",
},
"verdict": {
"type": "string",
"enum": ["winner", "loser", "marginal", "reference"],
"description": "Filter by verdict.",
},
"tag": {
"type": "string",
"description": "Filter entries that contain this tag.",
},
"min_profit": {
"type": "number",
"description": "Filter entries with net_profit >= this value.",
},
"max_dd_pct": {
"type": "number",
"description": "Filter entries with max_dd_pct <= this value.",
},
"sort_by": {
"type": "string",
"enum": ["date", "profit", "dd", "sharpe"],
"description": "Sort order (default: date, newest first).",
},
"limit": {
"type": "integer",
"description": "Max entries to return (default: 20).",
},
"include_monthly": {
"type": "boolean",
"description": "Include monthly_pnl arrays (default: false, saves tokens).",
},
},
},
),
types.Tool(
name="promote_to_baseline",
description=(
"Promote a backtest result to config/baseline.json — the reference used by "
"compare_baseline and the Claude Code baseline hook. "
"Accepts a history entry id, a report_dir, or defaults to the latest report. "
"Also marks the history entry as promoted."
),
inputSchema={
"type": "object",
"properties": {
"history_id": {
"type": "string",
"description": "Entry id from get_history (report dir basename).",
},
"report_dir": {
"type": "string",
"description": "Direct path to report directory (alternative to history_id).",
},
"notes": {
"type": "string",
"description": "Notes written to baseline.json (e.g. 'v1.3 promoted after 3-month walk-forward').",
},
},
},
),
types.Tool(
name="annotate_history",
description=(
"Add or update notes, verdict, or tags on a history entry in "
"config/backtest_history.json. Use this after compare_baseline to record "
"the verdict, or to tag runs for later retrieval."
),
inputSchema={
"type": "object",
"required": ["history_id"],
"properties": {
"history_id": {
"type": "string",
"description": "Entry id (report dir basename) to update.",
},
"verdict": {
"type": "string",
"enum": ["winner", "loser", "marginal", "reference"],
},
"notes": {
"type": "string",
"description": "Free-text notes (replaces existing notes).",
},
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "Tags to set (replaces existing tags).",
},
"add_tags": {
"type": "array",
"items": {"type": "string"},
"description": "Tags to append without replacing existing ones.",
},
},
},
),
]
# ── Tool handlers ─────────────────────────────────────────────────────────────
@app.call_tool()
async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.TextContent]:
try:
if name == "run_backtest":
result = await handle_run_backtest(arguments)
elif name == "run_optimization":
result = await handle_run_optimization(arguments)
elif name == "get_optimization_results":
result = await handle_get_optimization_results(arguments)
elif name == "analyze_report":
result = await handle_analyze_report(arguments)
elif name == "compare_baseline":
result = await handle_compare_baseline(arguments)
elif name == "compile_ea":
result = await handle_compile_ea(arguments)
elif name == "verify_setup":
result = await handle_verify_setup(arguments)
elif name == "get_backtest_status":
result = await handle_get_backtest_status(arguments)
elif name == "get_optimization_status":
result = await handle_get_optimization_status(arguments)
elif name == "prune_reports":
result = await handle_prune_reports(arguments)
elif name == "list_reports":
result = await handle_list_reports(arguments)
elif name == "tail_log":
result = await handle_tail_log(arguments)
elif name == "cache_status":
result = await handle_cache_status(arguments)
elif name == "clean_cache":
result = await handle_clean_cache(arguments)
elif name == "read_set_file":
result = await handle_read_set_file(arguments)
elif name == "write_set_file":
result = await handle_write_set_file(arguments)
elif name == "patch_set_file":
result = await handle_patch_set_file(arguments)
elif name == "clone_set_file":
result = await handle_clone_set_file(arguments)
elif name == "set_from_optimization":
result = await handle_set_from_optimization(arguments)
elif name == "diff_set_files":
result = await handle_diff_set_files(arguments)
elif name == "describe_sweep":
result = await handle_describe_sweep(arguments)
elif name == "list_set_files":
result = await handle_list_set_files(arguments)
elif name == "list_jobs":
result = await handle_list_jobs(arguments)
elif name == "archive_report":
result = await handle_archive_report(arguments)
elif name == "archive_all_reports":
result = await handle_archive_all_reports(arguments)
elif name == "get_history":
result = await handle_get_history(arguments)
elif name == "promote_to_baseline":
result = await handle_promote_to_baseline(arguments)
elif name == "annotate_history":
result = await handle_annotate_history(arguments)
else:
result = {"error": f"Unknown tool: {name}"}
except Exception as e:
result = {"error": str(e), "success": False}
return [types.TextContent(type="text", text=format_result(result))]
async def handle_run_backtest(args: dict) -> dict:
env_error = _validate_environment()
if env_error:
return env_error
symbol = args.get('symbol') or cfg('backtest_symbol', 'XAUUSD')
symbol_warning, symbol_suggestions = _check_symbol(symbol)
cmd = [str(SCRIPTS_DIR / 'backtest_pipeline.sh')]
cmd += ['--expert', args['expert']]
project_dir = cfg('project_dir', '')
if project_dir:
cmd += ['--project-dir', project_dir]
if 'symbol' in args:
cmd += ['--symbol', args['symbol']]
if 'preset' in args:
cmd += ['--preset', args['preset']]
if 'from_date' in args:
cmd += ['--from', args['from_date']]
if 'to_date' in args:
cmd += ['--to', args['to_date']]
if 'timeframe' in args:
cmd += ['--timeframe', args['timeframe']]
if 'deposit' in args:
cmd += ['--deposit', str(args['deposit'])]
if 'model' in args:
cmd += ['--model', str(args['model'])]
if 'set_file' in args:
set_file = args['set_file']
# Resolve relative paths against project_dir (where the EA repo lives)
if project_dir and not os.path.isabs(set_file):
set_file = os.path.join(project_dir, set_file)
cmd += ['--set', set_file]
if args.get('skip_compile'):
cmd.append('--skip-compile')
if args.get('skip_clean'):
cmd.append('--skip-clean')
if args.get('skip_analyze'):
cmd.append('--skip-analyze')
if args.get('deep'):
cmd.append('--deep')
if args.get('gui'):
cmd.append('--gui')
if args.get('shutdown'):
cmd.append('--shutdown')
if args.get('kill_existing'):
cmd.append('--kill-existing')
timeout = args.get('timeout', 900)
success, output = run_script(cmd, timeout=timeout)
if not success:
return {'success': False, 'error': output[-2000:]} # last 2k chars
# Parse report dir from pipeline output (reliable, avoids stale REPORTS_DIR at startup)
report_dir = None
for line in output.splitlines():
if line.strip().startswith('Report:') or ' Report: ' in line:
parts = line.split('Report:', 1)
if len(parts) == 2:
candidate = parts[1].strip()
if os.path.isdir(candidate):
report_dir = candidate
break
if not report_dir:
report_dir = latest_report_dir()
if not report_dir:
return {'success': False, 'error': 'Pipeline completed but no report directory found'}
metrics = read_json(os.path.join(report_dir, 'metrics.json'))
analysis = read_json(os.path.join(report_dir, 'analysis.json'))
result = {
'success': True,
'report_dir': report_dir,
'metrics': metrics,
'analysis_summary': analysis.get('summary', {}),
'worst_dd_event': analysis.get('dd_events', [{}])[0] if analysis.get('dd_events') else None,
'monthly_pnl': analysis.get('monthly_pnl', []),
'grid_depth_histogram': analysis.get('grid_depth_histogram', {}),
'output': output[-1000:],
}
if symbol_warning:
result['symbol_warning'] = symbol_warning
result['symbol_suggestions'] = symbol_suggestions
return result
async def handle_run_optimization(args: dict) -> dict:
env_error = _validate_environment()
if env_error:
return env_error
cmd = [str(SCRIPTS_DIR / 'optimize.sh')]
cmd += ['--expert', args['expert']]
cmd += ['--set', args['set_file']]
cmd += ['--from', args['from_date']]
cmd += ['--to', args['to_date']]
if 'symbol' in args:
cmd += ['--symbol', args['symbol']]
if 'deposit' in args:
cmd += ['--deposit', str(args['deposit'])]
success, output = run_script(cmd, timeout=60) # script returns quickly (nohup)
# Extract job ID from output
import re
job_match = re.search(r'opt_\d{8}_\d{6}', output)
job_id = job_match.group(0) if job_match else None
return {
'success': success,
'job_id': job_id,
'message': 'Optimization launched in background. Do NOT poll. Signal me when MT5 completes.',
'output': output[-500:],
}
async def handle_get_optimization_results(args: dict) -> dict:
from analytics.optimize_parser import find_report as find_opt_report
# Locate report
report_path = None
if 'report_file' in args:
report_path = args['report_file']
elif 'job_id' in args:
try:
report_path = find_opt_report(args['job_id'])
except FileNotFoundError as e:
return {'success': False, 'error': str(e)}
if not report_path or not os.path.exists(report_path):
return {'success': False, 'error': 'Report not found. Is optimization still running?'}
fmt = opt_detect_format(report_path)
if fmt == 'xml':
raw = opt_parse_xml(report_path)
else:
raw = opt_parse_html(report_path)
results = normalize(raw)
results.sort(key=lambda r: r.get('net_profit', 0), reverse=True)
top_n = args.get('top_n', 20)
dd_threshold = args.get('dd_threshold', 20.0)
conv = convergence_analysis(results, top_n=10)
# Flag high-risk results
for r in results:
r['high_risk'] = r.get('max_dd_pct', 0) > dd_threshold
return {
'success': True,
'total_passes': len(results),
'results': results[:top_n],
'convergence': conv,
'recommendation': _opt_recommendation(results, dd_threshold),
}
def _opt_recommendation(results: list[dict], dd_threshold: float) -> dict:
safe = [r for r in results if r.get('max_dd_pct', 999) < dd_threshold]
if not safe:
return {
'verdict': 'all_high_risk',
'message': f'All top results exceed DD threshold ({dd_threshold}%). Widen parameter ranges or increase DD threshold.',
}
best = safe[0]
return {
'verdict': 'verify_model0' if best.get('model', 0) != 0 else 'promote_candidate',
'best_params': best.get('params', {}),
'net_profit': best.get('net_profit', 0),
'max_dd_pct': best.get('max_dd_pct', 0),
'message': f"Run verification backtest with these params before promoting.",
}
async def handle_analyze_report(args: dict) -> dict:
report_dir = args.get('report_dir') or latest_report_dir()
if not report_dir:
return {'success': False, 'error': 'No report directory found'}
metrics = read_json(os.path.join(report_dir, 'metrics.json'))
analysis = read_json(os.path.join(report_dir, 'analysis.json'))
if not metrics and not analysis:
# Try re-running analysis on deals.csv
deals_csv = os.path.join(report_dir, 'deals.csv')
if os.path.exists(deals_csv):
deals = load_deals(deals_csv)
monthly = monthly_pnl(deals)
dd_events = reconstruct_dd_events(deals, metrics)
analysis = {
'summary': build_summary(metrics, monthly, dd_events),
'monthly_pnl': monthly,
'dd_events': dd_events,
'grid_depth_histogram': grid_depth_histogram(deals),
'top_losses': top_losses(deals),
'loss_sequences': loss_sequences(deals),
}
else:
return {'success': False, 'error': f'No data found in {report_dir}'}
return {
'success': True,
'report_dir': report_dir,
'metrics': metrics,
**analysis,
}
async def handle_compare_baseline(args: dict) -> dict:
report_dir = args.get('report_dir') or latest_report_dir()
if not report_dir:
return {'success': False, 'error': 'No report directory found'}
metrics = read_json(os.path.join(report_dir, 'metrics.json'))
baseline = args['baseline']
dd_limit = args.get('promote_dd_limit', 20.0)
candidate_profit = metrics.get('net_profit', 0)
candidate_dd = metrics.get('max_dd_pct', 999)
baseline_profit = baseline['net_profit']
baseline_dd = baseline['max_dd_pct']
profit_delta = candidate_profit - baseline_profit
dd_delta = candidate_dd - baseline_dd
profit_pct = (profit_delta / baseline_profit * 100) if baseline_profit else 0
is_winner = candidate_profit > baseline_profit and candidate_dd < dd_limit
if is_winner:
verdict = 'winner'
elif candidate_profit > baseline_profit:
verdict = 'marginal' # Better profit but DD too high
else:
verdict = 'loser'
sign = '+' if profit_delta >= 0 else ''
dd_sign = '+' if dd_delta >= 0 else ''
return {
'success': True,
'verdict': verdict,
'auto_promote': is_winner,
'delta': {
'profit_usd': round(profit_delta, 2),
'profit_pct': round(profit_pct, 1),
'dd_pp': round(dd_delta, 2),
},
'summary': (
f"{sign}${profit_delta:,.2f} ({sign}{profit_pct:.1f}%) profit vs {baseline.get('label', 'baseline')}. "
f"DD: {candidate_dd:.2f}% vs {baseline_dd:.2f}% ({dd_sign}{dd_delta:.2f}pp). "
f"{'Auto-promoting.' if is_winner else 'Not promoting (DD too high).' if verdict == 'marginal' else 'Regression.'}"
),
'candidate': {
'net_profit': candidate_profit,
'max_dd_pct': candidate_dd,
'total_trades': metrics.get('total_trades', 0),
},
'baseline': baseline,
}
async def handle_compile_ea(args: dict) -> dict:
env_error = _validate_environment()
if env_error:
return env_error
expert_path = args['expert_path']
cmd = [str(SCRIPTS_DIR / 'mqlcompile.sh'), expert_path]
success, output = run_script(cmd, timeout=120)
return {
'success': success,
'output': output,
'expert_path': expert_path,
}
async def handle_verify_setup(args: dict) -> dict:
checks: dict = {}
all_ok = True
# Config file
config_path = ROOT_DIR / 'config' / 'mt5-quant.yaml'
checks['config_file'] = {
'ok': config_path.exists(),
'detail': str(config_path) if config_path.exists() else 'Not found — run: bash scripts/setup.sh',
}
if not config_path.exists():
all_ok = False
# Wine executable
wine = cfg('wine_executable')
if not wine:
checks['wine_executable'] = {'ok': False, 'detail': 'Not configured in mt5-quant.yaml'}
all_ok = False
else:
executable = os.access(wine, os.X_OK)
version = ''
if executable:
try:
r = subprocess.run([wine, '--version'], capture_output=True, text=True, timeout=5)
version = ((r.stdout or '') + (r.stderr or '')).strip().splitlines()[0]
except Exception as e:
version = f'error: {e}'
checks['wine_executable'] = {
'ok': executable,
'version': version,
'detail': wine if executable else f'Not executable: {wine}',
}
if not executable:
all_ok = False
# terminal_dir and derived paths
terminal_dir = cfg('terminal_dir')
if not terminal_dir:
checks['terminal_dir'] = {'ok': False, 'detail': 'Not configured in mt5-quant.yaml'}
all_ok = False
else:
td_ok = Path(terminal_dir).is_dir()
checks['terminal_dir'] = {
'ok': td_ok,
'detail': terminal_dir if td_ok else f'Directory not found: {terminal_dir}',
}
if not td_ok:
all_ok = False
terminal_exe = Path(terminal_dir) / 'terminal64.exe'
checks['terminal64_exe'] = {
'ok': terminal_exe.exists(),
'detail': str(terminal_exe) if terminal_exe.exists()
else 'Not found — launch MT5 once to unpack it',
}
experts_dir = Path(cfg('experts_dir') or os.path.join(terminal_dir, 'MQL5', 'Experts'))
ea_count = len(list(experts_dir.glob('*.ex5'))) if experts_dir.is_dir() else 0
checks['experts_dir'] = {
'ok': experts_dir.is_dir(),
'detail': f'{ea_count} .ex5 file(s)' if experts_dir.is_dir()
else f'Not found (will be created on first EA compile): {experts_dir}',
}
tester_dir = Path(cfg('tester_profiles_dir') or os.path.join(terminal_dir, 'MQL5', 'Profiles', 'Tester'))
set_count = len(list(tester_dir.glob('*.set'))) if tester_dir.is_dir() else 0
checks['tester_profiles_dir'] = {
'ok': tester_dir.is_dir(),
'detail': f'{set_count} .set file(s)' if tester_dir.is_dir()
else f'Not found (will be created on first backtest): {tester_dir}',
}
cache_dir = Path(cfg('tester_cache_dir') or os.path.join(terminal_dir, 'Tester'))
checks['tester_cache_dir'] = {
'ok': cache_dir.is_dir(),
'detail': str(cache_dir) if cache_dir.is_dir() else f'Not found: {cache_dir}',
}
return {
'all_ok': all_ok,
'checks': checks,
'hint': 'Run: bash scripts/setup.sh' if not all_ok else 'Environment looks good.',
}
async def handle_get_backtest_status(args: dict) -> dict:
report_dir = args.get('report_dir') or latest_report_dir()
if not report_dir:
return {'success': False, 'error': 'No report directory found'}
progress_log = Path(report_dir) / 'progress.log'
pipeline_meta = Path(report_dir) / 'pipeline_metadata.json'
stages = []
if progress_log.exists():
for line in progress_log.read_text().splitlines():
parts = line.split()
if len(parts) >= 3:
stages.append({'stage': parts[0], 'timestamp': parts[1], 'elapsed': parts[2]})
current_stage = stages[-1]['stage'] if stages else 'UNKNOWN'
finished = pipeline_meta.exists() or current_stage == 'DONE'
elapsed = None
if stages:
try:
elapsed = int(stages[-1]['elapsed'].replace('elapsed=', '').rstrip('s'))
except (ValueError, AttributeError):
pass
return {
'success': True,
'report_dir': report_dir,
'current_stage': current_stage,
'elapsed_seconds': elapsed,
'finished': finished,
'stages': stages,
}
async def handle_get_optimization_status(args: dict) -> dict:
job_id = args['job_id']
meta_path = ROOT_DIR / '.mt5mcp_jobs' / f'{job_id}.json'
if not meta_path.exists():
return {'success': False, 'error': f'Job not found: {job_id}. Check .mt5mcp_jobs/'}
with open(meta_path) as f:
meta = json.load(f)
pid = meta.get('pid')
log_file = meta.get('log_file', '')
wine_prefix = meta.get('wine_prefix', '')
started_at = meta.get('started_at', '')
# Check process alive via kill -0
alive = False
if pid:
try:
os.kill(int(pid), 0)
alive = True
except (OSError, ProcessLookupError):
alive = False
# Report file existence = definitive completion signal
report_found = False
report_path = None
if wine_prefix:
base = os.path.join(wine_prefix, 'drive_c', 'mt5mcp_opt_report')
for ext in ('.htm', '.htm.xml', '.html'):
candidate = base + ext
if os.path.exists(candidate):
report_found = True
report_path = candidate
break
# Tail log
log_tail: list[str] = []
if log_file and os.path.exists(log_file):
try:
log_tail = Path(log_file).read_text(errors='replace').splitlines()[-20:]
except Exception:
pass
# Elapsed time
elapsed_seconds = None
if started_at:
try:
from datetime import datetime, timezone
start_dt = datetime.fromisoformat(started_at.replace('Z', '+00:00'))
elapsed_seconds = int((datetime.now(timezone.utc) - start_dt).total_seconds())
except Exception:
pass
if report_found:
hint = f'Optimization complete. Call get_optimization_results with job_id="{job_id}".'
elif alive:
hint = f'Still running. Monitor: tail -f {log_file}'
else:
hint = f'Process not running and no report found. Check log: {log_file}'
return {
'success': True,
'job_id': job_id,
'alive': alive,
'finished': report_found,
'elapsed_seconds': elapsed_seconds,
'report_found': report_found,
'report_path': report_path,
'log_file': log_file,
'log_tail': log_tail,
'hint': hint,
}
async def handle_prune_reports(args: dict) -> dict:
keep_last = int(args.get('keep_last') or cfg('keep_last', '20') or 20)
REPORTS_DIR.mkdir(exist_ok=True)
all_dirs = sorted(
[d for d in REPORTS_DIR.iterdir() if d.is_dir() and not d.name.endswith('_opt')],
key=lambda d: d.stat().st_mtime,
)
to_delete = all_dirs[:-keep_last] if len(all_dirs) > keep_last else []
kept = all_dirs[-keep_last:] if len(all_dirs) > keep_last else all_dirs
deleted_names = []
for d in to_delete:
try:
shutil.rmtree(str(d))
deleted_names.append(d.name)
except Exception:
pass
return {
'success': True,
'deleted_count': len(deleted_names),
'kept_count': len(kept),
'deleted_dirs': deleted_names,
'kept_dirs': [d.name for d in kept],
}
async def handle_list_reports(args: dict) -> dict:
REPORTS_DIR.mkdir(exist_ok=True)
include_opt = args.get('include_opt', False)
limit = int(args.get('limit') or 30)
dirs = sorted(
[d for d in REPORTS_DIR.iterdir() if d.is_dir()],
key=lambda d: d.stat().st_mtime,
reverse=True,
)
if not include_opt:
dirs = [d for d in dirs if not d.name.endswith('_opt')]
dirs = dirs[:limit]
rows = []
for d in dirs:
m = read_json(str(d / 'metrics.json'))
row: dict = {'name': d.name, 'is_opt': d.name.endswith('_opt')}
if m:
row['net_profit'] = m.get('net_profit')
row['max_dd_pct'] = m.get('max_dd_pct')
row['total_trades'] = m.get('total_trades')
row['symbol'] = m.get('symbol')
row['timeframe'] = m.get('timeframe')
row['from_date'] = m.get('from_date') or m.get('testing_from')
row['to_date'] = m.get('to_date') or m.get('testing_to')
else:
row['metrics'] = 'missing'
rows.append(row)
return {'success': True, 'count': len(rows), 'reports': rows}
async def handle_tail_log(args: dict) -> dict:
n = int(args.get('n') or 50)
filt = args.get('filter', 'all')
log_path: str | None = args.get('log_file')
if not log_path and 'job_id' in args:
job_id = args['job_id']
meta_path = ROOT_DIR / '.mt5mcp_jobs' / f'{job_id}.json'
if not meta_path.exists():
return {'success': False, 'error': f'Job not found: {job_id}'}
with open(meta_path) as f:
meta = json.load(f)
log_path = meta.get('log_file', '')
if not log_path:
report_dir = args.get('report_dir') or latest_report_dir()
if report_dir:
log_path = str(Path(report_dir) / 'progress.log')
if not log_path or not os.path.exists(log_path):
return {'success': False, 'error': f'Log file not found: {log_path}'}
try:
lines = Path(log_path).read_text(errors='replace').splitlines()
except Exception as e:
return {'success': False, 'error': str(e)}
if filt == 'errors':
lines = [l for l in lines if 'error' in l.lower() or 'fail' in l.lower() or 'err:' in l.lower()]
elif filt == 'warnings':
lines = [l for l in lines if 'warn' in l.lower() or 'error' in l.lower()]
return {
'success': True,
'log_file': log_path,
'total_lines': len(lines),
'lines': lines[-n:],
}
def _dir_size(path: Path) -> int:
return sum(f.stat().st_size for f in path.rglob('*') if f.is_file())
async def handle_cache_status(args: dict) -> dict:
terminal_dir = cfg('terminal_dir')
if not terminal_dir:
return {'success': False, 'error': 'terminal_dir not configured'}
cache_dir = Path(cfg('tester_cache_dir') or os.path.join(terminal_dir, 'Tester'))
if not cache_dir.is_dir():
return {'success': False, 'error': f'Cache dir not found: {cache_dir}'}
total_bytes = 0
breakdown: list[dict] = []
for item in sorted(cache_dir.iterdir()):
if item.is_dir():
sz = _dir_size(item)
total_bytes += sz
breakdown.append({'symbol': item.name, 'size_mb': round(sz / 1024 / 1024, 2)})
elif item.is_file():
sz = item.stat().st_size
total_bytes += sz
return {
'success': True,
'cache_dir': str(cache_dir),
'total_size_mb': round(total_bytes / 1024 / 1024, 2),
'symbols': breakdown,
}
async def handle_clean_cache(args: dict) -> dict:
terminal_dir = cfg('terminal_dir')
if not terminal_dir:
return {'success': False, 'error': 'terminal_dir not configured'}
cache_dir = Path(cfg('tester_cache_dir') or os.path.join(terminal_dir, 'Tester'))
if not cache_dir.is_dir():
return {'success': False, 'error': f'Cache dir not found: {cache_dir}'}
symbol = args.get('symbol', '').strip()
dry_run = bool(args.get('dry_run', False))
targets: list[Path] = []
if symbol:
target = cache_dir / symbol
if target.is_dir():
targets.append(target)
else:
return {'success': False, 'error': f'No cache found for symbol: {symbol}'}
else:
targets = [d for d in cache_dir.iterdir() if d.is_dir()]
freed_bytes = sum(_dir_size(t) for t in targets)
names = [t.name for t in targets]
if not dry_run:
for t in targets:
shutil.rmtree(str(t))
return {
'success': True,
'dry_run': dry_run,
'deleted_symbols': names,
'freed_mb': round(freed_bytes / 1024 / 1024, 2),
'hint': 'Next backtest will regenerate tick data (slower first run).',
}
# ── .set file helpers ─────────────────────────────────────────────────────────
def _parse_set_line(line: str) -> tuple[str, dict] | None:
"""Parse one .set file line → (name, param_dict) or None."""
line = line.strip()
if not line or line.startswith(';') or '=' not in line:
return None
name, _, raw = line.partition('=')
name = name.strip()
parts = raw.split('||')
value = parts[0].strip()
param: dict = {'value': value}
if len(parts) >= 4:
param['from'] = parts[1].strip()
param['to'] = parts[2].strip()
param['step'] = parts[3].strip() if len(parts) > 3 else ''
param['optimize'] = parts[4].strip() == 'Y' if len(parts) > 4 else False
return name, param
def _decode_set(path: str) -> tuple[dict, list[str]]:
"""Load a .set file → (params, comments). Raises ValueError on decode failure."""
content = None
raw = Path(path).read_bytes()
for enc in ('utf-16-le', 'utf-16', 'utf-8-sig', 'utf-8'):
try:
if enc in ('utf-16-le', 'utf-16') and raw[:2] in (b'\xff\xfe', b'\xfe\xff'):
content = raw.decode('utf-16')
else:
content = raw.decode(enc)
break
except (UnicodeDecodeError, LookupError):
continue
if content is None:
raise ValueError(f'Cannot decode {path} — unknown encoding')
params: dict = {}
comments: list[str] = []
for line in content.splitlines():
if line.strip().startswith(';'):
comments.append(line.strip().lstrip(';').strip())
continue
result = _parse_set_line(line)
if result:
name, param = result
params[name] = param
return params, comments
def _encode_set(params: dict, comments: list[str] | None = None) -> bytes:
"""Serialize params (and optional header comments) to UTF-16LE bytes."""
lines: list[str] = []
if comments:
for c in comments:
lines.append(f'; {c}')
for name, spec in params.items():
if isinstance(spec, dict):
value = str(spec.get('value', ''))
if 'from' in spec:
flag = 'Y' if spec.get('optimize', False) else 'N'
lines.append(f"{name}={value}||{spec['from']}||{spec.get('to', value)}||{spec.get('step', '1')}||{flag}")
else:
lines.append(f"{name}={value}")
else:
lines.append(f"{name}={spec}")
return ('\r\n'.join(lines) + '\r\n').encode('utf-16-le')
def _write_set(path: str, data: bytes) -> None:
"""Write bytes to path and apply chmod 444 (required by MT5)."""
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
# chmod 644 first in case file already exists as 444
if p.exists():
os.chmod(path, 0o644)
p.write_bytes(data)
os.chmod(path, 0o444)
def _sweep_combinations(params: dict) -> tuple[list[dict], int]:
"""Return (swept_param_details, total_combinations) for a parsed params dict."""
import math
swept = []
total = 1
for name, spec in params.items():
if not isinstance(spec, dict) or not spec.get('optimize'):
continue
try:
f = float(spec['from'])
t = float(spec['to'])
s = float(spec['step'])
count = max(1, math.floor(abs(t - f) / s) + 1) if s else 1
except (KeyError, ValueError, ZeroDivisionError):
count = 1
swept.append({
'name': name,
'from': spec.get('from'),
'to': spec.get('to'),
'step': spec.get('step'),
'count': count,
})
total *= count
return swept, total
async def handle_read_set_file(args: dict) -> dict:
path = args['path']
if not os.path.exists(path):
return {'success': False, 'error': f'File not found: {path}'}
try:
params, comments = _decode_set(path)
except Exception as e:
return {'success': False, 'error': str(e)}
return {
'success': True,
'path': path,
'param_count': len(params),
'comments': comments,
'params': params,
}
async def handle_write_set_file(args: dict) -> dict:
path = args['path']
params: dict = args['params']
try:
_write_set(path, _encode_set(params))
except Exception as e:
return {'success': False, 'error': str(e)}
return {
'success': True,
'path': path,
'param_count': len(params),
'encoding': 'utf-16-le',
'permissions': '444 (read-only, required by MT5)',
}
async def handle_patch_set_file(args: dict) -> dict:
path = args['path']
patches: dict = args['patches']
if not os.path.exists(path):
return {'success': False, 'error': f'File not found: {path}'}
try:
params, comments = _decode_set(path)
except Exception as e:
return {'success': False, 'error': str(e)}
changed: list[dict] = []
for name, new_spec in patches.items():
old = params.get(name, {})
old_value = old.get('value') if isinstance(old, dict) else str(old)
if isinstance(new_spec, dict):
# Merge: keep existing sweep config unless overridden
merged = dict(old) if isinstance(old, dict) else {'value': old_value}
merged.update(new_spec)
params[name] = merged
new_value = str(merged.get('value', ''))
else:
new_value = str(new_spec)
if isinstance(params.get(name), dict):
params[name] = dict(params[name])
params[name]['value'] = new_value
else:
params[name] = {'value': new_value}
if old_value != new_value:
changed.append({'name': name, 'old': old_value, 'new': new_value})
try:
_write_set(path, _encode_set(params, comments))
except Exception as e:
return {'success': False, 'error': str(e)}
return {
'success': True,
'path': path,
'changed_count': len(changed),
'changed': changed,
'param_count': len(params),
}
async def handle_clone_set_file(args: dict) -> dict:
source = args['source']
destination = args['destination']
overrides: dict = args.get('overrides', {})
if not os.path.exists(source):
return {'success': False, 'error': f'Source not found: {source}'}
try:
params, comments = _decode_set(source)
except Exception as e:
return {'success': False, 'error': str(e)}
changed: list[dict] = []
for name, new_spec in overrides.items():
old = params.get(name, {})
old_value = old.get('value') if isinstance(old, dict) else str(old) if old else None
if isinstance(new_spec, dict):
merged = dict(old) if isinstance(old, dict) else {}
merged.update(new_spec)
params[name] = merged
new_value = str(merged.get('value', ''))
else:
new_value = str(new_spec)
if isinstance(params.get(name), dict):
params[name] = dict(params[name])
params[name]['value'] = new_value
else:
params[name] = {'value': new_value}
if old_value != new_value:
changed.append({'name': name, 'old': old_value, 'new': new_value})
try:
_write_set(destination, _encode_set(params, comments))
except Exception as e:
return {'success': False, 'error': str(e)}
return {
'success': True,
'source': source,
'destination': destination,
'param_count': len(params),
'overridden_count': len(changed),
'overridden': changed,
}
async def handle_set_from_optimization(args: dict) -> dict:
path = args['path']
opt_params: dict = args['params'] # {name: value} from optimization result
template: str | None = args.get('template')
sweep: dict = args.get('sweep', {}) # {name: {from, to, step}} to add sweep flags
base_params: dict = {}
base_comments: list[str] = []
if template:
if not os.path.exists(template):
return {'success': False, 'error': f'Template not found: {template}'}
try:
base_params, base_comments = _decode_set(template)
except Exception as e:
return {'success': False, 'error': str(e)}
# Start from template (or empty), apply opt values, strip all sweep flags
merged: dict = {}
for name, spec in base_params.items():
# Copy as fixed value (no sweep)
value = spec.get('value') if isinstance(spec, dict) else str(spec)
merged[name] = {'value': value}
# Apply optimization result values (overwrite template values, add new params)
for name, value in opt_params.items():
merged[name] = {'value': str(value)}
# Optionally re-add sweep ranges for a subset of params
for name, sweep_spec in sweep.items():
if name in merged:
merged[name].update({
'from': str(sweep_spec.get('from', '')),
'to': str(sweep_spec.get('to', '')),
'step': str(sweep_spec.get('step', '1')),
'optimize': bool(sweep_spec.get('optimize', True)),
})
try:
_write_set(path, _encode_set(merged, base_comments))
except Exception as e:
return {'success': False, 'error': str(e)}
swept, total = _sweep_combinations(merged)
return {
'success': True,
'path': path,
'param_count': len(merged),
'from_template': bool(template),
'opt_params_applied': len(opt_params),
'swept_params': len(swept),
'total_combinations': total if swept else 0,
}
async def handle_diff_set_files(args: dict) -> dict:
path_a = args['path_a']
path_b = args['path_b']
for p in (path_a, path_b):
if not os.path.exists(p):
return {'success': False, 'error': f'File not found: {p}'}
try:
params_a, _ = _decode_set(path_a)
params_b, _ = _decode_set(path_b)
except Exception as e:
return {'success': False, 'error': str(e)}
keys_a = set(params_a)
keys_b = set(params_b)
added = []
for k in sorted(keys_b - keys_a):
spec = params_b[k]
added.append({'name': k, 'value': spec.get('value') if isinstance(spec, dict) else str(spec)})
removed = []
for k in sorted(keys_a - keys_b):
spec = params_a[k]
removed.append({'name': k, 'value': spec.get('value') if isinstance(spec, dict) else str(spec)})
changed = []
for k in sorted(keys_a & keys_b):
sa = params_a[k]
sb = params_b[k]
va = sa.get('value') if isinstance(sa, dict) else str(sa)
vb = sb.get('value') if isinstance(sb, dict) else str(sb)
opt_a = sa.get('optimize', False) if isinstance(sa, dict) else False
opt_b = sb.get('optimize', False) if isinstance(sb, dict) else False
if va != vb or opt_a != opt_b:
entry: dict = {'name': k, 'a': va, 'b': vb}
if opt_a != opt_b:
entry['sweep_a'] = opt_a
entry['sweep_b'] = opt_b
changed.append(entry)
identical = not added and not removed and not changed
return {
'success': True,
'path_a': path_a,
'path_b': path_b,
'identical': identical,
'added_count': len(added),
'removed_count': len(removed),
'changed_count': len(changed),
'added': added,
'removed': removed,
'changed': changed,
}
async def handle_describe_sweep(args: dict) -> dict:
path = args['path']
if not os.path.exists(path):
return {'success': False, 'error': f'File not found: {path}'}
try:
params, comments = _decode_set(path)
except Exception as e:
return {'success': False, 'error': str(e)}
swept, total = _sweep_combinations(params)
fixed_count = len(params) - len(swept)
return {
'success': True,
'path': path,
'total_params': len(params),
'swept_count': len(swept),
'fixed_count': fixed_count,
'total_combinations': total,
'swept_params': swept,
'hint': (
'No swept params — this is a backtest .set, not an optimization .set.'
if not swept else
f'{total:,} combinations. Typical range: 18h depending on EA tick speed.'
),
}
async def handle_list_set_files(args: dict) -> dict:
terminal_dir = cfg('terminal_dir')
if not terminal_dir:
return {'success': False, 'error': 'terminal_dir not configured'}
profiles_dir = Path(cfg('tester_profiles_dir') or
os.path.join(terminal_dir, 'MQL5', 'Profiles', 'Tester'))
if not profiles_dir.is_dir():
return {'success': False, 'error': f'Tester profiles dir not found: {profiles_dir}'}
ea_filter = args.get('ea', '').lower()
rows: list[dict] = []
for f in sorted(profiles_dir.glob('*.set'), key=lambda x: x.stat().st_mtime, reverse=True):
if ea_filter and ea_filter not in f.stem.lower():
continue
try:
params, _ = _decode_set(str(f))
swept, total = _sweep_combinations(params)
rows.append({
'name': f.name,
'param_count': len(params),
'swept_count': len(swept),
'total_combinations': total if swept else 0,
'modified': f.stat().st_mtime,
})
except Exception:
rows.append({'name': f.name, 'error': 'unreadable'})
# Convert mtime to ISO for readability
from datetime import datetime
for r in rows:
if 'modified' in r:
r['modified'] = datetime.fromtimestamp(r['modified']).strftime('%Y-%m-%d %H:%M')
return {
'success': True,
'profiles_dir': str(profiles_dir),
'count': len(rows),
'files': rows,
}
async def handle_archive_report(args: dict) -> dict:
report_dir = args.get('report_dir') or latest_report_dir()
if not report_dir:
return {'success': False, 'error': 'No report directory found'}
entry = _build_history_entry(report_dir)
if not entry:
return {'success': False, 'error': f'No metrics.json or analysis.json in {report_dir}'}
if args.get('verdict'):
entry['verdict'] = args['verdict']
if args.get('notes'):
entry['notes'] = args['notes']
if args.get('tags'):
entry['tags'] = args['tags']
history = load_history()
existing_ids = {e['id'] for e in history}
already_exists = entry['id'] in existing_ids
if not already_exists:
history.append(entry)
save_history(history)
deleted = False
if args.get('delete_after') and not already_exists:
try:
shutil.rmtree(report_dir)
deleted = True
# Update the entry in history to reflect deletion
for e in history:
if e['id'] == entry['id']:
e['report_dir_deleted'] = True
break
save_history(history)
except Exception as exc:
return {'success': False, 'error': f'Archive succeeded but delete failed: {exc}'}
return {
'success': True,
'id': entry['id'],
'already_existed': already_exists,
'deleted_source': deleted,
'history_file': str(HISTORY_FILE),
'entry_summary': {
'ea': entry['ea'],
'symbol': entry['symbol'],
'metrics': entry['metrics'],
'verdict': entry['verdict'],
},
}
async def handle_archive_all_reports(args: dict) -> dict:
REPORTS_DIR.mkdir(exist_ok=True)
delete_after = bool(args.get('delete_after', False))
keep_last = int(args.get('keep_last', 5))
dry_run = bool(args.get('dry_run', False))
all_dirs = sorted(
[d for d in REPORTS_DIR.iterdir() if d.is_dir() and not d.name.endswith('_opt')],
key=lambda d: d.stat().st_mtime,
)
history = load_history()
existing_ids = {e['id'] for e in history}
# Dirs protected from deletion regardless of keep_last
protected = {d.name for d in all_dirs[-keep_last:]} if keep_last > 0 else set()
results = {'archived': [], 'skipped': [], 'deleted': [], 'failed': []}
for d in all_dirs:
if d.name in existing_ids:
results['skipped'].append(d.name)
continue
entry = _build_history_entry(str(d))
if not entry:
results['failed'].append(d.name)
continue
if not dry_run:
history.append(entry)
results['archived'].append(d.name)
should_delete = delete_after and d.name not in protected
if should_delete and not dry_run:
try:
shutil.rmtree(str(d))
entry['report_dir_deleted'] = True
results['deleted'].append(d.name)
except Exception:
results['failed'].append(d.name)
if not dry_run and results['archived']:
save_history(history)
return {
'success': True,
'dry_run': dry_run,
'archived_count': len(results['archived']),
'skipped_count': len(results['skipped']),
'deleted_count': len(results['deleted']),
'failed_count': len(results['failed']),
'history_file': str(HISTORY_FILE),
**results,
}
async def handle_get_history(args: dict) -> dict:
history = load_history()
if not history:
return {'success': True, 'count': 0, 'entries': []}
ea_filter = args.get('ea', '').lower()
symbol_filter = args.get('symbol', '').upper()
verdict_filter = args.get('verdict')
tag_filter = args.get('tag', '')
min_profit = args.get('min_profit')
max_dd = args.get('max_dd_pct')
sort_by = args.get('sort_by', 'date')
limit = int(args.get('limit') or 20)
include_monthly = bool(args.get('include_monthly', False))
filtered = []
for e in history:
if ea_filter and ea_filter not in e.get('ea', '').lower():
continue
if symbol_filter and e.get('symbol', '').upper() != symbol_filter:
continue
if verdict_filter and e.get('verdict') != verdict_filter:
continue
if tag_filter and tag_filter not in e.get('tags', []):
continue
m = e.get('metrics', {})
if min_profit is not None and (m.get('net_profit') or 0) < min_profit:
continue
if max_dd is not None and (m.get('max_dd_pct') or 999) > max_dd:
continue
filtered.append(e)
key_map = {
'date': lambda e: e.get('archived_at', ''),
'profit': lambda e: (e.get('metrics') or {}).get('net_profit') or 0,
'dd': lambda e: (e.get('metrics') or {}).get('max_dd_pct') or 999,
'sharpe': lambda e: (e.get('metrics') or {}).get('sharpe_ratio') or 0,
}
reverse = sort_by != 'dd'
filtered.sort(key=key_map.get(sort_by, key_map['date']), reverse=reverse)
filtered = filtered[:limit]
if not include_monthly:
for e in filtered:
e.pop('monthly_pnl', None)
return {'success': True, 'count': len(filtered), 'entries': filtered}
async def handle_promote_to_baseline(args: dict) -> dict:
from datetime import datetime, timezone
# Resolve source: history entry, explicit report_dir, or latest
entry: dict | None = None
report_dir: str | None = None
if 'history_id' in args:
history = load_history()
matches = [e for e in history if e['id'] == args['history_id']]
if not matches:
return {'success': False, 'error': f"History entry not found: {args['history_id']}"}
entry = matches[0]
report_dir = entry.get('report_dir') if not entry.get('report_dir_deleted') else None
else:
report_dir = args.get('report_dir') or latest_report_dir()
if not report_dir:
return {'success': False, 'error': 'No report directory found'}
# Load metrics — prefer live report dir, fall back to history entry
if report_dir and Path(report_dir).is_dir():
metrics = read_json(os.path.join(report_dir, 'metrics.json'))
elif entry:
metrics = entry.get('metrics', {})
else:
return {'success': False, 'error': 'Source not found (report dir missing and no history entry)'}
if not metrics:
return {'success': False, 'error': 'No metrics found in source'}
now = datetime.now(timezone.utc).strftime('%Y-%m-%d')
ea = (entry or {}).get('ea') or metrics.get('expert') or metrics.get('ea') or ''
symbol = (entry or {}).get('symbol') or metrics.get('symbol') or ''
from_date = (entry or {}).get('from_date') or ''
to_date = (entry or {}).get('to_date') or ''
period = f"{from_date}/{to_date}" if from_date and to_date else ''
baseline = {
'ea': ea,
'symbol': symbol,
'period': period,
'net_profit': metrics.get('net_profit'),
'profit_factor': metrics.get('profit_factor'),
'max_drawdown_pct': metrics.get('max_dd_pct'),
'sharpe_ratio': metrics.get('sharpe_ratio'),
'total_trades': metrics.get('total_trades'),
'recovery_factor': metrics.get('recovery_factor'),
'promoted_from': (entry or {}).get('id') or Path(report_dir or '').name,
'promoted_at': now,
'notes': args.get('notes', f'Promoted {now}'),
}
BASELINE_FILE.parent.mkdir(exist_ok=True)
with open(BASELINE_FILE, 'w') as f:
json.dump(baseline, f, indent=2)
# Mark in history
if entry:
history = load_history()
for e in history:
if e['id'] == entry['id']:
e['promoted_to_baseline'] = True
e['verdict'] = e.get('verdict') or 'reference'
break
save_history(history)
return {
'success': True,
'baseline_file': str(BASELINE_FILE),
'baseline': baseline,
}
async def handle_annotate_history(args: dict) -> dict:
history_id = args['history_id']
history = load_history()
target = next((e for e in history if e['id'] == history_id), None)
if not target:
return {'success': False, 'error': f'Entry not found: {history_id}'}
if 'verdict' in args:
target['verdict'] = args['verdict']
if 'notes' in args:
target['notes'] = args['notes']
if 'tags' in args:
target['tags'] = args['tags']
if 'add_tags' in args:
existing = target.get('tags') or []
for t in args['add_tags']:
if t not in existing:
existing.append(t)
target['tags'] = existing
save_history(history)
return {
'success': True,
'id': history_id,
'verdict': target.get('verdict'),
'notes': target.get('notes'),
'tags': target.get('tags'),
}
async def handle_list_jobs(args: dict) -> dict:
jobs_dir = ROOT_DIR / '.mt5mcp_jobs'
if not jobs_dir.is_dir():
return {'success': True, 'jobs': [], 'count': 0}
include_done = args.get('include_done', True)
rows: list[dict] = []
from datetime import datetime, timezone
for meta_file in sorted(jobs_dir.glob('*.json'), reverse=True):
try:
with open(meta_file) as f:
meta = json.load(f)
except Exception:
continue
job_id = meta_file.stem
pid = meta.get('pid')
started_at = meta.get('started_at', '')
log_file = meta.get('log_file', '')
wine_prefix = meta.get('wine_prefix', '')
alive = False
if pid:
try:
os.kill(int(pid), 0)
alive = True
except (OSError, ProcessLookupError):
pass
report_found = False
if wine_prefix:
base = os.path.join(wine_prefix, 'drive_c', 'mt5mcp_opt_report')
for ext in ('.htm', '.htm.xml', '.html'):
if os.path.exists(base + ext):
report_found = True
break
status = 'running' if alive else ('done' if report_found else 'failed')
elapsed_seconds = None
if started_at:
try:
start_dt = datetime.fromisoformat(started_at.replace('Z', '+00:00'))
elapsed_seconds = int((datetime.now(timezone.utc) - start_dt).total_seconds())
except Exception:
pass
if not include_done and status != 'running':
continue
rows.append({
'job_id': job_id,
'status': status,
'elapsed_seconds': elapsed_seconds,
'expert': meta.get('expert', ''),
'started_at': started_at,
'log_file': log_file,
})
return {'success': True, 'count': len(rows), 'jobs': rows}
# ── Entry point ───────────────────────────────────────────────────────────────
async def main():
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
await app.run(
read_stream,
write_stream,
app.create_initialization_options(),
)
def cli():
"""Sync entry point for [project.scripts] — pyproject.toml requires a sync callable."""
asyncio.run(main())
if __name__ == '__main__':
asyncio.run(main())