feat: MT5-Quant MCP server for backtesting and optimization
MCP server exposing MetaTrader 5 strategy development tools to AI assistants (Claude, Cursor, etc.) on macOS (CrossOver) and Linux (Wine). Tools: - run_backtest: full pipeline — compile EA, clean cache, backtest, parse HTML/XML report, analyze deals → metrics.json + analysis.json - run_optimization: background genetic optimization with nohup/disown, UTF-16LE .set file handling, OptMode reset - compile_ea: MQL5 compilation via MetaEditor with auto-detected include/ directory sync - get_backtest_status / get_optimization_status: job polling - verify_environment: Wine/MT5 path validation Analytics: - extract.py: MT5 HTML and SpreadsheetML XML report parser - analyze.py: deal-level analysis (drawdown events, grid depth, loss sequences, monthly P&L) → analysis.json - optimize_parser.py: optimization result parser with convergence analysis Platform support: - macOS CrossOver (GUI mode, no Xvfb needed) - Linux Wine + Xvfb (headless, CI/CD compatible) - Auto-detection of Wine executable and MT5 terminal paths
This commit is contained in:
Executable
+453
@@ -0,0 +1,453 @@
|
||||
#!/usr/bin/env bash
|
||||
# backtest_pipeline.sh — 5-stage MT5 backtest pipeline
|
||||
# Stages: COMPILE → CLEAN → BACKTEST → EXTRACT → ANALYZE
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/backtest_pipeline.sh [options]
|
||||
#
|
||||
# Options:
|
||||
# --expert NAME EA name (without path or .mq5 extension)
|
||||
# --symbol SYMBOL Trading symbol (default: from config)
|
||||
# --from YYYY.MM.DD Start date
|
||||
# --to YYYY.MM.DD End date
|
||||
# --preset PRESET last_month | last_3months | ytd | last_year
|
||||
# --timeframe TF M1 M5 M15 M30 H1 H4 D1 (default: M5)
|
||||
# --deposit AMOUNT Initial deposit (default: from config)
|
||||
# --model 0|1|2 Tick model (default: 0=every tick)
|
||||
# --set FILE Path to .set parameter file
|
||||
# --leverage N Leverage (default: 500)
|
||||
# --skip-compile Skip compilation stage
|
||||
# --skip-clean Skip cache clean stage
|
||||
# --skip-analyze Skip analysis stage (extract only)
|
||||
# --deep Run deep analysis (hourly + volume profile)
|
||||
# --strategy NAME Analysis strategy profile: grid (default) | scalper | trend | hedge | generic
|
||||
# --timeout N Backtest timeout in seconds (default: 900)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# Resolve real physical path (follows symlinks) so analytics/ is found even when
|
||||
# scripts/ is a symlink (e.g. ~/.config/mt5-quant/scripts -> /path/to/mt5-quant/scripts)
|
||||
REAL_SCRIPT_DIR="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "${REAL_SCRIPT_DIR}/.." && pwd)"
|
||||
source "${SCRIPT_DIR}/platform_detect.sh"
|
||||
|
||||
# ── Defaults from config ──────────────────────────────────────────────────────
|
||||
DEFAULT_SYMBOL=$(_cfg "backtest_symbol" "XAUUSD")
|
||||
DEFAULT_DEPOSIT=$(_cfg "backtest_deposit" "10000")
|
||||
DEFAULT_CURRENCY=$(_cfg "backtest_currency" "USD")
|
||||
DEFAULT_LEVERAGE=$(_cfg "backtest_leverage" "500")
|
||||
DEFAULT_MODEL=$(_cfg "backtest_model" "0")
|
||||
DEFAULT_TF=$(_cfg "backtest_timeframe" "M5")
|
||||
DEFAULT_TIMEOUT=$(_cfg "backtest_timeout" "900")
|
||||
REPORTS_DIR="$(_cfg "reports_dir" "${ROOT_DIR}/reports")"
|
||||
# Optional: force headless terminal to a specific broker account (needed when live
|
||||
# trading terminal uses a different broker than the backtest symbol requires).
|
||||
DEFAULT_LOGIN=$(_cfg "backtest_login" "")
|
||||
DEFAULT_SERVER=$(_cfg "backtest_server" "")
|
||||
|
||||
# ── Parse arguments ───────────────────────────────────────────────────────────
|
||||
EXPERT=""
|
||||
SYMBOL="$DEFAULT_SYMBOL"
|
||||
FROM_DATE=""
|
||||
TO_DATE=""
|
||||
PRESET=""
|
||||
TIMEFRAME="$DEFAULT_TF"
|
||||
DEPOSIT="$DEFAULT_DEPOSIT"
|
||||
CURRENCY="$DEFAULT_CURRENCY"
|
||||
MODEL="$DEFAULT_MODEL"
|
||||
SET_FILE=""
|
||||
LEVERAGE="$DEFAULT_LEVERAGE"
|
||||
SKIP_COMPILE=false
|
||||
SKIP_CLEAN=false
|
||||
SKIP_ANALYZE=false
|
||||
DEEP_ANALYZE=false
|
||||
STRATEGY="grid"
|
||||
TIMEOUT="$DEFAULT_TIMEOUT"
|
||||
PROJECT_DIR="$(_cfg "project_dir" "")"
|
||||
GUI_MODE=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--expert) EXPERT="$2"; shift 2 ;;
|
||||
--project-dir) PROJECT_DIR="$2"; shift 2 ;;
|
||||
--gui) GUI_MODE=true; shift ;;
|
||||
--symbol) SYMBOL="$2"; shift 2 ;;
|
||||
--from) FROM_DATE="$2"; shift 2 ;;
|
||||
--to) TO_DATE="$2"; shift 2 ;;
|
||||
--preset) PRESET="$2"; shift 2 ;;
|
||||
--timeframe) TIMEFRAME="$2"; shift 2 ;;
|
||||
--deposit) DEPOSIT="$2"; shift 2 ;;
|
||||
--model) MODEL="$2"; shift 2 ;;
|
||||
--set) SET_FILE="$2"; shift 2 ;;
|
||||
--leverage) LEVERAGE="$2"; shift 2 ;;
|
||||
--timeout) TIMEOUT="$2"; shift 2 ;;
|
||||
--skip-compile) SKIP_COMPILE=true; shift ;;
|
||||
--skip-clean) SKIP_CLEAN=true; shift ;;
|
||||
--skip-analyze) SKIP_ANALYZE=true; shift ;;
|
||||
--deep) DEEP_ANALYZE=true; shift ;;
|
||||
--strategy) STRATEGY="$2"; shift 2 ;;
|
||||
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$EXPERT" ]] && { echo "ERROR: --expert is required" >&2; exit 1; }
|
||||
|
||||
# ── Preset date resolution ────────────────────────────────────────────────────
|
||||
if [[ -n "$PRESET" ]]; then
|
||||
TODAY=$(date +%Y.%m.%d)
|
||||
case "$PRESET" in
|
||||
last_month)
|
||||
FROM_DATE=$(date -d "1 month ago" +%Y.%m.01 2>/dev/null || \
|
||||
date -v-1m +%Y.%m.01)
|
||||
TO_DATE="$TODAY"
|
||||
;;
|
||||
last_3months)
|
||||
FROM_DATE=$(date -d "3 months ago" +%Y.%m.01 2>/dev/null || \
|
||||
date -v-3m +%Y.%m.01)
|
||||
TO_DATE="$TODAY"
|
||||
;;
|
||||
ytd)
|
||||
FROM_DATE=$(date +%Y.01.01)
|
||||
TO_DATE="$TODAY"
|
||||
;;
|
||||
last_year)
|
||||
PREV_YEAR=$(( $(date +%Y) - 1 ))
|
||||
FROM_DATE="${PREV_YEAR}.01.01"
|
||||
TO_DATE="${PREV_YEAR}.12.31"
|
||||
;;
|
||||
*) echo "ERROR: Unknown preset: $PRESET" >&2; exit 1 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
[[ -z "$FROM_DATE" || -z "$TO_DATE" ]] && {
|
||||
echo "ERROR: Provide --from/--to dates or --preset" >&2; exit 1
|
||||
}
|
||||
|
||||
# ── Report directory ──────────────────────────────────────────────────────────
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
REPORT_ID="${TIMESTAMP}_${EXPERT}_${SYMBOL}_${TIMEFRAME}"
|
||||
REPORT_DIR="${REPORTS_DIR}/${REPORT_ID}"
|
||||
mkdir -p "$REPORT_DIR"
|
||||
|
||||
PIPELINE_START=$(date +%s)
|
||||
PROGRESS_LOG="${REPORT_DIR}/progress.log"
|
||||
_progress() { echo "$1 $(date -u +%Y-%m-%dT%H:%M:%SZ) elapsed=$(( $(date +%s) - PIPELINE_START ))" >> "$PROGRESS_LOG"; }
|
||||
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo " MT5-Quant Backtest Pipeline"
|
||||
echo " Expert: $EXPERT"
|
||||
echo " Symbol: $SYMBOL Timeframe: $TIMEFRAME Model: $MODEL"
|
||||
echo " Period: $FROM_DATE → $TO_DATE"
|
||||
echo " Deposit: $CURRENCY $DEPOSIT Leverage: 1:$LEVERAGE"
|
||||
[[ -n "$SET_FILE" ]] && echo " Set file: $SET_FILE"
|
||||
echo " Report: $REPORT_DIR"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
# ── Resolve platform ──────────────────────────────────────────────────────────
|
||||
resolve_platform
|
||||
|
||||
# ── Stage 1: COMPILE ──────────────────────────────────────────────────────────
|
||||
if [[ "$SKIP_COMPILE" == false ]]; then
|
||||
_progress "COMPILE"
|
||||
echo ""
|
||||
echo "[1/5] COMPILE"
|
||||
|
||||
# Find source file — check project.dir first, then fall back to pipeline root
|
||||
EA_SOURCE=""
|
||||
search_paths=(
|
||||
"${ROOT_DIR}/src/experts/${EXPERT}.mq5"
|
||||
"${ROOT_DIR}/src/${EXPERT}.mq5"
|
||||
"${ROOT_DIR}/${EXPERT}.mq5"
|
||||
)
|
||||
if [[ -n "$PROJECT_DIR" ]]; then
|
||||
search_paths=(
|
||||
"${PROJECT_DIR}/src/experts/${EXPERT}.mq5"
|
||||
"${PROJECT_DIR}/src/${EXPERT}.mq5"
|
||||
"${PROJECT_DIR}/${EXPERT}.mq5"
|
||||
"${search_paths[@]}"
|
||||
)
|
||||
fi
|
||||
for search_path in "${search_paths[@]}"; do
|
||||
[[ -f "$search_path" ]] && { EA_SOURCE="$search_path"; break; }
|
||||
done
|
||||
|
||||
[[ -z "$EA_SOURCE" ]] && {
|
||||
echo " ERROR: Cannot find ${EXPERT}.mq5" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
"${SCRIPT_DIR}/mqlcompile.sh" "$EA_SOURCE"
|
||||
else
|
||||
echo "[1/5] COMPILE skipped"
|
||||
fi
|
||||
|
||||
# ── Stage 2: CLEAN ────────────────────────────────────────────────────────────
|
||||
if [[ "$SKIP_CLEAN" == false ]]; then
|
||||
_progress "CLEAN"
|
||||
echo ""
|
||||
echo "[2/5] CLEAN"
|
||||
|
||||
# Clear tester cache
|
||||
if [[ -d "$MT5_CACHE_DIR" ]]; then
|
||||
find "$MT5_CACHE_DIR" -name "*.tst" -delete 2>/dev/null || true
|
||||
echo " Cleared tester cache: $MT5_CACHE_DIR"
|
||||
fi
|
||||
|
||||
# Remove cached .set file for this expert
|
||||
CACHED_SET="${MT5_TESTER_DIR}/${EXPERT}.set"
|
||||
if [[ -f "$CACHED_SET" ]]; then
|
||||
rm -f "$CACHED_SET"
|
||||
echo " Removed cached .set: $CACHED_SET"
|
||||
fi
|
||||
|
||||
# Reset terminal.ini OptMode — after any test/optimization MT5 sets OptMode=-1
|
||||
# which causes the next headless run to exit immediately (exit 49, no report)
|
||||
TERMINAL_INI="${MT5_DIR}/config/terminal.ini"
|
||||
if [[ -f "$TERMINAL_INI" ]]; then
|
||||
python3 -c "
|
||||
import sys, re
|
||||
path = sys.argv[1]
|
||||
try:
|
||||
with open(path, 'rb') as f:
|
||||
raw = f.read()
|
||||
# Detect encoding: UTF-16 with BOM, or plain text
|
||||
if raw[:2] in (b'\xff\xfe', b'\xfe\xff'):
|
||||
text = raw.decode('utf-16')
|
||||
encoding = 'utf-16'
|
||||
else:
|
||||
text = raw.decode('utf-8', errors='replace')
|
||||
encoding = 'utf-8'
|
||||
text = re.sub(r'(?m)^OptMode=-1\s*$', 'OptMode=0', text)
|
||||
text = re.sub(r'(?m)^LastOptimization=1\s*\n?', '', text)
|
||||
with open(path, 'wb') as f:
|
||||
f.write(text.encode(encoding))
|
||||
print(' Reset OptMode=-1 -> OptMode=0 in terminal.ini')
|
||||
except Exception as e:
|
||||
print(f' Warning: could not reset OptMode in terminal.ini: {e}')
|
||||
" "$TERMINAL_INI" 2>/dev/null || true
|
||||
echo " Reset terminal.ini OptMode"
|
||||
fi
|
||||
else
|
||||
echo "[2/5] CLEAN skipped"
|
||||
fi
|
||||
|
||||
# ── Prepare .set file ─────────────────────────────────────────────────────────
|
||||
if [[ -n "$SET_FILE" ]]; then
|
||||
# Resolve relative paths against PROJECT_DIR (fallback: script ROOT_DIR, then CWD)
|
||||
if [[ ! -f "$SET_FILE" ]]; then
|
||||
for base in "$PROJECT_DIR" "$ROOT_DIR" "$(pwd)"; do
|
||||
[[ -n "$base" && -f "${base}/${SET_FILE}" ]] && { SET_FILE="${base}/${SET_FILE}"; break; }
|
||||
done
|
||||
fi
|
||||
if [[ ! -f "$SET_FILE" ]]; then
|
||||
echo "ERROR: Set file not found: $SET_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Copy to tester profiles dir (MT5 reads from here)
|
||||
mkdir -p "$MT5_TESTER_DIR"
|
||||
cp "$SET_FILE" "${MT5_TESTER_DIR}/${EXPERT}.set"
|
||||
SET_FILENAME="$(basename "$SET_FILE")"
|
||||
fi
|
||||
|
||||
# ── Stage 3: BACKTEST ─────────────────────────────────────────────────────────
|
||||
_progress "BACKTEST"
|
||||
echo ""
|
||||
echo "[3/5] BACKTEST"
|
||||
|
||||
# Guard: detect if terminal64.exe is already running (live trading mode).
|
||||
# MT5 uses a single-instance lock per Wine prefix — a second headless instance
|
||||
# exits in ~3s with no report. Kill the existing instance before proceeding.
|
||||
if pgrep -f "wine64-preloader.*terminal64\.exe" > /dev/null 2>&1; then
|
||||
echo " WARNING: MetaTrader 5 is already running — killing it to allow backtest."
|
||||
echo " (Restart MT5 manually after the backtest if needed.)"
|
||||
# Graceful SIGTERM first, then SIGKILL after 5s
|
||||
pkill -TERM -f "wine64-preloader.*terminal64\.exe" 2>/dev/null || true
|
||||
for _i in 1 2 3 4 5; do
|
||||
sleep 1
|
||||
pgrep -f "wine64-preloader.*terminal64\.exe" > /dev/null 2>&1 || break
|
||||
done
|
||||
# Force-kill if still alive
|
||||
if pgrep -f "wine64-preloader.*terminal64\.exe" > /dev/null 2>&1; then
|
||||
pkill -KILL -f "wine64-preloader.*terminal64\.exe" 2>/dev/null || true
|
||||
sleep 1
|
||||
fi
|
||||
echo " MT5 stopped."
|
||||
fi
|
||||
|
||||
# Build backtest.ini
|
||||
REPORT_FILENAME="${REPORT_ID}.htm"
|
||||
# Relative path — MT5 resolves against its working dir (C:\Program Files\MetaTrader 5\reports\)
|
||||
WINE_REPORT_PATH="reports\\${REPORT_FILENAME}"
|
||||
|
||||
INI_HOST_PATH="${MT5_DIR}/backtest_config.ini"
|
||||
mkdir -p "${MT5_DIR}/reports"
|
||||
|
||||
# Prepend [Common] section if login/server are configured — forces the headless
|
||||
# terminal to connect to the correct broker account (avoids "symbol not exist"
|
||||
# when live terminal uses a different broker than the backtest symbol requires).
|
||||
INI_CONTENT=""
|
||||
if [[ -n "$DEFAULT_LOGIN" && -n "$DEFAULT_SERVER" ]]; then
|
||||
INI_CONTENT="[Common]
|
||||
Login=${DEFAULT_LOGIN}
|
||||
Server=${DEFAULT_SERVER}
|
||||
|
||||
"
|
||||
fi
|
||||
|
||||
INI_CONTENT+="[Tester]
|
||||
Expert=${EXPERT}.ex5
|
||||
Symbol=${SYMBOL}
|
||||
Period=${TIMEFRAME}
|
||||
Optimization=0
|
||||
Model=${MODEL}
|
||||
FromDate=${FROM_DATE}
|
||||
ToDate=${TO_DATE}
|
||||
ForwardMode=0
|
||||
Deposit=${DEPOSIT}
|
||||
Currency=${CURRENCY}
|
||||
ProfitInPips=1
|
||||
Leverage=${LEVERAGE}
|
||||
ExecutionMode=10
|
||||
OptimizationCriterion=0
|
||||
Visual=$([[ "$GUI_MODE" == true ]] && echo 1 || echo 0)
|
||||
Report=${WINE_REPORT_PATH}
|
||||
ReplaceReport=1
|
||||
ShutdownTerminal=1
|
||||
"
|
||||
[[ -n "$SET_FILE" ]] && INI_CONTENT+="ExpertParameters=${EXPERT}.set
|
||||
"
|
||||
|
||||
# MT5 requires UTF-16LE with BOM — plain UTF-8 is silently ignored
|
||||
printf "%s" "$INI_CONTENT" | iconv -f UTF-8 -t UTF-16LE > "${INI_HOST_PATH}.tmp"
|
||||
# Prepend BOM (FF FE)
|
||||
printf '\xff\xfe' | cat - "${INI_HOST_PATH}.tmp" > "${INI_HOST_PATH}"
|
||||
rm -f "${INI_HOST_PATH}.tmp"
|
||||
|
||||
# Set Wine prefix — CRITICAL: without WINEPREFIX, Wine uses ~/.wine (wrong prefix)
|
||||
# which causes MT5 to exit immediately (no registry, no tick data, no report)
|
||||
WINE_PREFIX_DIR=$(dirname "$(dirname "$(dirname "$MT5_DIR")")")
|
||||
export WINEPREFIX="$WINE_PREFIX_DIR"
|
||||
export WINEDEBUG="-all"
|
||||
|
||||
# Write launcher batch (start /wait works correctly once WINEPREFIX is set)
|
||||
BAT_PATH="${WINE_PREFIX_DIR}/drive_c/_mt5mcp_run.bat"
|
||||
cat > "$BAT_PATH" << 'BATEOF'
|
||||
@echo off
|
||||
cd /d "C:\Program Files\MetaTrader 5"
|
||||
start /wait terminal64.exe /config:"C:\Program Files\MetaTrader 5\backtest_config.ini"
|
||||
BATEOF
|
||||
|
||||
echo " Launching MT5 (timeout: ${TIMEOUT}s) ..."
|
||||
BACKTEST_START=$(date +%s)
|
||||
|
||||
set +e
|
||||
timeout "${TIMEOUT}" ${MT5_ARCH} "${MT5_WINE}" cmd.exe /c 'C:\_mt5mcp_run.bat' 2>/dev/null
|
||||
WINE_EXIT=$?
|
||||
set -e
|
||||
|
||||
rm -f "$BAT_PATH"
|
||||
|
||||
BACKTEST_ELAPSED=$(( $(date +%s) - BACKTEST_START ))
|
||||
echo " MT5 completed in ${BACKTEST_ELAPSED}s (exit: ${WINE_EXIT})"
|
||||
|
||||
# Give MT5 a moment to flush the report to disk
|
||||
sleep 2
|
||||
|
||||
# ── Locate report file ────────────────────────────────────────────────────────
|
||||
MT5_REPORT=""
|
||||
# Primary: expected relative path from ini
|
||||
for ext in ".htm" ".htm.xml" ".html"; do
|
||||
candidate="${MT5_DIR}/reports/${REPORT_ID}${ext}"
|
||||
if [[ -f "$candidate" ]]; then
|
||||
MT5_REPORT="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
# Fallback: any HTM in MT5_DIR newer than the ini file
|
||||
if [[ -z "$MT5_REPORT" ]]; then
|
||||
MT5_REPORT=$(find "${MT5_DIR}" -maxdepth 3 -name "*.htm" -newer "${INI_HOST_PATH}" 2>/dev/null | head -1)
|
||||
fi
|
||||
|
||||
if [[ -z "$MT5_REPORT" ]]; then
|
||||
echo " ERROR: MT5 produced no report." >&2
|
||||
echo " Check: symbol name, date range, EA name, and that MT5 ran to completion." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo " Report: $MT5_REPORT"
|
||||
|
||||
# ── Stage 4: EXTRACT ─────────────────────────────────────────────────────────
|
||||
_progress "EXTRACT"
|
||||
echo ""
|
||||
echo "[4/5] EXTRACT"
|
||||
|
||||
python3 "${ROOT_DIR}/analytics/extract.py" \
|
||||
"$MT5_REPORT" \
|
||||
--output-dir "$REPORT_DIR" \
|
||||
&& echo " → metrics.json, deals.csv, deals.json"
|
||||
|
||||
# ── Stage 5: ANALYZE ─────────────────────────────────────────────────────────
|
||||
if [[ "$SKIP_ANALYZE" == false ]]; then
|
||||
_progress "ANALYZE"
|
||||
echo ""
|
||||
echo "[5/5] ANALYZE"
|
||||
|
||||
ANALYZE_FLAGS="$STRATEGY"
|
||||
[[ "$DEEP_ANALYZE" == true ]] && ANALYZE_FLAGS="$ANALYZE_FLAGS --deep"
|
||||
|
||||
python3 "${ROOT_DIR}/analytics/analyze.py" \
|
||||
$ANALYZE_FLAGS \
|
||||
"${REPORT_DIR}/deals.csv" \
|
||||
--output-dir "$REPORT_DIR" \
|
||||
&& echo " → analysis.json [$STRATEGY]"
|
||||
else
|
||||
echo "[5/5] ANALYZE skipped"
|
||||
fi
|
||||
|
||||
# ── Save pipeline metadata ────────────────────────────────────────────────────
|
||||
_progress "DONE"
|
||||
PIPELINE_ELAPSED=$(( $(date +%s) - PIPELINE_START ))
|
||||
|
||||
python3 - << PYEOF
|
||||
import json, os
|
||||
meta = {
|
||||
"expert": "${EXPERT}",
|
||||
"symbol": "${SYMBOL}",
|
||||
"timeframe": "${TIMEFRAME}",
|
||||
"from_date": "${FROM_DATE}",
|
||||
"to_date": "${TO_DATE}",
|
||||
"deposit": ${DEPOSIT},
|
||||
"currency": "${CURRENCY}",
|
||||
"model": ${MODEL},
|
||||
"leverage": ${LEVERAGE},
|
||||
"set_file": "${SET_FILE}",
|
||||
"report_dir": "${REPORT_DIR}",
|
||||
"duration_seconds": ${PIPELINE_ELAPSED},
|
||||
"files": {
|
||||
"metrics": "${REPORT_DIR}/metrics.json",
|
||||
"analysis": "${REPORT_DIR}/analysis.json",
|
||||
"deals_csv": "${REPORT_DIR}/deals.csv",
|
||||
"deals_json": "${REPORT_DIR}/deals.json"
|
||||
}
|
||||
}
|
||||
with open("${REPORT_DIR}/pipeline_metadata.json", "w") as f:
|
||||
json.dump(meta, f, indent=2)
|
||||
PYEOF
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo " Pipeline complete in ${PIPELINE_ELAPSED}s"
|
||||
echo " Report: $REPORT_DIR"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
# Print key metrics inline
|
||||
if [[ -f "${REPORT_DIR}/metrics.json" ]]; then
|
||||
python3 - << PYEOF
|
||||
import json
|
||||
with open("${REPORT_DIR}/metrics.json") as f:
|
||||
m = json.load(f)
|
||||
print(f" Profit: \${m.get('net_profit',0):,.2f} PF: {m.get('profit_factor',0):.2f} DD: {m.get('max_dd_pct',0):.2f}% Sharpe: {m.get('sharpe_ratio',0):.2f} Trades: {m.get('total_trades',0)}")
|
||||
PYEOF
|
||||
fi
|
||||
Executable
+143
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env bash
|
||||
# mqlcompile.sh — Compile an MQL5 Expert Advisor via MetaEditor (Wine/CrossOver)
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/mqlcompile.sh <path/to/Expert.mq5>
|
||||
#
|
||||
# Output:
|
||||
# Compiled .ex5 written to MT5_EXPERTS_DIR
|
||||
# Exit 0 on success, 1 on compile errors
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "${SCRIPT_DIR}/platform_detect.sh"
|
||||
|
||||
# ── Args ──────────────────────────────────────────────────────────────────────
|
||||
SOURCE_FILE="${1:-}"
|
||||
if [[ -z "$SOURCE_FILE" ]]; then
|
||||
echo "Usage: $0 <path/to/Expert.mq5>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$SOURCE_FILE" ]]; then
|
||||
echo "ERROR: Source file not found: $SOURCE_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SOURCE_FILE="$(realpath "$SOURCE_FILE")"
|
||||
EXPERT_NAME="$(basename "$SOURCE_FILE" .mq5)"
|
||||
|
||||
# ── Resolve platform ──────────────────────────────────────────────────────────
|
||||
resolve_platform
|
||||
|
||||
METAEDITOR="${MT5_DIR}/metaeditor64.exe"
|
||||
if [[ ! -f "$METAEDITOR" ]]; then
|
||||
echo "ERROR: metaeditor64.exe not found at: $METAEDITOR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Copy source to MT5 Experts source dir ────────────────────────────────────
|
||||
# MetaEditor requires the source file to be inside the MT5 directory tree
|
||||
MT5_SRC_DIR="${MT5_DIR}/MQL5/Experts"
|
||||
mkdir -p "$MT5_SRC_DIR"
|
||||
cp "$SOURCE_FILE" "${MT5_SRC_DIR}/${EXPERT_NAME}.mq5"
|
||||
|
||||
WINE_SRC_PATH="$(host_to_wine_path "${MT5_SRC_DIR}/${EXPERT_NAME}.mq5")"
|
||||
|
||||
# ── Sync .mqh include files to MT5 Include dir ───────────────────────────────
|
||||
# Auto-detect include/ directory relative to source file. Searches up to 2
|
||||
# levels above the source file for an include/ sibling directory.
|
||||
# Layout supported:
|
||||
# <project>/experts/EA.mq5 + <project>/include/<subdir>/*.mqh
|
||||
# <project>/src/experts/EA.mq5 + <project>/src/include/<subdir>/*.mqh
|
||||
_find_include_dir() {
|
||||
local source_dir="$1"
|
||||
local candidate
|
||||
for candidate in "$source_dir" "$(dirname "$source_dir")" "$(dirname "$(dirname "$source_dir")")"; do
|
||||
if [[ -d "${candidate}/include" ]]; then
|
||||
echo "${candidate}/include"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
INCLUDE_BASE=""
|
||||
INCLUDE_BASE=$(_find_include_dir "$(dirname "$SOURCE_FILE")") || true
|
||||
|
||||
if [[ -n "$INCLUDE_BASE" ]]; then
|
||||
synced_total=0
|
||||
# Sync each subdirectory under include/ → MQL5/Include/<subdir>/
|
||||
while IFS= read -r -d '' subdir; do
|
||||
dir_name="$(basename "$subdir")"
|
||||
mt5_dest="${MT5_DIR}/MQL5/Include/${dir_name}"
|
||||
rm -rf "$mt5_dest"
|
||||
cp -r "$subdir" "$mt5_dest"
|
||||
count=$(find "$mt5_dest" -name "*.mqh" | wc -l | tr -d ' ')
|
||||
echo "[compile] Synced ${count} .mqh → Include/${dir_name}/"
|
||||
synced_total=$((synced_total + count))
|
||||
done < <(find "$INCLUDE_BASE" -mindepth 1 -maxdepth 1 -type d -print0 2>/dev/null)
|
||||
|
||||
# Also sync any .mqh files directly in include/ (flat layout)
|
||||
while IFS= read -r -d '' mqh; do
|
||||
cp "$mqh" "${MT5_DIR}/MQL5/Include/"
|
||||
synced_total=$((synced_total + 1))
|
||||
done < <(find "$INCLUDE_BASE" -maxdepth 1 -name "*.mqh" -print0 2>/dev/null)
|
||||
|
||||
if [[ $synced_total -eq 0 ]]; then
|
||||
echo "[compile] INFO: include/ found but contains no .mqh files — skipping sync"
|
||||
else
|
||||
echo "[compile] Synced ${synced_total} .mqh file(s) total"
|
||||
fi
|
||||
else
|
||||
echo "[compile] INFO: No include/ directory found — skipping .mqh sync"
|
||||
fi
|
||||
|
||||
# ── Set Wine prefix ───────────────────────────────────────────────────────────
|
||||
WINE_PREFIX_DIR=$(dirname "$(dirname "$(dirname "$MT5_DIR")")")
|
||||
export WINEPREFIX="$WINE_PREFIX_DIR"
|
||||
export WINEDEBUG="-all"
|
||||
|
||||
# ── Run MetaEditor ────────────────────────────────────────────────────────────
|
||||
echo "[compile] Compiling ${EXPERT_NAME}.mq5 ..."
|
||||
LOG_FILE="$(mktemp /tmp/mqlcompile_XXXXXX.log)"
|
||||
|
||||
set +e
|
||||
${MT5_ARCH} "${MT5_WINE}" "${METAEDITOR}" \
|
||||
/compile:"${WINE_SRC_PATH}" \
|
||||
/log:"${LOG_FILE}" \
|
||||
2>/dev/null
|
||||
WINE_EXIT=$?
|
||||
set -e
|
||||
|
||||
# MetaEditor always exits 0 on macOS/Wine; check log for errors
|
||||
ERRORS=0
|
||||
WARNINGS=0
|
||||
if [[ -f "$LOG_FILE" ]]; then
|
||||
# Log may be UTF-16LE
|
||||
LOG_TEXT=$(iconv -f UTF-16LE -t UTF-8 "$LOG_FILE" 2>/dev/null || cat "$LOG_FILE")
|
||||
ERRORS=$(echo "$LOG_TEXT" | grep -cE "^.*error" || true)
|
||||
WARNINGS=$(echo "$LOG_TEXT" | grep -cE "^.*warning" || true)
|
||||
|
||||
if [[ $ERRORS -gt 0 ]]; then
|
||||
echo "[compile] FAILED: $ERRORS error(s), $WARNINGS warning(s)"
|
||||
echo "$LOG_TEXT" | grep -E "error|warning" | head -20
|
||||
rm -f "$LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Verify .ex5 was produced ──────────────────────────────────────────────────
|
||||
EX5_PATH="${MT5_SRC_DIR}/${EXPERT_NAME}.ex5"
|
||||
if [[ ! -f "$EX5_PATH" ]]; then
|
||||
echo "[compile] ERROR: .ex5 not produced. MetaEditor may have failed silently." >&2
|
||||
[[ -f "$LOG_FILE" ]] && cat "$LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BINARY_SIZE=$(stat -f%z "$EX5_PATH" 2>/dev/null || stat -c%s "$EX5_PATH")
|
||||
echo "[compile] OK: ${EXPERT_NAME}.ex5 (${BINARY_SIZE} bytes, ${WARNINGS} warning(s))"
|
||||
|
||||
rm -f "$LOG_FILE"
|
||||
exit 0
|
||||
Executable
+226
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env bash
|
||||
# optimize.sh — Launch MT5 genetic optimization (always background + detached)
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/optimize.sh [options]
|
||||
#
|
||||
# Options:
|
||||
# --expert NAME EA name
|
||||
# --set FILE Optimization .set file (with ||Y flags)
|
||||
# --symbol SYMBOL Trading symbol
|
||||
# --from YYYY.MM.DD Start date
|
||||
# --to YYYY.MM.DD End date
|
||||
# --deposit AMOUNT Initial deposit
|
||||
# --model 0|1|2 Tick model (ALWAYS use 0 for grid/martingale EAs)
|
||||
# --log FILE Log file path (default: /tmp/mt5opt_TIMESTAMP.log)
|
||||
#
|
||||
# IMPORTANT: This script launches MT5 as a detached background process.
|
||||
# It returns immediately. Do NOT set a timeout on this script.
|
||||
# Monitor /tmp/mt5opt_*.log and wait for user signal before parsing results.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
source "${SCRIPT_DIR}/platform_detect.sh"
|
||||
|
||||
# ── Defaults ──────────────────────────────────────────────────────────────────
|
||||
DEFAULT_SYMBOL=$(_cfg "backtest_symbol" "XAUUSD")
|
||||
DEFAULT_DEPOSIT=$(_cfg "backtest_deposit" "10000")
|
||||
DEFAULT_CURRENCY=$(_cfg "backtest_currency" "USD")
|
||||
DEFAULT_LEVERAGE=$(_cfg "backtest_leverage" "500")
|
||||
|
||||
EXPERT=""
|
||||
SET_FILE=""
|
||||
SYMBOL="$DEFAULT_SYMBOL"
|
||||
FROM_DATE=""
|
||||
TO_DATE=""
|
||||
DEPOSIT="$DEFAULT_DEPOSIT"
|
||||
CURRENCY="$DEFAULT_CURRENCY"
|
||||
LEVERAGE="$DEFAULT_LEVERAGE"
|
||||
MODEL=0 # ALWAYS 0 for optimization — see below
|
||||
LOG_FILE=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--expert) EXPERT="$2"; shift 2 ;;
|
||||
--set) SET_FILE="$2"; shift 2 ;;
|
||||
--symbol) SYMBOL="$2"; shift 2 ;;
|
||||
--from) FROM_DATE="$2"; shift 2 ;;
|
||||
--to) TO_DATE="$2"; shift 2 ;;
|
||||
--deposit) DEPOSIT="$2"; shift 2 ;;
|
||||
--model)
|
||||
# Warn if user tries to use model != 0
|
||||
if [[ "$2" != "0" ]]; then
|
||||
echo "WARNING: --model $2 ignored. Optimization always uses model=0." >&2
|
||||
echo " Model 1/2 overfits martingale/grid EAs (intra-bar price not simulated)." >&2
|
||||
fi
|
||||
shift 2
|
||||
;;
|
||||
--log) LOG_FILE="$2"; shift 2 ;;
|
||||
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$EXPERT" ]] && { echo "ERROR: --expert is required" >&2; exit 1; }
|
||||
[[ -z "$SET_FILE" ]] && { echo "ERROR: --set is required" >&2; exit 1; }
|
||||
[[ -z "$FROM_DATE" ]] && { echo "ERROR: --from is required" >&2; exit 1; }
|
||||
[[ -z "$TO_DATE" ]] && { echo "ERROR: --to is required" >&2; exit 1; }
|
||||
|
||||
[[ ! -f "$SET_FILE" ]] && { echo "ERROR: Set file not found: $SET_FILE" >&2; exit 1; }
|
||||
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
LOG_FILE="${LOG_FILE:-/tmp/mt5opt_${TIMESTAMP}.log}"
|
||||
JOB_ID="opt_${TIMESTAMP}"
|
||||
|
||||
# ── Resolve platform ──────────────────────────────────────────────────────────
|
||||
resolve_platform
|
||||
|
||||
# ── Write .set file as UTF-16LE with BOM (read-only) ─────────────────────────
|
||||
# MT5 REQUIREMENT: optimization .set files must be UTF-16LE with BOM.
|
||||
# If provided as UTF-8, MT5 strips the ||Y optimization flags silently —
|
||||
# every pass runs with the fixed base value and optimization is useless.
|
||||
python3 - << PYEOF
|
||||
import sys, os, shutil
|
||||
|
||||
src = "${SET_FILE}"
|
||||
dst = "${MT5_TESTER_DIR}/${EXPERT}.set"
|
||||
os.makedirs("${MT5_TESTER_DIR}", exist_ok=True)
|
||||
|
||||
with open(src, 'r', encoding='utf-8', errors='replace') as f:
|
||||
content = f.read()
|
||||
|
||||
# Write UTF-16LE with BOM
|
||||
with open(dst, 'w', encoding='utf-16-le') as f:
|
||||
f.write('\ufeff') # BOM
|
||||
f.write(content)
|
||||
|
||||
# Make read-only — prevents MT5 from overwriting ||Y flags during optimization
|
||||
os.chmod(dst, 0o444)
|
||||
print(f" .set → {dst} (UTF-16LE, read-only)")
|
||||
PYEOF
|
||||
|
||||
# ── Reset OptMode in terminal.ini ─────────────────────────────────────────────
|
||||
# After any optimization run (complete or aborted), MT5 writes OptMode=-1.
|
||||
# On next launch, MT5 reads OptMode=-1 and exits immediately without running.
|
||||
# Must reset to 0 before every optimization launch.
|
||||
TERMINAL_INI="${MT5_DIR}/terminal.ini"
|
||||
if [[ -f "$TERMINAL_INI" ]]; then
|
||||
# Use Python for safe in-place edit (sed -i behaves differently on macOS vs Linux)
|
||||
python3 - << PYEOF
|
||||
import re
|
||||
|
||||
ini_path = "${TERMINAL_INI}"
|
||||
with open(ini_path, 'r', errors='replace') as f:
|
||||
content = f.read()
|
||||
|
||||
# Reset OptMode
|
||||
content = re.sub(r'OptMode=.*', 'OptMode=0', content)
|
||||
# Remove LastOptimization (causes MT5 to skip running)
|
||||
content = re.sub(r'LastOptimization=.*\n?', '', content)
|
||||
|
||||
with open(ini_path, 'w') as f:
|
||||
f.write(content)
|
||||
print(f" terminal.ini: OptMode reset to 0")
|
||||
PYEOF
|
||||
fi
|
||||
|
||||
# ── Build optimization INI ────────────────────────────────────────────────────
|
||||
WINE_PREFIX_DIR=$(dirname "$(dirname "$MT5_DIR")")
|
||||
|
||||
cat > "${WINE_PREFIX_DIR}/drive_c/mt5mcp_backtest.ini" << INI
|
||||
[Tester]
|
||||
Expert=${EXPERT}
|
||||
Symbol=${SYMBOL}
|
||||
Period=M5
|
||||
Deposit=${DEPOSIT}
|
||||
Currency=${CURRENCY}
|
||||
Leverage=${LEVERAGE}
|
||||
Model=${MODEL}
|
||||
FromDate=${FROM_DATE}
|
||||
ToDate=${TO_DATE}
|
||||
Report=C:\\mt5mcp_opt_report
|
||||
Optimization=2
|
||||
ExpertParameters=${EXPERT}.set
|
||||
ShutdownTerminal=1
|
||||
INI
|
||||
|
||||
cat > "${WINE_PREFIX_DIR}/drive_c/mt5mcp_run.bat" << 'EOF'
|
||||
@echo off
|
||||
"C:\Program Files\MetaTrader 5\terminal64.exe" /config:C:\mt5mcp_backtest.ini
|
||||
EOF
|
||||
|
||||
# ── Count optimization combinations ──────────────────────────────────────────
|
||||
COMBINATIONS=$(python3 - << PYEOF
|
||||
import re, math
|
||||
|
||||
with open("${SET_FILE}", 'r', errors='replace') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
total = 1
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if line.startswith(';') or '=' not in line:
|
||||
continue
|
||||
# Format: param=value||start||step||stop||Y
|
||||
parts = line.split('||')
|
||||
if len(parts) >= 5 and parts[-1].strip().upper() == 'Y':
|
||||
try:
|
||||
start = float(parts[1])
|
||||
step = float(parts[2])
|
||||
stop = float(parts[3])
|
||||
count = max(1, int((stop - start) / step) + 1)
|
||||
total *= count
|
||||
except (ValueError, ZeroDivisionError):
|
||||
pass
|
||||
|
||||
print(total)
|
||||
PYEOF
|
||||
)
|
||||
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo " MT5-Quant Genetic Optimization"
|
||||
echo " Job ID: $JOB_ID"
|
||||
echo " Expert: $EXPERT"
|
||||
echo " Symbol: $SYMBOL Model: ${MODEL} (every tick)"
|
||||
echo " Period: $FROM_DATE → $TO_DATE"
|
||||
echo " Set file: $SET_FILE"
|
||||
echo " Combos: $COMBINATIONS (genetic — converges in ~300-500 passes)"
|
||||
echo " Log: $LOG_FILE"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
# ── Launch detached ───────────────────────────────────────────────────────────
|
||||
# nohup: prevents SIGHUP when parent (Claude task, SSH session) exits
|
||||
# disown: removes from shell job table so shell exit doesn't kill it
|
||||
# Both are required for true detachment.
|
||||
|
||||
nohup bash -c "${MT5_ARCH} '${MT5_WINE}' cmd.exe /c 'C:\\mt5mcp_run.bat' 2>/dev/null || true" \
|
||||
> "$LOG_FILE" 2>&1 &
|
||||
OPT_PID=$!
|
||||
disown $OPT_PID
|
||||
|
||||
# Write job metadata
|
||||
JOBS_DIR="${ROOT_DIR}/.mt5mcp_jobs"
|
||||
mkdir -p "$JOBS_DIR"
|
||||
cat > "${JOBS_DIR}/${JOB_ID}.json" << JEOF
|
||||
{
|
||||
"job_id": "${JOB_ID}",
|
||||
"pid": ${OPT_PID},
|
||||
"expert": "${EXPERT}",
|
||||
"symbol": "${SYMBOL}",
|
||||
"from_date": "${FROM_DATE}",
|
||||
"to_date": "${TO_DATE}",
|
||||
"set_file": "${SET_FILE}",
|
||||
"combinations": ${COMBINATIONS},
|
||||
"log_file": "${LOG_FILE}",
|
||||
"wine_prefix": "${WINE_PREFIX_DIR}",
|
||||
"started_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
}
|
||||
JEOF
|
||||
|
||||
echo ""
|
||||
echo " Launched (pid: $OPT_PID)"
|
||||
echo " Optimization runs for 2-6 hours. Do NOT kill this process."
|
||||
echo " Signal when MT5 shows 'Optimization complete' and use:"
|
||||
echo " python3 analytics/optimize_parser.py --job $JOB_ID"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
Executable
+244
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env bash
|
||||
# platform_detect.sh — Detect Wine path, MT5 location, and display mode
|
||||
# Sourced by other scripts: source scripts/platform_detect.sh
|
||||
# Sets: MT5_WINE, MT5_DIR, MT5_EXPERTS_DIR, MT5_TESTER_DIR, MT5_CACHE_DIR, DISPLAY_ENV
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# Config resolution: user config (~/.config/mt5-quant/) takes precedence over repo config
|
||||
_USER_CFG="${HOME}/.config/mt5-quant/config/mt5-quant.yaml"
|
||||
_REPO_CFG="${SCRIPT_DIR}/../config/mt5-quant.yaml"
|
||||
if [[ -f "$_USER_CFG" ]]; then
|
||||
CONFIG_FILE="$_USER_CFG"
|
||||
elif [[ -f "$_REPO_CFG" ]]; then
|
||||
CONFIG_FILE="$_REPO_CFG"
|
||||
else
|
||||
CONFIG_FILE="$_REPO_CFG" # will fail gracefully in _cfg
|
||||
fi
|
||||
|
||||
# ── Config reader (minimal YAML parser for simple key: value) ────────────────
|
||||
_cfg() {
|
||||
local key="$1"
|
||||
local default="${2:-}"
|
||||
if [[ -f "$CONFIG_FILE" ]]; then
|
||||
local val
|
||||
val=$(grep -E "^[[:space:]]*${key}[[:space:]]*:" "$CONFIG_FILE" 2>/dev/null \
|
||||
| head -1 | sed 's/.*:[[:space:]]*//' | tr -d '"' | tr -d "'" | tr -d '\r')
|
||||
if [[ -n "$val" && "$val" != "null" && "$val" != '""' ]]; then
|
||||
echo "$val"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
echo "$default"
|
||||
}
|
||||
|
||||
# ── Wine / CrossOver detection ───────────────────────────────────────────────
|
||||
_detect_wine() {
|
||||
local configured
|
||||
configured=$(_cfg "wine_executable")
|
||||
|
||||
if [[ -n "$configured" && -x "$configured" ]]; then
|
||||
echo "$configured"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Auto-detect: native MT5.app (macOS App Store / direct download)
|
||||
local mt5_app_wine="/Applications/MetaTrader 5.app/Contents/SharedSupport/wine/bin/wine64"
|
||||
if [[ -x "$mt5_app_wine" ]]; then
|
||||
echo "$mt5_app_wine"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Auto-detect: CrossOver (macOS)
|
||||
local crossover_wine="/Applications/CrossOver.app/Contents/SharedSupport/CrossOver/bin/wine64"
|
||||
if [[ -x "$crossover_wine" ]]; then
|
||||
echo "$crossover_wine"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Auto-detect: Wine (Linux / Homebrew)
|
||||
for candidate in wine64 wine; do
|
||||
if command -v "$candidate" &>/dev/null; then
|
||||
echo "$(command -v "$candidate")"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── MT5 terminal directory detection ─────────────────────────────────────────
|
||||
_detect_mt5_dir() {
|
||||
local configured
|
||||
configured=$(_cfg "terminal_dir")
|
||||
[[ -n "$configured" && -d "$configured" ]] && { echo "$configured"; return 0; }
|
||||
|
||||
# macOS CrossOver — scan all bottles
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
# Native MT5.app sandboxed Wine prefix (most common on macOS)
|
||||
local app_support="$HOME/Library/Application Support"
|
||||
for prefix_pattern in \
|
||||
"net.metaquotes.wine.metatrader5" \
|
||||
"MetaTrader 5/Bottles/metatrader5"; do
|
||||
local candidate="${app_support}/${prefix_pattern}/drive_c/Program Files/MetaTrader 5"
|
||||
if [[ -f "${candidate}/terminal64.exe" ]]; then
|
||||
echo "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
# CrossOver old-style ~/.cxoffice bottles
|
||||
local bottle_base="$HOME/.cxoffice"
|
||||
if [[ -d "$bottle_base" ]]; then
|
||||
while IFS= read -r -d '' terminal; do
|
||||
echo "$(dirname "$terminal")"
|
||||
return 0
|
||||
done < <(find "$bottle_base" -name "terminal64.exe" -print0 2>/dev/null | head -z -1)
|
||||
fi
|
||||
# CrossOver 24+ default location
|
||||
local mq_base="$HOME/Library/Application Support/MetaQuotes"
|
||||
if [[ -d "$mq_base" ]]; then
|
||||
while IFS= read -r -d '' terminal; do
|
||||
echo "$(dirname "$terminal")"
|
||||
return 0
|
||||
done < <(find "$mq_base" -name "terminal64.exe" -print0 2>/dev/null | head -z -1)
|
||||
fi
|
||||
fi
|
||||
|
||||
# Linux Wine — common default
|
||||
local wine_mt5="$HOME/.wine/drive_c/Program Files/MetaTrader 5"
|
||||
[[ -d "$wine_mt5" ]] && { echo "$wine_mt5"; return 0; }
|
||||
|
||||
echo ""
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Display / headless mode ───────────────────────────────────────────────────
|
||||
_detect_display_env() {
|
||||
local mode
|
||||
mode=$(_cfg "display_mode" "auto")
|
||||
local xvfb_display
|
||||
xvfb_display=$(_cfg "xvfb_display" ":99")
|
||||
local xvfb_screen
|
||||
xvfb_screen=$(_cfg "xvfb_screen" "1024x768x16")
|
||||
|
||||
case "$mode" in
|
||||
false|gui)
|
||||
# GUI mode — use whatever DISPLAY is set
|
||||
echo "gui"
|
||||
return 0
|
||||
;;
|
||||
true|headless)
|
||||
# Force headless via Xvfb
|
||||
_start_xvfb "$xvfb_display" "$xvfb_screen"
|
||||
echo "headless:${xvfb_display}"
|
||||
return 0
|
||||
;;
|
||||
auto|*)
|
||||
# macOS: CrossOver handles display — no Xvfb needed
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
echo "gui"
|
||||
return 0
|
||||
fi
|
||||
# Linux: if $DISPLAY is set, use it; otherwise start Xvfb
|
||||
if [[ -n "${DISPLAY:-}" ]]; then
|
||||
echo "gui"
|
||||
return 0
|
||||
fi
|
||||
_start_xvfb "$xvfb_display" "$xvfb_screen"
|
||||
echo "headless:${xvfb_display}"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
_start_xvfb() {
|
||||
local display="$1"
|
||||
local screen="$2"
|
||||
|
||||
if ! command -v Xvfb &>/dev/null; then
|
||||
echo "[platform_detect] ERROR: headless mode requires Xvfb. Install with:" >&2
|
||||
echo " sudo apt install xvfb # Debian/Ubuntu" >&2
|
||||
echo " sudo yum install xorg-x11-server-Xvfb # RHEL/CentOS" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if already running
|
||||
if xdpyinfo -display "$display" &>/dev/null 2>&1; then
|
||||
return 0 # already up
|
||||
fi
|
||||
|
||||
Xvfb "$display" -screen 0 "$screen" &>/dev/null &
|
||||
local xvfb_pid=$!
|
||||
sleep 1 # brief wait for Xvfb to initialize
|
||||
|
||||
if ! xdpyinfo -display "$display" &>/dev/null 2>&1; then
|
||||
echo "[platform_detect] ERROR: Xvfb failed to start on display ${display}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[platform_detect] Xvfb started (pid=${xvfb_pid}, display=${display})" >&2
|
||||
}
|
||||
|
||||
# ── macOS arch flag ───────────────────────────────────────────────────────────
|
||||
_arch_prefix() {
|
||||
if [[ "$(uname -s)" == "Darwin" && "$(uname -m)" == "arm64" ]]; then
|
||||
echo "arch -x86_64"
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Wine path converter (host path → Windows C:\ path) ───────────────────────
|
||||
host_to_wine_path() {
|
||||
local host_path="$1"
|
||||
# Convert Unix absolute path to Wine C:\ equivalent
|
||||
# Works for both CrossOver and standard Wine
|
||||
echo "$host_path" | sed \
|
||||
-e 's|.*drive_c/|C:\\|' \
|
||||
-e 's|/|\\|g'
|
||||
}
|
||||
|
||||
# ── Main: resolve everything and export ───────────────────────────────────────
|
||||
resolve_platform() {
|
||||
MT5_WINE=$(_detect_wine) || {
|
||||
echo "[platform_detect] ERROR: Wine/CrossOver not found." >&2
|
||||
echo " Configure wine_executable in config/mt5-quant.yaml" >&2
|
||||
echo " macOS: install CrossOver from https://www.codeweavers.com/" >&2
|
||||
echo " Linux: sudo apt install wine64" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
MT5_DIR=$(_detect_mt5_dir) || {
|
||||
echo "[platform_detect] ERROR: MetaTrader 5 installation not found." >&2
|
||||
echo " Configure terminal_dir in config/mt5-quant.yaml" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Derive sub-paths from MT5_DIR (override via config if needed)
|
||||
MT5_EXPERTS_DIR="$(_cfg "experts_dir" "${MT5_DIR}/MQL5/Experts")"
|
||||
MT5_TESTER_DIR="$(_cfg "tester_profiles_dir" "${MT5_DIR}/MQL5/Profiles/Tester")"
|
||||
MT5_CACHE_DIR="$(_cfg "tester_cache_dir" "${MT5_DIR}/Tester")"
|
||||
MT5_ARCH="$(_arch_prefix)"
|
||||
|
||||
DISPLAY_MODE=$(_detect_display_env)
|
||||
if [[ "$DISPLAY_MODE" == headless:* ]]; then
|
||||
export DISPLAY="${DISPLAY_MODE#headless:}"
|
||||
fi
|
||||
|
||||
export MT5_WINE MT5_DIR MT5_EXPERTS_DIR MT5_TESTER_DIR MT5_CACHE_DIR MT5_ARCH DISPLAY_MODE
|
||||
}
|
||||
|
||||
# Run if executed directly (not sourced)
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
resolve_platform
|
||||
echo "Wine: $MT5_WINE"
|
||||
echo "MT5 dir: $MT5_DIR"
|
||||
echo "Experts: $MT5_EXPERTS_DIR"
|
||||
echo "Tester: $MT5_TESTER_DIR"
|
||||
echo "Cache: $MT5_CACHE_DIR"
|
||||
echo "Display: $DISPLAY_MODE"
|
||||
echo "Arch: ${MT5_ARCH:-native}"
|
||||
fi
|
||||
Executable
+832
@@ -0,0 +1,832 @@
|
||||
#!/usr/bin/env bash
|
||||
# setup.sh — Auto-detect MT5/Wine paths and write config/mt5-quant.yaml
|
||||
# Usage: bash scripts/setup.sh [--yes] [--output /path/to/config.yaml] [--keep-last N] [--claude-code]
|
||||
#
|
||||
# --yes Overwrite existing config and register MCP without prompting
|
||||
# --output FILE Write to a custom path instead of config/mt5-quant.yaml
|
||||
# --keep-last N Keep only last N backtest reports (default: 20)
|
||||
# --claude-code Generate CLAUDE.md template and .claude/hooks/user-prompt-submit.sh
|
||||
# (skips main config wizard — run standalone or alongside normal setup)
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
CONFIG_OUT="${REPO_DIR}/config/mt5-quant.yaml"
|
||||
AUTO_YES=false
|
||||
KEEP_LAST=20
|
||||
CLAUDE_CODE=false
|
||||
|
||||
# ── Argument parsing ──────────────────────────────────────────────────────────
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--yes|-y) AUTO_YES=true ;;
|
||||
--output) CONFIG_OUT="$2"; shift ;;
|
||||
--keep-last) KEEP_LAST="$2"; shift ;;
|
||||
--claude-code) CLAUDE_CODE=true ;;
|
||||
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
_green() { printf '\033[0;32m%s\033[0m\n' "$*"; }
|
||||
_yellow() { printf '\033[0;33m%s\033[0m\n' "$*"; }
|
||||
_red() { printf '\033[0;31m%s\033[0m\n' "$*"; }
|
||||
_bold() { printf '\033[1m%s\033[0m\n' "$*"; }
|
||||
_ok() { printf ' \033[0;32m✓\033[0m %s\n' "$*"; }
|
||||
_warn() { printf ' \033[0;33m⚠\033[0m %s\n' "$*"; }
|
||||
_fail() { printf ' \033[0;31m✗\033[0m %s\n' "$*"; }
|
||||
|
||||
_ask() {
|
||||
# _ask "Prompt text" default_value → echoes user input or default
|
||||
local prompt="$1"
|
||||
local default="${2:-}"
|
||||
if [[ -n "$default" ]]; then
|
||||
printf '%s [%s]: ' "$prompt" "$default" >&2
|
||||
else
|
||||
printf '%s: ' "$prompt" >&2
|
||||
fi
|
||||
local answer
|
||||
read -r answer
|
||||
echo "${answer:-$default}"
|
||||
}
|
||||
|
||||
_pick() {
|
||||
# _pick "Label" item1 item2 ... → echoes chosen item
|
||||
local label="$1"; shift
|
||||
local items=("$@")
|
||||
if [[ ${#items[@]} -eq 1 ]]; then
|
||||
echo "${items[0]}"
|
||||
return
|
||||
fi
|
||||
printf '\n%s\n' "$label" >&2
|
||||
local i
|
||||
for i in "${!items[@]}"; do
|
||||
printf ' [%d] %s\n' "$((i+1))" "${items[$i]}" >&2
|
||||
done
|
||||
local choice
|
||||
while true; do
|
||||
printf ' Choose [1-%d]: ' "${#items[@]}" >&2
|
||||
read -r choice
|
||||
if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#items[@]} )); then
|
||||
echo "${items[$((choice-1))]}"
|
||||
return
|
||||
fi
|
||||
printf ' Invalid choice.\n' >&2
|
||||
done
|
||||
}
|
||||
|
||||
# ── Wine candidate scanner ────────────────────────────────────────────────────
|
||||
_scan_wine_candidates() {
|
||||
local candidates=()
|
||||
local os
|
||||
os="$(uname -s)"
|
||||
|
||||
if [[ "$os" == "Darwin" ]]; then
|
||||
# Native MT5.app (bundled Wine — most common on macOS)
|
||||
local mt5_app_wine="/Applications/MetaTrader 5.app/Contents/SharedSupport/wine/bin/wine64"
|
||||
[[ -x "$mt5_app_wine" ]] && candidates+=("$mt5_app_wine")
|
||||
|
||||
# CrossOver (classic install)
|
||||
local cxover="/Applications/CrossOver.app/Contents/SharedSupport/CrossOver/bin/wine64"
|
||||
[[ -x "$cxover" ]] && candidates+=("$cxover")
|
||||
|
||||
# Homebrew wine
|
||||
for brew_wine in /opt/homebrew/bin/wine64 /usr/local/bin/wine64 \
|
||||
/opt/homebrew/bin/wine /usr/local/bin/wine; do
|
||||
[[ -x "$brew_wine" ]] && candidates+=("$brew_wine")
|
||||
done
|
||||
fi
|
||||
|
||||
# Linux / fallback
|
||||
for sys_wine in wine64 wine; do
|
||||
local found
|
||||
found="$(command -v "$sys_wine" 2>/dev/null || true)"
|
||||
[[ -n "$found" && -x "$found" ]] && candidates+=("$found")
|
||||
done
|
||||
|
||||
# Deduplicate
|
||||
local seen=()
|
||||
local c
|
||||
for c in "${candidates[@]}"; do
|
||||
local dup=false
|
||||
local s
|
||||
for s in "${seen[@]:-}"; do [[ "$s" == "$c" ]] && dup=true && break; done
|
||||
$dup || seen+=("$c")
|
||||
done
|
||||
|
||||
printf '%s\n' "${seen[@]:-}"
|
||||
}
|
||||
|
||||
# ── MT5 prefix scanner ────────────────────────────────────────────────────────
|
||||
_scan_mt5_prefixes() {
|
||||
# Returns list of terminal_dir paths (each containing terminal64.exe)
|
||||
local results=()
|
||||
local os
|
||||
os="$(uname -s)"
|
||||
|
||||
if [[ "$os" == "Darwin" ]]; then
|
||||
local search_roots=(
|
||||
# Native MT5.app sandboxed prefix
|
||||
"$HOME/Library/Application Support"
|
||||
# CrossOver old
|
||||
"$HOME/.cxoffice"
|
||||
# CrossOver 24+
|
||||
"$HOME/Library/Application Support/MetaQuotes"
|
||||
)
|
||||
local root
|
||||
for root in "${search_roots[@]}"; do
|
||||
[[ -d "$root" ]] || continue
|
||||
while IFS= read -r -d '' terminal_exe; do
|
||||
results+=("$(dirname "$terminal_exe")")
|
||||
done < <(find "$root" -maxdepth 8 -name "terminal64.exe" -print0 2>/dev/null)
|
||||
done
|
||||
fi
|
||||
|
||||
# Linux Wine prefixes
|
||||
local linux_roots=(
|
||||
"$HOME/.wine"
|
||||
"$HOME/.wine64"
|
||||
)
|
||||
local root
|
||||
for root in "${linux_roots[@]}"; do
|
||||
[[ -d "$root" ]] || continue
|
||||
local mt5_dir="${root}/drive_c/Program Files/MetaTrader 5"
|
||||
[[ -f "${mt5_dir}/terminal64.exe" ]] && results+=("$mt5_dir")
|
||||
done
|
||||
|
||||
# Deduplicate
|
||||
local seen=()
|
||||
local c
|
||||
for c in "${results[@]:-}"; do
|
||||
local dup=false
|
||||
local s
|
||||
for s in "${seen[@]:-}"; do [[ "$s" == "$c" ]] && dup=true && break; done
|
||||
$dup || seen+=("$c")
|
||||
done
|
||||
|
||||
printf '%s\n' "${seen[@]:-}"
|
||||
}
|
||||
|
||||
# Score an MT5 dir by activity: count .ex5 + .set files (higher = more active)
|
||||
_score_mt5_dir() {
|
||||
local dir="$1"
|
||||
local count=0
|
||||
count=$(find "$dir" -name "*.ex5" -o -name "*.set" 2>/dev/null | wc -l | tr -d ' ')
|
||||
echo "$count"
|
||||
}
|
||||
|
||||
# Pick the most active MT5 dir from a list
|
||||
_best_mt5_dir() {
|
||||
local best="" best_score=-1
|
||||
local d
|
||||
while IFS= read -r d; do
|
||||
[[ -z "$d" ]] && continue
|
||||
local score
|
||||
score=$(_score_mt5_dir "$d")
|
||||
if (( score > best_score )); then
|
||||
best="$d"
|
||||
best_score=$score
|
||||
fi
|
||||
done
|
||||
echo "$best"
|
||||
}
|
||||
|
||||
# ── MT5 auto-installer ───────────────────────────────────────────────────────
|
||||
#
|
||||
# MetaTrader 5 provides official installers for both platforms:
|
||||
# macOS — DMG from MetaQuotes CDN (includes bundled Wine, no CrossOver needed)
|
||||
# Linux — Official bash installer from MetaQuotes (handles Wine + MT5 prefix)
|
||||
#
|
||||
# After install, MT5 must be launched once to unpack terminal64.exe into the
|
||||
# Wine prefix. _install_mt5() handles this automatically on Linux (headless via
|
||||
# Xvfb). On macOS the user must launch the app once manually (GUI required).
|
||||
|
||||
MT5_DMG_URL="https://download.mql5.com/cdn/web/metaquotes.software.corp/mt5/MetaTrader5.dmg"
|
||||
MT5_LINUX_URL="https://download.mql5.com/cdn/web/metaquotes.software.corp/mt5/mt5ubuntu.sh"
|
||||
MT5_EXE_URL="https://download.mql5.com/cdn/web/metaquotes.software.corp/mt5/mt5setup.exe"
|
||||
|
||||
_install_mt5() {
|
||||
local os
|
||||
os="$(uname -s)"
|
||||
|
||||
if [[ "$os" == "Darwin" ]]; then
|
||||
_install_mt5_macos
|
||||
else
|
||||
_install_mt5_linux
|
||||
fi
|
||||
}
|
||||
|
||||
_install_mt5_macos() {
|
||||
echo ""
|
||||
_bold "Installing MetaTrader 5 for macOS..."
|
||||
|
||||
if [[ -d "/Applications/MetaTrader 5.app" ]]; then
|
||||
_ok "MetaTrader 5.app already present in /Applications"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Prefer mas (Mac App Store CLI) — avoids Gatekeeper issues
|
||||
if command -v mas &>/dev/null; then
|
||||
_ok "Using mas (Mac App Store CLI)..."
|
||||
# MT5 App Store ID: 413698442
|
||||
mas install 413698442 && {
|
||||
_ok "Installed via Mac App Store"
|
||||
return 0
|
||||
} || _warn "mas install failed — falling back to direct download"
|
||||
fi
|
||||
|
||||
# Direct download from MetaQuotes CDN
|
||||
local dmg="/tmp/MetaTrader5.dmg"
|
||||
_ok "Downloading MetaTrader5.dmg from MetaQuotes CDN..."
|
||||
if ! curl -L --progress-bar --connect-timeout 30 "$MT5_DMG_URL" -o "$dmg"; then
|
||||
_fail "Download failed. Check your internet connection."
|
||||
echo ""
|
||||
echo " Manual install: open https://www.metatrader5.com/en/terminal/help/start_advanced/install_mac"
|
||||
return 1
|
||||
fi
|
||||
|
||||
_ok "Mounting DMG..."
|
||||
local mnt="/tmp/mt5_mount"
|
||||
if ! hdiutil attach "$dmg" -mountpoint "$mnt" -quiet -nobrowse; then
|
||||
_fail "Could not mount DMG: $dmg"
|
||||
return 1
|
||||
fi
|
||||
|
||||
_ok "Copying MetaTrader 5.app to /Applications..."
|
||||
cp -R "${mnt}/MetaTrader 5.app" /Applications/ 2>/dev/null || {
|
||||
_fail "Copy failed — try: sudo cp -R \"${mnt}/MetaTrader 5.app\" /Applications/"
|
||||
hdiutil detach "$mnt" -quiet 2>/dev/null
|
||||
return 1
|
||||
}
|
||||
|
||||
hdiutil detach "$mnt" -quiet 2>/dev/null
|
||||
rm -f "$dmg"
|
||||
_ok "MetaTrader 5.app installed to /Applications"
|
||||
|
||||
echo ""
|
||||
_yellow "ACTION REQUIRED: Launch MetaTrader 5.app once to complete initialization."
|
||||
echo " It will create the Wine prefix and download terminal64.exe."
|
||||
echo " After it loads (shows the login screen), you can close it."
|
||||
echo ""
|
||||
if ! $AUTO_YES; then
|
||||
_ask "Press Enter when MT5 has been launched and closed..." ""
|
||||
fi
|
||||
}
|
||||
|
||||
_install_mt5_linux() {
|
||||
echo ""
|
||||
_bold "Installing MetaTrader 5 for Linux..."
|
||||
|
||||
# Check for existing Wine terminal64.exe anywhere
|
||||
local existing
|
||||
existing=$(find "$HOME/.wine" "$HOME/.wine64" \
|
||||
"$HOME/Library/Application Support" 2>/dev/null \
|
||||
-name "terminal64.exe" -print -quit 2>/dev/null || true)
|
||||
if [[ -n "$existing" ]]; then
|
||||
_ok "terminal64.exe already found at: $existing"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# MetaQuotes provides an official Ubuntu installer that handles Wine + MT5
|
||||
local has_wget has_curl
|
||||
has_wget=$(command -v wget &>/dev/null && echo yes || echo no)
|
||||
has_curl=$(command -v curl &>/dev/null && echo yes || echo no)
|
||||
|
||||
local script="/tmp/mt5ubuntu.sh"
|
||||
_ok "Downloading MetaQuotes Linux installer..."
|
||||
if [[ "$has_wget" == "yes" ]]; then
|
||||
wget -q --show-progress "$MT5_LINUX_URL" -O "$script" || {
|
||||
_fail "Download failed. Check: $MT5_LINUX_URL"
|
||||
return 1
|
||||
}
|
||||
elif [[ "$has_curl" == "yes" ]]; then
|
||||
curl -L --progress-bar "$MT5_LINUX_URL" -o "$script" || {
|
||||
_fail "Download failed. Check: $MT5_LINUX_URL"
|
||||
return 1
|
||||
}
|
||||
else
|
||||
_fail "Neither wget nor curl found. Install one and retry."
|
||||
return 1
|
||||
fi
|
||||
|
||||
chmod +x "$script"
|
||||
_ok "Running MetaQuotes Linux installer (may prompt for sudo)..."
|
||||
bash "$script" || {
|
||||
_fail "Installer failed. Check output above."
|
||||
rm -f "$script"
|
||||
return 1
|
||||
}
|
||||
rm -f "$script"
|
||||
|
||||
# The MetaQuotes installer creates the Wine prefix and launches MT5 briefly.
|
||||
# On headless systems, we use Xvfb to allow MT5 to initialize.
|
||||
if [[ -z "${DISPLAY:-}" ]]; then
|
||||
_ok "Headless system — running MT5 init via Xvfb..."
|
||||
if ! command -v Xvfb &>/dev/null; then
|
||||
_warn "Xvfb not found. Install with: sudo apt install xvfb"
|
||||
_warn "Then launch MT5 manually: DISPLAY=:99 metatrader5"
|
||||
return 0
|
||||
fi
|
||||
Xvfb :99 -screen 0 1024x768x16 &>/dev/null &
|
||||
local xvfb_pid=$!
|
||||
sleep 2
|
||||
DISPLAY=:99 metatrader5 &>/dev/null &
|
||||
local mt5_pid=$!
|
||||
_ok "MT5 initializing (30s)..."
|
||||
sleep 30
|
||||
kill "$mt5_pid" 2>/dev/null || true
|
||||
kill "$xvfb_pid" 2>/dev/null || true
|
||||
else
|
||||
_ok "Launching MT5 to initialize Wine prefix (closes in 20s)..."
|
||||
metatrader5 &>/dev/null &
|
||||
local mt5_pid=$!
|
||||
sleep 20
|
||||
kill "$mt5_pid" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
_ok "MT5 initialization complete"
|
||||
}
|
||||
|
||||
# ── Display mode detection ────────────────────────────────────────────────────
|
||||
_detect_display_mode() {
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
echo "auto" # macOS always GUI
|
||||
elif [[ -n "${DISPLAY:-}" ]]; then
|
||||
echo "auto" # Linux with display
|
||||
else
|
||||
echo "auto" # headless Linux — auto will pick Xvfb
|
||||
fi
|
||||
}
|
||||
|
||||
# ── YAML writer ───────────────────────────────────────────────────────────────
|
||||
_write_yaml() {
|
||||
local wine="$1"
|
||||
local terminal_dir="$2"
|
||||
local display_mode="$3"
|
||||
local keep_last="${4:-20}"
|
||||
|
||||
# Quote paths that contain spaces for YAML (wrap in double quotes)
|
||||
local wine_q="\"${wine}\""
|
||||
local terminal_dir_q="\"${terminal_dir}\""
|
||||
local experts_q="\"${terminal_dir}/MQL5/Experts\""
|
||||
local tester_q="\"${terminal_dir}/MQL5/Profiles/Tester\""
|
||||
local cache_q="\"${terminal_dir}/Tester\""
|
||||
|
||||
cat > "$CONFIG_OUT" <<YAML
|
||||
# mt5-quant configuration — generated by scripts/setup.sh
|
||||
# Re-run scripts/setup.sh to regenerate, or edit manually.
|
||||
|
||||
mt5:
|
||||
# Path to Wine / CrossOver wine64 binary
|
||||
wine_executable: ${wine_q}
|
||||
|
||||
# MT5 installation directory (inside the Wine prefix)
|
||||
terminal_dir: ${terminal_dir_q}
|
||||
|
||||
# Where MT5 looks for Expert Advisor binaries (.ex5)
|
||||
experts_dir: ${experts_q}
|
||||
|
||||
# Where MT5 reads/writes .set files during backtests
|
||||
tester_profiles_dir: ${tester_q}
|
||||
|
||||
# MT5 tester cache directory (cleared before each run)
|
||||
tester_cache_dir: ${cache_q}
|
||||
|
||||
display:
|
||||
# headless: true → Xvfb (Linux only)
|
||||
# headless: false → GUI visible
|
||||
# headless: auto → macOS=GUI, Linux with \$DISPLAY=GUI, Linux without=Xvfb
|
||||
mode: ${display_mode}
|
||||
|
||||
# Virtual display for Xvfb (Linux headless only)
|
||||
xvfb_display: ":99"
|
||||
xvfb_screen: "1024x768x16"
|
||||
|
||||
backtest:
|
||||
symbol: "XAUUSD"
|
||||
deposit: 10000
|
||||
currency: "USD"
|
||||
leverage: 500
|
||||
model: 0 # 0=every tick (required for martingale/grid EAs)
|
||||
timeframe: "M5"
|
||||
timeout: 900 # seconds per backtest run
|
||||
|
||||
optimization:
|
||||
log_dir: "/tmp"
|
||||
min_agents: 1
|
||||
|
||||
reports:
|
||||
output_dir: "./reports"
|
||||
keep_last: ${keep_last}
|
||||
YAML
|
||||
}
|
||||
|
||||
# ── Validation ────────────────────────────────────────────────────────────────
|
||||
_validate() {
|
||||
local wine="$1"
|
||||
local terminal_dir="$2"
|
||||
local ok=true
|
||||
|
||||
echo ""
|
||||
_bold "Validating paths..."
|
||||
|
||||
if [[ -x "$wine" ]]; then
|
||||
_ok "wine_executable: $wine"
|
||||
else
|
||||
_fail "wine_executable not found or not executable: $wine"
|
||||
ok=false
|
||||
fi
|
||||
|
||||
if [[ -d "$terminal_dir" ]]; then
|
||||
_ok "terminal_dir: $terminal_dir"
|
||||
else
|
||||
_fail "terminal_dir not found: $terminal_dir"
|
||||
ok=false
|
||||
fi
|
||||
|
||||
local terminal_exe="${terminal_dir}/terminal64.exe"
|
||||
if [[ -f "$terminal_exe" ]]; then
|
||||
_ok "terminal64.exe found"
|
||||
else
|
||||
_warn "terminal64.exe not found (expected at ${terminal_exe})"
|
||||
fi
|
||||
|
||||
local experts_dir="${terminal_dir}/MQL5/Experts"
|
||||
if [[ -d "$experts_dir" ]]; then
|
||||
local ea_count
|
||||
ea_count=$(find "$experts_dir" -name "*.ex5" 2>/dev/null | wc -l | tr -d ' ')
|
||||
_ok "experts_dir: ${ea_count} .ex5 file(s) found"
|
||||
else
|
||||
_warn "experts_dir not found yet (will be created by MT5 on first run)"
|
||||
fi
|
||||
|
||||
local tester_dir="${terminal_dir}/MQL5/Profiles/Tester"
|
||||
if [[ -d "$tester_dir" ]]; then
|
||||
local set_count
|
||||
set_count=$(find "$tester_dir" -name "*.set" 2>/dev/null | wc -l | tr -d ' ')
|
||||
_ok "tester_profiles_dir: ${set_count} .set file(s) found"
|
||||
else
|
||||
_warn "tester_profiles_dir not found yet (will be created by MT5 on first run)"
|
||||
fi
|
||||
|
||||
$ok
|
||||
}
|
||||
|
||||
# ── Claude Code generation ────────────────────────────────────────────────────
|
||||
#
|
||||
# Writes two files to help users integrate Claude Code with their backtesting workflow:
|
||||
#
|
||||
# config/CLAUDE.template.md — copy to your EA project root as CLAUDE.md
|
||||
# .claude/hooks/user-prompt-submit.sh — injects production baseline into every prompt
|
||||
#
|
||||
# The hook reads config/baseline.json (gitignored, user-maintained).
|
||||
# baseline.json schema:
|
||||
# {
|
||||
# "symbol": "XAUUSD.cent",
|
||||
# "period": "2024-01-01/2024-12-31",
|
||||
# "net_profit": 1250.50,
|
||||
# "profit_factor": 1.43,
|
||||
# "max_drawdown_pct": 18.2,
|
||||
# "sharpe_ratio": 0.87,
|
||||
# "total_trades": 342,
|
||||
# "notes": "Best config as of 2024-12-15"
|
||||
# }
|
||||
|
||||
_generate_claude_code() {
|
||||
echo ""
|
||||
_bold "Generating Claude Code integration files..."
|
||||
|
||||
# ── CLAUDE.md template ────────────────────────────────────────────────────
|
||||
local template_out="${REPO_DIR}/config/CLAUDE.template.md"
|
||||
if [[ -f "$template_out" ]] && ! $AUTO_YES; then
|
||||
local ans
|
||||
ans=$(_ask "config/CLAUDE.template.md already exists. Overwrite?" "no")
|
||||
if [[ ! "$ans" =~ ^[Yy] ]]; then
|
||||
_warn "Skipping CLAUDE.md template"
|
||||
else
|
||||
_write_claude_template "$template_out"
|
||||
fi
|
||||
else
|
||||
_write_claude_template "$template_out"
|
||||
fi
|
||||
|
||||
# ── .claude/hooks/user-prompt-submit.sh ───────────────────────────────────
|
||||
local hooks_dir="${REPO_DIR}/.claude/hooks"
|
||||
mkdir -p "$hooks_dir"
|
||||
local hook_out="${hooks_dir}/user-prompt-submit.sh"
|
||||
if [[ -f "$hook_out" ]] && ! $AUTO_YES; then
|
||||
local ans
|
||||
ans=$(_ask ".claude/hooks/user-prompt-submit.sh already exists. Overwrite?" "no")
|
||||
if [[ ! "$ans" =~ ^[Yy] ]]; then
|
||||
_warn "Skipping hook generation"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
_write_baseline_hook "$hook_out"
|
||||
chmod +x "$hook_out"
|
||||
_ok "Written: $hook_out"
|
||||
|
||||
echo ""
|
||||
_green "Claude Code files ready."
|
||||
echo ""
|
||||
echo " Next steps:"
|
||||
echo " 1. Copy config/CLAUDE.template.md to your EA project root as CLAUDE.md"
|
||||
echo " 2. Create config/baseline.json with your production backtest metrics"
|
||||
echo " (see comments in .claude/hooks/user-prompt-submit.sh for the schema)"
|
||||
echo " 3. The hook auto-injects your baseline into every Claude prompt"
|
||||
echo ""
|
||||
}
|
||||
|
||||
_write_claude_template() {
|
||||
local out="$1"
|
||||
cat > "$out" <<'CLAUDE_MD'
|
||||
# CLAUDE.md — [Your EA Project Name]
|
||||
|
||||
## MT5 MCP Integration
|
||||
|
||||
This project uses [mt5-quant](https://github.com/masdevid/mt5-quant) for backtesting and
|
||||
optimization via Claude Code MCP tools.
|
||||
|
||||
Available MCP tools: `run_backtest`, `run_optimization`, `get_results`,
|
||||
`list_reports`, `get_report`, `get_analysis`
|
||||
|
||||
## Baseline Tracking
|
||||
|
||||
Production baseline is in `config/baseline.json` (gitignored, user-maintained).
|
||||
|
||||
- Always compare new backtest results against the baseline before calling a result
|
||||
an improvement. A result only counts as better if it beats the baseline on the
|
||||
primary metric without significantly degrading secondary metrics.
|
||||
- Update `baseline.json` only after confirming improvement in live/demo testing.
|
||||
- Key metrics: `net_profit`, `profit_factor`, `max_drawdown_pct`, `sharpe_ratio`,
|
||||
`total_trades`.
|
||||
|
||||
## Symbol Name Rules
|
||||
|
||||
- Always use the **exact** symbol name from the broker
|
||||
(e.g., `"XAUUSD.cent"` not `"XAUUSD"` — they are different instruments).
|
||||
- The symbol is set in `config/mt5-quant.yaml` under `backtest.symbol`.
|
||||
- Never hardcode a symbol name in tool calls — read it from config.
|
||||
|
||||
## Backtest Rules
|
||||
|
||||
- Model 0 (every tick) is required for martingale/grid EAs.
|
||||
- Never run two backtests in parallel — MT5 uses a single Wine prefix.
|
||||
- After optimization, reset `OptMode` in `terminal.ini` before the next backtest.
|
||||
|
||||
## Optimization Rules
|
||||
|
||||
- Optimizations run in the background (`nohup+disown`) — never add a timeout.
|
||||
- Use Model 0 only — Model 1 overfits martingale/grid strategies.
|
||||
- `.set` files must be UTF-16LE; UTF-8 causes MT5 to strip `||Y` parameter flags.
|
||||
CLAUDE_MD
|
||||
_ok "Written: $out (copy to your EA project root as CLAUDE.md)"
|
||||
}
|
||||
|
||||
_write_baseline_hook() {
|
||||
local out="$1"
|
||||
cat > "$out" <<'HOOK_SH'
|
||||
#!/usr/bin/env bash
|
||||
# .claude/hooks/user-prompt-submit.sh
|
||||
# Injects the current production baseline into every Claude Code prompt.
|
||||
#
|
||||
# Reads: config/baseline.json (gitignored — create and maintain manually)
|
||||
#
|
||||
# baseline.json schema:
|
||||
# {
|
||||
# "symbol": "XAUUSD.cent",
|
||||
# "period": "2024-01-01/2024-12-31",
|
||||
# "net_profit": 1250.50,
|
||||
# "profit_factor": 1.43,
|
||||
# "max_drawdown_pct": 18.2,
|
||||
# "sharpe_ratio": 0.87,
|
||||
# "total_trades": 342,
|
||||
# "notes": "Best config as of 2024-12-15"
|
||||
# }
|
||||
|
||||
HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(cd "${HOOK_DIR}/../.." && pwd)"
|
||||
BASELINE="${REPO_DIR}/config/baseline.json"
|
||||
|
||||
[[ -f "$BASELINE" ]] || exit 0
|
||||
|
||||
python3 - "$BASELINE" <<'PY'
|
||||
import json, sys
|
||||
|
||||
path = sys.argv[1]
|
||||
try:
|
||||
with open(path) as f:
|
||||
baseline = json.load(f)
|
||||
except Exception as e:
|
||||
sys.exit(0) # Malformed baseline — don't block the prompt
|
||||
|
||||
context = (
|
||||
"## Production Baseline (config/baseline.json)\n"
|
||||
"Compare all backtest results against these metrics. "
|
||||
"A result is an improvement only if it beats the baseline on the primary "
|
||||
"metric without significantly degrading secondary metrics.\n"
|
||||
"```json\n"
|
||||
+ json.dumps(baseline, indent=2)
|
||||
+ "\n```"
|
||||
)
|
||||
print(json.dumps({"context": context}))
|
||||
PY
|
||||
HOOK_SH
|
||||
}
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||
main() {
|
||||
# --claude-code: skip the config wizard — only generate Claude Code integration files
|
||||
if $CLAUDE_CODE; then
|
||||
_generate_claude_code
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
_bold "MT5-Quant setup — auto-detecting Wine and MT5 paths"
|
||||
echo "────────────────────────────────────────────────────"
|
||||
|
||||
# ── Check if config already exists ───────────────────────────────────────
|
||||
if [[ -f "$CONFIG_OUT" ]] && ! $AUTO_YES; then
|
||||
_yellow "Config already exists: $CONFIG_OUT"
|
||||
local ans
|
||||
ans=$(_ask "Overwrite?" "no")
|
||||
if [[ ! "$ans" =~ ^[Yy] ]]; then
|
||||
echo "Aborted — existing config unchanged."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Detect Wine ───────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
_bold "Scanning for Wine..."
|
||||
local wine_candidates=()
|
||||
while IFS= read -r line; do
|
||||
[[ -n "$line" ]] && wine_candidates+=("$line")
|
||||
done < <(_scan_wine_candidates)
|
||||
|
||||
local wine=""
|
||||
if [[ ${#wine_candidates[@]} -eq 0 ]]; then
|
||||
_fail "No Wine installation found."
|
||||
local install_ans="yes"
|
||||
if ! $AUTO_YES; then
|
||||
install_ans=$(_ask "Download and install MetaTrader 5 automatically?" "yes")
|
||||
fi
|
||||
if [[ "$install_ans" =~ ^[Yy] ]]; then
|
||||
_install_mt5 || { _red "Auto-install failed. Install MT5 manually and re-run setup.sh."; exit 1; }
|
||||
# Re-scan after install
|
||||
while IFS= read -r line; do
|
||||
[[ -n "$line" ]] && wine_candidates+=("$line")
|
||||
done < <(_scan_wine_candidates)
|
||||
fi
|
||||
if [[ ${#wine_candidates[@]} -eq 0 ]]; then
|
||||
if ! $AUTO_YES; then
|
||||
wine=$(_ask "Enter wine64 path manually (or press Enter to abort)")
|
||||
fi
|
||||
[[ -z "$wine" ]] && { _red "Cannot continue without Wine."; exit 1; }
|
||||
fi
|
||||
fi
|
||||
if [[ -z "$wine" ]]; then
|
||||
if [[ ${#wine_candidates[@]} -eq 1 ]]; then
|
||||
wine="${wine_candidates[0]}"
|
||||
_ok "Found: $wine"
|
||||
else
|
||||
_ok "Found ${#wine_candidates[@]} Wine installations"
|
||||
if $AUTO_YES; then
|
||||
wine="${wine_candidates[0]}"
|
||||
_ok "Auto-selected: $wine"
|
||||
else
|
||||
wine=$(_pick "Select Wine executable:" "${wine_candidates[@]}")
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Detect MT5 ────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
_bold "Scanning for MetaTrader 5 installations..."
|
||||
local mt5_candidates=()
|
||||
while IFS= read -r line; do
|
||||
[[ -n "$line" ]] && mt5_candidates+=("$line")
|
||||
done < <(_scan_mt5_prefixes)
|
||||
|
||||
local terminal_dir=""
|
||||
if [[ ${#mt5_candidates[@]} -eq 0 ]]; then
|
||||
_fail "No MetaTrader 5 installation found."
|
||||
# Wine was found but MT5 prefix doesn't exist yet — offer to run installer
|
||||
local install_ans2="yes"
|
||||
if ! $AUTO_YES; then
|
||||
install_ans2=$(_ask "Install MetaTrader 5 and initialize Wine prefix?" "yes")
|
||||
fi
|
||||
if [[ "$install_ans2" =~ ^[Yy] ]]; then
|
||||
_install_mt5 || true
|
||||
# Re-scan
|
||||
while IFS= read -r line; do
|
||||
[[ -n "$line" ]] && mt5_candidates+=("$line")
|
||||
done < <(_scan_mt5_prefixes)
|
||||
fi
|
||||
if [[ ${#mt5_candidates[@]} -eq 0 ]]; then
|
||||
if ! $AUTO_YES; then
|
||||
terminal_dir=$(_ask "Enter terminal_dir path manually (or press Enter to abort)")
|
||||
fi
|
||||
[[ -z "$terminal_dir" ]] && { _red "Cannot continue without terminal_dir."; exit 1; }
|
||||
fi
|
||||
fi
|
||||
if [[ -z "$terminal_dir" ]]; then
|
||||
if [[ ${#mt5_candidates[@]} -eq 1 ]]; then
|
||||
terminal_dir="${mt5_candidates[0]}"
|
||||
_ok "Found: $terminal_dir"
|
||||
else
|
||||
_ok "Found ${#mt5_candidates[@]} MT5 installations"
|
||||
# Auto-pick: highest activity score
|
||||
local best
|
||||
best=$(printf '%s\n' "${mt5_candidates[@]}" | _best_mt5_dir)
|
||||
if $AUTO_YES; then
|
||||
terminal_dir="$best"
|
||||
_ok "Auto-selected (most active): $terminal_dir"
|
||||
else
|
||||
# Show scores for each candidate
|
||||
echo ""
|
||||
local i
|
||||
for i in "${!mt5_candidates[@]}"; do
|
||||
local d="${mt5_candidates[$i]}"
|
||||
local score
|
||||
score=$(_score_mt5_dir "$d")
|
||||
printf ' [%d] %s (%d files)\n' "$((i+1))" "$d" "$score" >&2
|
||||
[[ "$d" == "$best" ]] && printf ' ^ most active\n' >&2
|
||||
done
|
||||
terminal_dir=$(_pick "Select MT5 installation:" "${mt5_candidates[@]}")
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Display mode ──────────────────────────────────────────────────────────
|
||||
local display_mode
|
||||
display_mode=$(_detect_display_mode)
|
||||
|
||||
# ── Validate ──────────────────────────────────────────────────────────────
|
||||
if ! _validate "$wine" "$terminal_dir"; then
|
||||
_red "Validation failed. Config NOT written."
|
||||
if ! $AUTO_YES; then
|
||||
local ans
|
||||
ans=$(_ask "Write config anyway?" "no")
|
||||
[[ ! "$ans" =~ ^[Yy] ]] && exit 1
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Write YAML ────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
_bold "Writing config..."
|
||||
_write_yaml "$wine" "$terminal_dir" "$display_mode" "$KEEP_LAST"
|
||||
_ok "Written: $CONFIG_OUT"
|
||||
echo " Tip: see config/example.set for optimization .set file format"
|
||||
|
||||
# ── Register with Claude Code ──────────────────────────────────────────────
|
||||
_offer_mcp_register
|
||||
|
||||
echo ""
|
||||
_green "Setup complete!"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ── MCP registration ──────────────────────────────────────────────────────────
|
||||
_offer_mcp_register() {
|
||||
echo ""
|
||||
_bold "Registering with Claude Code..."
|
||||
|
||||
if ! command -v claude &>/dev/null; then
|
||||
_warn "claude CLI not found — register manually:"
|
||||
echo ""
|
||||
echo " claude mcp add mt5-quant -- python3 \"${REPO_DIR}/server/main.py\""
|
||||
echo ""
|
||||
return
|
||||
fi
|
||||
|
||||
local register=true
|
||||
if ! $AUTO_YES; then
|
||||
local ans
|
||||
ans=$(_ask "Register mt5-quant with Claude Code now?" "yes")
|
||||
[[ ! "$ans" =~ ^[Yy] ]] && register=false
|
||||
fi
|
||||
|
||||
if $register; then
|
||||
local out
|
||||
out=$(claude mcp add mt5-quant -- python3 "${REPO_DIR}/server/main.py" 2>&1) || true
|
||||
if echo "$out" | grep -qi "already\|exists"; then
|
||||
_ok "Already registered (no change needed)"
|
||||
elif echo "$out" | grep -qi "error\|failed"; then
|
||||
_warn "Registration failed: $out"
|
||||
echo " Run manually: claude mcp add mt5-quant -- python3 \"${REPO_DIR}/server/main.py\""
|
||||
else
|
||||
_ok "Registered: claude mcp add mt5-quant"
|
||||
fi
|
||||
else
|
||||
echo " Skipped. Run manually:"
|
||||
echo " claude mcp add mt5-quant -- python3 \"${REPO_DIR}/server/main.py\""
|
||||
fi
|
||||
}
|
||||
|
||||
main
|
||||
Reference in New Issue
Block a user