2130 lines
93 KiB
Python
2130 lines
93 KiB
Python
import os
|
|
import sys
|
|
import json
|
|
import time
|
|
import shutil
|
|
import threading
|
|
import datetime as _dt_mod
|
|
from datetime import datetime
|
|
import tkinter as tk
|
|
from tkinter import ttk, messagebox, filedialog
|
|
import yaml
|
|
import subprocess
|
|
import ctypes
|
|
import ctypes.wintypes
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'scripts'))
|
|
_IMPORT_ERRORS: list = []
|
|
try:
|
|
from mt5_paths import resolve_mt5_settings
|
|
from ini_generator import INIGenerator
|
|
from mt5_auto_runner import MT5AutoRunner
|
|
from result_parser import ResultParser
|
|
from report_generator import ReportGenerator
|
|
except Exception as _e:
|
|
_IMPORT_ERRORS.append(repr(_e))
|
|
sys.stderr.write(f"[IMPORT FAIL] {_e}\n")
|
|
resolve_mt5_settings = None # type: ignore
|
|
INIGenerator = None # type: ignore
|
|
MT5AutoRunner = None # type: ignore
|
|
ResultParser = None # type: ignore
|
|
ReportGenerator = None # type: ignore
|
|
|
|
|
|
def _gui_log(msg: str) -> None:
|
|
"""输出到 stdout 同时也追加到 logs/gui_run.log。"""
|
|
print(f"[gui] {msg}", flush=True)
|
|
try:
|
|
os.makedirs("logs", exist_ok=True)
|
|
with open("logs/gui_run.log", "a", encoding="utf-8") as f:
|
|
f.write(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}\n")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now().strftime('%H:%M:%S')
|
|
|
|
|
|
_user32 = ctypes.windll.user32
|
|
_kernel32 = ctypes.windll.kernel32
|
|
|
|
SW_MINIMIZE = 6
|
|
SWP_NOMOVE = 0x0002
|
|
SWP_NOSIZE = 0x0001
|
|
SWP_NOACTIVATE = 0x0010
|
|
SWP_NOZORDER = 0x0004
|
|
SWP_FRAMECHANGED = 0x0020
|
|
|
|
_EnumWindowsProc = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM)
|
|
|
|
|
|
def _mt5_startupinfo():
|
|
si = subprocess.STARTUPINFO()
|
|
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
si.wShowWindow = 6
|
|
return si
|
|
|
|
|
|
def _find_mt5_hwnds(pid, max_seconds=3.0):
|
|
hwnds = []
|
|
deadline = time.time() + max_seconds
|
|
while time.time() < deadline and not hwnds:
|
|
def cb(hwnd, _lParam):
|
|
owner_pid = ctypes.wintypes.DWORD()
|
|
_user32.GetWindowThreadProcessId(hwnd, ctypes.byref(owner_pid))
|
|
if owner_pid.value != pid or not _user32.IsWindowVisible(hwnd):
|
|
return True
|
|
length = _user32.GetWindowTextLengthW(hwnd)
|
|
if length <= 0:
|
|
hwnds.append(hwnd)
|
|
return True
|
|
buf = ctypes.create_unicode_buffer(length + 1)
|
|
_user32.GetWindowTextW(hwnd, buf, length + 1)
|
|
title = buf.value
|
|
if "MetaTrader" in title or "Strategy" in title or "Tester" in title:
|
|
hwnds.append(hwnd)
|
|
return True
|
|
_user32.EnumWindows(_EnumWindowsProc(cb), 0)
|
|
if not hwnds:
|
|
time.sleep(0.2)
|
|
return hwnds
|
|
|
|
|
|
def _minimize_mt5_windows(pid, max_seconds=20.0):
|
|
deadline = time.time() + max_seconds
|
|
while time.time() < deadline:
|
|
for hwnd in _find_mt5_hwnds(pid, max_seconds=0.5):
|
|
_user32.ShowWindow(hwnd, SW_MINIMIZE)
|
|
_user32.SetWindowPos(
|
|
hwnd, 0, 0, 0, 0, 0,
|
|
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_NOZORDER | SWP_FRAMECHANGED,
|
|
)
|
|
time.sleep(0.3)
|
|
|
|
|
|
def _clear_mt5_chart_workspace(data_dir):
|
|
if not data_dir:
|
|
return
|
|
charts_root = os.path.join(data_dir, "MQL5", "Profiles", "Charts")
|
|
if not os.path.isdir(charts_root):
|
|
return
|
|
for entry in os.listdir(charts_root):
|
|
profile_dir = os.path.join(charts_root, entry)
|
|
if not os.path.isdir(profile_dir):
|
|
continue
|
|
for fname in os.listdir(profile_dir):
|
|
if fname.startswith("chart") and fname.endswith(".chr"):
|
|
try:
|
|
os.remove(os.path.join(profile_dir, fname))
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _launch_mt5(mt5_path, ini_path, data_dir):
|
|
_clear_mt5_chart_workspace(data_dir)
|
|
process = subprocess.Popen(
|
|
[mt5_path, "/config:" + os.path.abspath(ini_path)],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
startupinfo=_mt5_startupinfo(),
|
|
)
|
|
threading.Thread(
|
|
target=_minimize_mt5_windows, args=(process.pid,), daemon=True
|
|
).start()
|
|
return process
|
|
|
|
|
|
class MT5BacktestGUI:
|
|
_CONFIG_FILE = "config/ea_configs.yaml"
|
|
|
|
def __init__(self, root):
|
|
self.root = root
|
|
self.root.title("MT5 EA 批量回测工具")
|
|
self.root.geometry("900x2000")
|
|
|
|
self.config = self._load_or_default_config()
|
|
self.scanned_eas = []
|
|
self.opt_ea_paths = {}
|
|
self._scan_eas()
|
|
|
|
self._create_widgets()
|
|
self._load_config_to_gui()
|
|
|
|
def _load_or_default_config(self):
|
|
"""已保存过配置就读 YAML;第一次用走默认(终端路径留空让用户填)。"""
|
|
if os.path.isfile(self._CONFIG_FILE):
|
|
try:
|
|
with open(self._CONFIG_FILE, "r", encoding="utf-8") as f:
|
|
saved = yaml.safe_load(f) or {}
|
|
default = self._load_default_config()
|
|
# 浅合并:saved 覆盖 default;缺失字段用 default 补全
|
|
for k in ("backtest_settings", "mt5_settings", "execution"):
|
|
if k in saved:
|
|
default[k].update(saved[k])
|
|
if "eas" in saved:
|
|
default["eas"] = saved["eas"]
|
|
# 占位符解析(仅做 {PROJECT_ROOT} 展开,无自动探测)
|
|
if resolve_mt5_settings:
|
|
default["mt5_settings"] = resolve_mt5_settings(default["mt5_settings"])
|
|
_gui_log(f"已加载保存的配置: {self._CONFIG_FILE}")
|
|
return default
|
|
except Exception as e:
|
|
_gui_log(f"读配置失败 ({e}),使用默认")
|
|
return self._load_default_config()
|
|
|
|
def _load_default_config(self):
|
|
return {
|
|
"eas": [],
|
|
"backtest_settings": {
|
|
"symbols": [
|
|
"EURUSD", "GBPUSD", "USDJPY", "USDCHF", "AUDUSD", "USDCAD", "NZDUSD",
|
|
"GBPJPY", "EURJPY", "AUDJPY", "EURGBP", "EURAUD", "GBPAUD",
|
|
"XAUUSD", "XAGUSD",
|
|
"US30", "US500", "USTEC",
|
|
"BTCUSD", "ETHUSD"
|
|
],
|
|
"timeframes": ["M1", "H1"],
|
|
"date_range": {"from": "2026.01.01", "to": "2026.01.04"},
|
|
"model": 0,
|
|
"execution_delay": 0,
|
|
"deposit": 10000,
|
|
"leverage": "1:100",
|
|
"currency": "USD",
|
|
"visual": 0,
|
|
"replace_report": True,
|
|
"shutdown_terminal": True,
|
|
"optimization": 0,
|
|
"forward_mode": 0,
|
|
"forward_date": ""
|
|
},
|
|
"mt5_settings": (resolve_mt5_settings({
|
|
"terminal_path": "",
|
|
"data_dir": "",
|
|
"reports_dir": "{PROJECT_ROOT}/reports",
|
|
"ini_dir": "{PROJECT_ROOT}/config/generated",
|
|
}) if resolve_mt5_settings else {
|
|
"terminal_path": "",
|
|
"data_dir": "",
|
|
"reports_dir": "reports",
|
|
"ini_dir": os.path.join("config", "generated"),
|
|
}),
|
|
"execution": {
|
|
"timeout_per_test": 30,
|
|
"skip_existing": True,
|
|
"kill_between": True
|
|
}
|
|
}
|
|
|
|
def _scan_eas(self):
|
|
data_dir = self.config["mt5_settings"]["data_dir"]
|
|
experts_dir = os.path.join(data_dir, "MQL5", "Experts") if data_dir else ""
|
|
self.scanned_eas = []
|
|
if experts_dir and os.path.exists(experts_dir):
|
|
for root, dirs, files in os.walk(experts_dir):
|
|
for f in files:
|
|
if f.endswith('.ex5'):
|
|
rel_path = os.path.relpath(os.path.join(root, f), experts_dir)
|
|
self.scanned_eas.append(rel_path)
|
|
else:
|
|
print(f"[scan] MT5 Experts dir not found: {experts_dir!r} (data_dir={data_dir!r})")
|
|
|
|
def _create_widgets(self):
|
|
notebook = ttk.Notebook(self.root)
|
|
notebook.pack(fill='both', expand=True, padx=5, pady=5)
|
|
|
|
tab_basic = ttk.Frame(notebook)
|
|
tab_execution = ttk.Frame(notebook)
|
|
tab_reports = ttk.Frame(notebook)
|
|
tab_optimizer = ttk.Frame(notebook)
|
|
notebook.add(tab_basic, text="回测配置")
|
|
notebook.add(tab_execution, text="执行选项")
|
|
notebook.add(tab_reports, text="报告查看")
|
|
notebook.add(tab_optimizer, text="参数优化")
|
|
|
|
self._create_basic_tab(tab_basic)
|
|
self._create_execution_tab(tab_execution)
|
|
self._create_reports_tab(tab_reports)
|
|
self._create_optimizer_tab(tab_optimizer)
|
|
self.notebook = notebook
|
|
|
|
btn_frame = ttk.Frame(self.root)
|
|
btn_frame.pack(fill='x', padx=5, pady=5)
|
|
|
|
ttk.Button(btn_frame, text="开始回测", command=self._on_start_backtest).pack(side='left', padx=5)
|
|
ttk.Button(btn_frame, text="保存配置", command=self._on_save_config).pack(side='left', padx=5)
|
|
ttk.Button(btn_frame, text="查看报告", command=self._on_view_reports).pack(side='left', padx=5)
|
|
ttk.Button(btn_frame, text="参数优化", command=self._on_start_optimizer).pack(side='left', padx=5)
|
|
ttk.Button(btn_frame, text="重新扫描EA", command=self._on_rescan_eas).pack(side='right', padx=5)
|
|
|
|
def _create_basic_tab(self, parent):
|
|
main_frame = ttk.Frame(parent)
|
|
main_frame.pack(fill='both', expand=True)
|
|
|
|
ea_frame = ttk.LabelFrame(main_frame, text="EA 选择 (勾选多选)")
|
|
ea_frame.pack(fill='x', padx=5, pady=5)
|
|
|
|
ttk.Label(ea_frame, text="已扫描到 %d 个EA,勾选需要回测的EA" % len(self.scanned_eas)).pack(anchor='w', padx=5, pady=2)
|
|
|
|
search_frame = ttk.Frame(ea_frame)
|
|
search_frame.pack(fill='x', padx=5, pady=2)
|
|
ttk.Label(search_frame, text="搜索:").pack(side='left', padx=2)
|
|
self.ea_search_var = tk.StringVar()
|
|
self.ea_search_var.trace('w', lambda *args: self._filter_ea_list())
|
|
ea_search_entry = ttk.Entry(search_frame, textvariable=self.ea_search_var, width=30)
|
|
ea_search_entry.pack(side='left', padx=2)
|
|
ttk.Button(search_frame, text="清除", command=lambda: self.ea_search_var.set('')).pack(side='left', padx=2)
|
|
|
|
container = ttk.Frame(ea_frame)
|
|
container.pack(fill='both', expand=True, padx=5, pady=5)
|
|
|
|
canvas = tk.Canvas(container, height=120)
|
|
canvas.pack(side='left', fill='both', expand=True)
|
|
|
|
scrollbar = ttk.Scrollbar(container, orient='vertical', command=canvas.yview)
|
|
scrollbar.pack(side='right', fill='y')
|
|
canvas['yscrollcommand'] = scrollbar.set
|
|
|
|
cb_window = tk.Frame(canvas)
|
|
canvas.create_window((0, 0), window=cb_window, anchor='nw')
|
|
|
|
self.ea_vars = {}
|
|
self.ea_cbs = {}
|
|
self._rebuild_ea_checkboxes(cb_window, canvas)
|
|
|
|
cb_window.update_idletasks()
|
|
canvas.configure(scrollregion=canvas.bbox("all"))
|
|
|
|
def on_mousewheel(event):
|
|
canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
|
|
canvas.bind("<MouseWheel>", on_mousewheel)
|
|
|
|
btn_frame = ttk.Frame(ea_frame)
|
|
btn_frame.pack(anchor='w', padx=5, pady=2)
|
|
ttk.Button(btn_frame, text="全选", command=self._select_all_eas).pack(side='left', padx=2)
|
|
ttk.Button(btn_frame, text="取消", command=self._deselect_all_eas).pack(side='left', padx=2)
|
|
ttk.Button(btn_frame, text="反选", command=self._invert_eas).pack(side='left', padx=2)
|
|
|
|
self._ea_canvas = canvas
|
|
self._ea_cb_window = cb_window
|
|
|
|
sym_frame = ttk.LabelFrame(main_frame, text="交易品种")
|
|
sym_frame.pack(fill='x', padx=5, pady=5)
|
|
|
|
self.symbol_vars = {}
|
|
for i, sym in enumerate([
|
|
"EURUSD", "GBPUSD", "USDJPY", "USDCAD", "USDCHF", "AUDUSD", "NZDUSD",
|
|
"EURGBP", "EURJPY", "GBPJPY", "EURCHF", "AUDJPY", "EURAUD", "GBPAUD",
|
|
"EURCAD", "GBPCAD", "NZDJPY", "AUDCAD", "CADJPY", "CHFJPY",
|
|
"AUDCHF", "CADCHF", "NZDCHF", "XAUUSD", "XAGUSD", "XBRUSD", "XTIUSD",
|
|
"XNGUSD", "BTCUSD", "ETHUSD", "LTCUSD",
|
|
"USDCNH", "USDHKD", "USDCZK", "USDDKK", "USDHUF", "USDMXN", "USDPLN",
|
|
"USDTHB", "NOKJPY", "SEKJPY", "XRPUSD", "XLMUSD",
|
|
"AUS200", "CHINA50", "ES35", "STOXX50", "F40", "HK50", "IT40", "JP225",
|
|
"UK100", "US2000", "US30", "US500", "USTEC", "CA60", "NETH25", "SE30",
|
|
"SWI20", "CHINAH", "NOR25", "TecDE30"
|
|
]):
|
|
var = tk.BooleanVar(value=sym in self.config["backtest_settings"]["symbols"])
|
|
cb = ttk.Checkbutton(sym_frame, text=sym, variable=var)
|
|
cb.grid(row=i//10, column=i%10, sticky='w', padx=2, pady=1)
|
|
self.symbol_vars[sym] = var
|
|
|
|
tf_frame = ttk.LabelFrame(main_frame, text="时间周期")
|
|
tf_frame.pack(fill='x', padx=5, pady=5)
|
|
|
|
self.tf_vars = {}
|
|
for i, tf in enumerate(["M1", "M5", "M15", "M30", "H1", "H4", "D1", "W1"]):
|
|
var = tk.BooleanVar(value=tf in self.config["backtest_settings"]["timeframes"])
|
|
cb = ttk.Checkbutton(tf_frame, text=tf, variable=var)
|
|
cb.grid(row=0, column=i, padx=3, pady=2)
|
|
self.tf_vars[tf] = var
|
|
|
|
date_model_frame = ttk.Frame(main_frame)
|
|
date_model_frame.pack(fill='x', padx=5, pady=5)
|
|
|
|
date_frame = ttk.LabelFrame(date_model_frame, text="回测日期")
|
|
date_frame.pack(side='left', fill='x', padx=5)
|
|
|
|
ttk.Label(date_frame, text="开始:").grid(row=0, column=0, sticky='w', padx=5)
|
|
self.from_date = ttk.Entry(date_frame, width=12)
|
|
self.from_date.insert(0, self.config["backtest_settings"]["date_range"]["from"])
|
|
self.from_date.grid(row=0, column=1, padx=5)
|
|
|
|
ttk.Label(date_frame, text="结束:").grid(row=0, column=2, sticky='w', padx=5)
|
|
self.to_date = ttk.Entry(date_frame, width=12)
|
|
self.to_date.insert(0, self.config["backtest_settings"]["date_range"]["to"])
|
|
self.to_date.grid(row=0, column=3, padx=5)
|
|
|
|
forward_frame = ttk.LabelFrame(date_model_frame, text="Forward 前向测试")
|
|
forward_frame.pack(side='left', fill='x', padx=5)
|
|
|
|
self.forward_var = tk.StringVar(value="No")
|
|
forward_values = ["No", "1/2", "1/3", "1/4", "Custom"]
|
|
ttk.Radiobutton(forward_frame, text="无 (No)", variable=self.forward_var, value="No").pack(anchor='w', padx=2)
|
|
ttk.Radiobutton(forward_frame, text="1/2", variable=self.forward_var, value="1/2").pack(anchor='w', padx=2)
|
|
ttk.Radiobutton(forward_frame, text="1/3", variable=self.forward_var, value="1/3").pack(anchor='w', padx=2)
|
|
ttk.Radiobutton(forward_frame, text="1/4", variable=self.forward_var, value="1/4").pack(anchor='w', padx=2)
|
|
|
|
custom_row = ttk.Frame(forward_frame)
|
|
custom_row.pack(fill='x', padx=2, pady=2)
|
|
ttk.Radiobutton(custom_row, text="自定义:", variable=self.forward_var, value="Custom").pack(side='left', padx=2)
|
|
self.forward_custom_date = ttk.Entry(custom_row, width=12)
|
|
self.forward_custom_date.insert(0, self.config["backtest_settings"].get("forward_date", ""))
|
|
self.forward_custom_date.pack(side='left', padx=2)
|
|
ttk.Label(custom_row, text="(日期)").pack(side='left')
|
|
|
|
model_frame = ttk.LabelFrame(date_model_frame, text="回测模式")
|
|
model_frame.pack(side='left', fill='x', padx=5)
|
|
|
|
self.model_var = tk.IntVar(value=self.config["backtest_settings"]["model"])
|
|
ttk.Radiobutton(model_frame, text="Every Tick (0)", variable=self.model_var, value=0).pack(anchor='w', padx=2)
|
|
ttk.Radiobutton(model_frame, text="1分钟OHLC (1)", variable=self.model_var, value=1).pack(anchor='w', padx=2)
|
|
ttk.Radiobutton(model_frame, text="真实Tick (4)", variable=self.model_var, value=4).pack(anchor='w', padx=2)
|
|
|
|
delay_frame = ttk.LabelFrame(date_model_frame, text="执行延迟")
|
|
delay_frame.pack(side='left', fill='x', padx=5)
|
|
|
|
self.delay_type = tk.IntVar(value=0)
|
|
ttk.Radiobutton(delay_frame, text="无延迟 (0)", variable=self.delay_type, value=0).pack(anchor='w', padx=2)
|
|
ttk.Radiobutton(delay_frame, text="随机延迟 (-1)", variable=self.delay_type, value=1).pack(anchor='w', padx=2)
|
|
delay_row = ttk.Frame(delay_frame)
|
|
delay_row.pack()
|
|
ttk.Radiobutton(delay_row, text="固定延迟:", variable=self.delay_type, value=2).pack(side='left', padx=2)
|
|
self.delay_value = ttk.Entry(delay_row, width=6)
|
|
self.delay_value.insert(0, "100")
|
|
self.delay_value.pack(side='left')
|
|
ttk.Label(delay_row, text="ms").pack(side='left')
|
|
|
|
acct_frame = ttk.LabelFrame(date_model_frame, text="账户")
|
|
acct_frame.pack(side='left', fill='x', padx=5)
|
|
|
|
ttk.Label(acct_frame, text="保证金:").pack(side='left', padx=2)
|
|
self.deposit = ttk.Entry(acct_frame, width=10)
|
|
self.deposit.insert(0, str(self.config["backtest_settings"]["deposit"]))
|
|
self.deposit.pack(side='left', padx=2)
|
|
ttk.Label(acct_frame, text="杠杆:").pack(side='left', padx=2)
|
|
self.leverage = ttk.Combobox(acct_frame, width=8, values=["1:10", "1:50", "1:100", "1:200", "1:500"])
|
|
self.leverage.set(self.config["backtest_settings"]["leverage"])
|
|
self.leverage.pack(side='left', padx=2)
|
|
ttk.Button(acct_frame, text="开始回测", command=self._on_start_backtest).pack(side='left', padx=5)
|
|
|
|
def _create_execution_tab(self, parent):
|
|
exec_frame = ttk.LabelFrame(parent, text="执行设置")
|
|
exec_frame.pack(fill='x', padx=5, pady=10)
|
|
|
|
ttk.Label(exec_frame, text="超时(分钟):").grid(row=0, column=0, sticky='w', padx=5, pady=3)
|
|
self.timeout = ttk.Entry(exec_frame, width=8)
|
|
self.timeout.insert(0, str(self.config["execution"]["timeout_per_test"]))
|
|
self.timeout.grid(row=0, column=1, sticky='w', padx=5, pady=3)
|
|
|
|
self.skip_var = tk.BooleanVar(value=self.config["execution"]["skip_existing"])
|
|
ttk.Checkbutton(exec_frame, text="跳过已完成的测试", variable=self.skip_var).grid(row=1, column=0, columnspan=2, sticky='w', padx=5, pady=3)
|
|
|
|
self.kill_var = tk.BooleanVar(value=self.config["execution"]["kill_between"])
|
|
ttk.Checkbutton(exec_frame, text="测试间关闭MT5进程", variable=self.kill_var).grid(row=2, column=0, columnspan=2, sticky='w', padx=5, pady=3)
|
|
|
|
self.visual_var = tk.BooleanVar(value=self.config["backtest_settings"]["visual"] == 1)
|
|
ttk.Checkbutton(exec_frame, text="可视化模式(慢)", variable=self.visual_var).grid(row=3, column=0, columnspan=2, sticky='w', padx=5, pady=3)
|
|
|
|
path_frame = ttk.LabelFrame(parent, text="路径设置")
|
|
path_frame.pack(fill='x', padx=5, pady=10)
|
|
|
|
ttk.Label(path_frame, text="MT5终端 (terminal64.exe):").grid(row=0, column=0, sticky='w', padx=5, pady=3)
|
|
self.mt5_path = ttk.Entry(path_frame, width=50)
|
|
self.mt5_path.insert(0, self.config["mt5_settings"].get("terminal_path", ""))
|
|
self.mt5_path.grid(row=0, column=1, padx=5, pady=3, sticky='ew')
|
|
ttk.Button(path_frame, text="浏览...", command=self._browse_mt5).grid(row=0, column=2, padx=5)
|
|
|
|
ttk.Label(path_frame, text="MT5数据目录 (含 MQL5/Experts 的那一层):").grid(row=1, column=0, sticky='w', padx=5, pady=3)
|
|
self.data_dir = ttk.Entry(path_frame, width=50)
|
|
self.data_dir.insert(0, self.config["mt5_settings"].get("data_dir", ""))
|
|
self.data_dir.grid(row=1, column=1, padx=5, pady=3, sticky='ew')
|
|
ttk.Button(path_frame, text="浏览...", command=self._browse_data).grid(row=1, column=2, padx=5)
|
|
|
|
ttk.Label(path_frame, text="报告输出目录:").grid(row=2, column=0, sticky='w', padx=5, pady=3)
|
|
self.reports_dir = ttk.Entry(path_frame, width=50)
|
|
self.reports_dir.insert(0, self.config["mt5_settings"].get("reports_dir", ""))
|
|
self.reports_dir.grid(row=2, column=1, padx=5, pady=3, sticky='ew')
|
|
ttk.Button(path_frame, text="浏览...", command=self._browse_reports).grid(row=2, column=2, padx=5)
|
|
|
|
path_hint = ttk.Label(path_frame,
|
|
text="提示: 数据目录 = File → 打开数据文件夹 (MT5 菜单里),里面要能看见 MQL5\\Experts 文件夹",
|
|
foreground='gray')
|
|
path_hint.grid(row=3, column=0, columnspan=3, sticky='w', padx=5, pady=(5, 0))
|
|
|
|
def _browse_mt5(self):
|
|
path = filedialog.askopenfilename(
|
|
title="选择 terminal64.exe",
|
|
filetypes=[("MT5 终端", "terminal64.exe"), ("所有文件", "*.*")],
|
|
initialdir=os.path.dirname(self.mt5_path.get()) if self.mt5_path.get() else None,
|
|
)
|
|
if path:
|
|
self.mt5_path.delete(0, tk.END)
|
|
self.mt5_path.insert(0, path)
|
|
|
|
def _browse_reports(self):
|
|
path = filedialog.askdirectory(initialdir=self.reports_dir.get())
|
|
if path:
|
|
self.reports_dir.delete(0, tk.END)
|
|
self.reports_dir.insert(0, path)
|
|
|
|
def _browse_data(self):
|
|
path = filedialog.askdirectory(initialdir=self.data_dir.get())
|
|
if path:
|
|
self.data_dir.delete(0, tk.END)
|
|
self.data_dir.insert(0, path)
|
|
|
|
def _select_all_eas(self):
|
|
for var in self.ea_vars.values():
|
|
var.set(True)
|
|
|
|
def _deselect_all_eas(self):
|
|
for var in self.ea_vars.values():
|
|
var.set(False)
|
|
|
|
def _invert_eas(self):
|
|
for var in self.ea_vars.values():
|
|
var.set(not var.get())
|
|
|
|
def _rebuild_ea_checkboxes(self, cb_window, canvas):
|
|
for widget in cb_window.winfo_children():
|
|
widget.destroy()
|
|
|
|
search_text = self.ea_search_var.get().lower()
|
|
filtered_eas = [ea for ea in self.scanned_eas if search_text in os.path.basename(ea).lower()]
|
|
|
|
self.ea_vars = {}
|
|
for i, ea in enumerate(filtered_eas):
|
|
var = tk.BooleanVar(value=False)
|
|
if ea in getattr(self, '_previous_ea_states', {}):
|
|
var.set(self._previous_ea_states[ea])
|
|
cb = ttk.Checkbutton(cb_window, text=os.path.basename(ea), variable=var)
|
|
cb.grid(row=i // 4, column=i % 4, sticky='w', padx=5, pady=1)
|
|
self.ea_vars[ea] = var
|
|
|
|
cb_window.update_idletasks()
|
|
if hasattr(self, '_ea_canvas'):
|
|
self._ea_canvas.configure(scrollregion=self._ea_canvas.bbox("all"))
|
|
|
|
def _filter_ea_list(self):
|
|
self._previous_ea_states = {ea: var.get() for ea, var in self.ea_vars.items()}
|
|
if hasattr(self, '_ea_cb_window') and hasattr(self, '_ea_canvas'):
|
|
self._rebuild_ea_checkboxes(self._ea_cb_window, self._ea_canvas)
|
|
|
|
def _get_selected_eas(self):
|
|
selected = []
|
|
for ea, var in self.ea_vars.items():
|
|
if var.get():
|
|
ea_name = os.path.splitext(os.path.basename(ea))[0]
|
|
selected.append({"name": ea_name, "filename": ea})
|
|
return selected
|
|
|
|
def _on_rescan_eas(self):
|
|
self._scan_eas()
|
|
for ea, cb in list(self.ea_vars.items())[:]:
|
|
if ea not in self.scanned_eas:
|
|
del self.ea_vars[ea]
|
|
for i, ea in enumerate(self.scanned_eas):
|
|
if ea not in self.ea_vars:
|
|
var = tk.BooleanVar(value=False)
|
|
self.ea_vars[ea] = var
|
|
messagebox.showinfo("扫描完成", "扫描到 %d 个EA文件" % len(self.scanned_eas))
|
|
|
|
def _load_config_to_gui(self):
|
|
pass
|
|
|
|
def _get_config(self):
|
|
symbols = [s for s, v in self.symbol_vars.items() if v.get()]
|
|
timeframes = [t for t, v in self.tf_vars.items() if v.get()]
|
|
|
|
delay = self.delay_type.get()
|
|
if delay == 0:
|
|
execution_delay = 0
|
|
elif delay == 1:
|
|
execution_delay = -1
|
|
else:
|
|
try:
|
|
execution_delay = int(self.delay_value.get())
|
|
except:
|
|
execution_delay = 0
|
|
|
|
forward = self.forward_var.get()
|
|
forward_mode_map = {"No": 0, "1/2": 1, "1/3": 2, "1/4": 3, "Custom": 4}
|
|
forward_mode = forward_mode_map.get(forward, 0)
|
|
forward_date = self.forward_custom_date.get() if forward == "Custom" else ""
|
|
|
|
return {
|
|
"eas": self._get_selected_eas(),
|
|
"backtest_settings": {
|
|
"symbols": symbols,
|
|
"timeframes": timeframes,
|
|
"date_range": {"from": self.from_date.get(), "to": self.to_date.get()},
|
|
"model": self.model_var.get(),
|
|
"execution_delay": execution_delay,
|
|
"deposit": float(self.deposit.get()),
|
|
"leverage": self.leverage.get(),
|
|
"currency": "USD",
|
|
"visual": 1 if self.visual_var.get() else 0,
|
|
"replace_report": True,
|
|
"shutdown_terminal": True,
|
|
"optimization": 0,
|
|
"forward_mode": forward_mode,
|
|
"forward_date": forward_date
|
|
},
|
|
"mt5_settings": {
|
|
"terminal_path": self.mt5_path.get(),
|
|
"data_dir": self.data_dir.get(),
|
|
"reports_dir": self.reports_dir.get(),
|
|
"ini_dir": os.path.join(os.path.dirname(self.reports_dir.get()), "config", "generated"),
|
|
},
|
|
"execution": {
|
|
"timeout_per_test": int(self.timeout.get()),
|
|
"skip_existing": self.skip_var.get(),
|
|
"kill_between": self.kill_var.get()
|
|
}
|
|
}
|
|
|
|
def _on_save_config(self):
|
|
config = self._get_config()
|
|
os.makedirs("config", exist_ok=True)
|
|
with open("config/ea_configs.yaml", 'w', encoding='utf-8') as f:
|
|
yaml.dump(config, f, allow_unicode=True)
|
|
messagebox.showinfo("保存成功", "配置已保存到 config/ea_configs.yaml")
|
|
|
|
def _on_start_backtest(self):
|
|
_gui_log("开始回测 button clicked")
|
|
if _IMPORT_ERRORS:
|
|
_gui_log(f"导入错误: {_IMPORT_ERRORS}")
|
|
messagebox.showerror("依赖缺失", f"导入失败: {_IMPORT_ERRORS[0]}\n请运行 pip install -r requirements.txt")
|
|
return
|
|
if not all([INIGenerator, MT5AutoRunner, ResultParser, ReportGenerator]):
|
|
messagebox.showerror("依赖缺失", "模块为 None(导入被吞)")
|
|
return
|
|
|
|
config = self._get_config()
|
|
_gui_log(f"config keys: {list(config.keys())}, eas={len(config.get('eas',[]))}, sym={len(config['backtest_settings']['symbols'])}, tf={len(config['backtest_settings']['timeframes'])}")
|
|
|
|
if not config["eas"]:
|
|
messagebox.showerror("错误", "请选择至少一个EA文件"); return
|
|
if not config["backtest_settings"]["symbols"]:
|
|
messagebox.showerror("错误", "请选择至少一个交易品种"); return
|
|
if not config["backtest_settings"]["timeframes"]:
|
|
messagebox.showerror("错误", "请选择至少一个时间周期"); return
|
|
|
|
os.makedirs("logs", exist_ok=True)
|
|
|
|
def _log(msg):
|
|
self.opt_log.insert('end', msg + "\n")
|
|
self.opt_log.see('end')
|
|
_gui_log(msg)
|
|
|
|
def _run():
|
|
try:
|
|
_log("=== 开始回测 ===")
|
|
generator = INIGenerator(config)
|
|
_log(f" INIGenerator OK, output_dir={generator.output_dir}")
|
|
ini_files = generator.generate_ini_files()
|
|
_log(f" 生成 INI: {len(ini_files)} 个")
|
|
|
|
if not ini_files:
|
|
_log(" [ERR] 未生成任何 INI — 检查 EA 路径与 MT5 Experts 目录")
|
|
return
|
|
|
|
runner = MT5AutoRunner(config)
|
|
reports_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "reports")
|
|
os.makedirs(reports_dir, exist_ok=True)
|
|
runner.reports_dir = reports_dir
|
|
_log(f" mt5_path={runner.mt5_path}")
|
|
_log(f" data_dir={runner.data_dir}")
|
|
_log(f" reports_dir={runner.reports_dir}")
|
|
_log(" 开始调用 MT5...(每个 EA/Symbol/TF 组合会启动一次 MT5 子进程,耐心等待)")
|
|
|
|
results = runner.run_full_auto(
|
|
ini_files,
|
|
skip_if_exists=config["execution"].get("skip_existing", False),
|
|
)
|
|
completed = sum(1 for r in results if r["status"] == "completed")
|
|
failed = [r for r in results if r["status"] not in ("completed", "already_completed")]
|
|
_log(f" 完成: {completed}/{len(results)}")
|
|
if failed:
|
|
_log(f" 失败: {len(failed)} 个")
|
|
for r in failed[:3]:
|
|
_log(f" - {r.get('ini_file','?')}: {r.get('error', r.get('status'))}")
|
|
|
|
if completed > 0:
|
|
parser = ResultParser(reports_dir)
|
|
parsed = parser.parse_all_reports()
|
|
_log(f" 解析: {len(parsed)} 个报告")
|
|
if parsed:
|
|
rg = ReportGenerator(parsed)
|
|
rg.generate_excel(os.path.join(reports_dir, "batch_summary.xlsx"))
|
|
rg.generate_csv(os.path.join(reports_dir, "batch_summary.csv"))
|
|
rg.generate_markdown(os.path.join(reports_dir, "batch_summary.md"))
|
|
_log(f" 报告: {reports_dir}")
|
|
|
|
_log("=== 全部完成 ===")
|
|
except Exception as e:
|
|
import traceback
|
|
tb = traceback.format_exc()
|
|
_log(f"[ERR] {e}")
|
|
_log(tb)
|
|
_gui_log(f"thread crashed: {e}\n{tb}")
|
|
|
|
threading.Thread(target=_run, daemon=True).start()
|
|
|
|
def _on_start_backtest_from_opt(self):
|
|
if not all([INIGenerator, MT5AutoRunner]):
|
|
messagebox.showerror("错误", "依赖未就绪,请检查 Python 环境")
|
|
return
|
|
ea_name = self.opt_ea_var.get()
|
|
if not ea_name:
|
|
messagebox.showwarning('警告', '请先选择一个EA')
|
|
return
|
|
symbol = self.opt_symbol.get()
|
|
period = self.opt_period.get()
|
|
from_date = self.opt_from_date.get()
|
|
to_date = self.opt_to_date.get()
|
|
model_str = self.opt_model.get()
|
|
model = int(model_str.split('(')[1].split(')')[0]) if '(' in model_str else 1
|
|
|
|
config = {
|
|
"eas": [{"name": ea_name, "filename": f"Experts\\{ea_name}.ex5"}],
|
|
"backtest_settings": {
|
|
"symbols": [symbol],
|
|
"timeframes": [period],
|
|
"date_range": {"from": from_date, "to": to_date},
|
|
"model": model,
|
|
"execution_mode": 0,
|
|
"execution_delay": 0,
|
|
"deposit": 10000,
|
|
"leverage": "1:100",
|
|
"currency": "USD",
|
|
"visual": 0,
|
|
"replace_report": True,
|
|
"shutdown_terminal": True,
|
|
"optimization": 0,
|
|
"forward_mode": 0,
|
|
"forward_date": "",
|
|
},
|
|
"mt5_settings": self.config["mt5_settings"],
|
|
"execution": self.config["execution"]
|
|
}
|
|
|
|
os.makedirs("logs", exist_ok=True)
|
|
|
|
def _run():
|
|
try:
|
|
self.opt_log.insert('end', f"=== 单EA回测: {ea_name} ===\n")
|
|
self.opt_log.see('end')
|
|
generator = INIGenerator(config)
|
|
ini_files = generator.generate_ini_files()
|
|
self.opt_log.insert('end', f" 生成 INI: {len(ini_files)}\n")
|
|
runner = MT5AutoRunner(config)
|
|
runner.reports_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "reports")
|
|
os.makedirs(runner.reports_dir, exist_ok=True)
|
|
results = runner.run_full_auto(ini_files, skip_if_exists=False)
|
|
completed = sum(1 for r in results if r["status"] == "completed")
|
|
self.opt_log.insert('end', f" 完成: {completed}/{len(results)}\n")
|
|
self.opt_log.insert('end', "=== 完成 ===\n")
|
|
self.opt_log.see('end')
|
|
except Exception as e:
|
|
import traceback
|
|
self.opt_log.insert('end', f"[ERR] {e}\n{traceback.format_exc()}\n")
|
|
self.opt_log.see('end')
|
|
|
|
threading.Thread(target=_run, daemon=True).start()
|
|
|
|
def _on_view_reports(self):
|
|
path = self.reports_dir.get()
|
|
if os.path.exists(path):
|
|
subprocess.Popen(['explorer', path])
|
|
else:
|
|
messagebox.showwarning("警告", "报告目录不存在")
|
|
|
|
def _on_start_optimizer(self):
|
|
self.notebook.select(3)
|
|
|
|
def _create_reports_tab(self, parent):
|
|
main_frame = ttk.Frame(parent)
|
|
main_frame.pack(fill='both', expand=True)
|
|
|
|
control_frame = ttk.Frame(main_frame)
|
|
control_frame.pack(fill='x', padx=5, pady=5)
|
|
|
|
ttk.Label(control_frame, text="报告目录:").pack(side='left', padx=5)
|
|
self.report_dir = ttk.Entry(control_frame, width=40)
|
|
self.report_dir.insert(0, self.config["mt5_settings"]["reports_dir"])
|
|
self.report_dir.pack(side='left', padx=5)
|
|
ttk.Button(control_frame, text="浏览", command=self._browse_report_dir).pack(side='left', padx=5)
|
|
ttk.Button(control_frame, text="刷新", command=self._refresh_reports).pack(side='left', padx=5)
|
|
|
|
search_frame = ttk.LabelFrame(main_frame, text="搜索和筛选")
|
|
search_frame.pack(fill='x', padx=5, pady=5)
|
|
|
|
search_row = ttk.Frame(search_frame)
|
|
search_row.pack(fill='x', padx=5, pady=3)
|
|
|
|
ttk.Label(search_row, text="搜索:").pack(side='left', padx=5)
|
|
self.search_var = tk.StringVar()
|
|
self.search_var.trace('w', lambda *args: self._apply_filter())
|
|
search_entry = ttk.Entry(search_row, textvariable=self.search_var, width=20)
|
|
search_entry.pack(side='left', padx=5)
|
|
ttk.Button(search_row, text="清除", command=lambda: self.search_var.set('')).pack(side='left', padx=2)
|
|
|
|
filter_row = ttk.Frame(search_frame)
|
|
filter_row.pack(fill='x', padx=5, pady=3)
|
|
|
|
ttk.Label(filter_row, text="EA筛选:").pack(side='left', padx=5)
|
|
self.filter_ea_var = tk.StringVar(value='全部')
|
|
self.filter_ea_combo = ttk.Combobox(filter_row, textvariable=self.filter_ea_var, width=20, state='readonly')
|
|
self.filter_ea_combo.pack(side='left', padx=5)
|
|
self.filter_ea_combo.bind('<<ComboboxSelected>>', lambda e: self._apply_filter())
|
|
|
|
ttk.Label(filter_row, text="品种:").pack(side='left', padx=10)
|
|
self.filter_symbol_var = tk.StringVar(value='全部')
|
|
self.filter_symbol_combo = ttk.Combobox(filter_row, textvariable=self.filter_symbol_var, width=10, state='readonly')
|
|
self.filter_symbol_combo.pack(side='left', padx=5)
|
|
self.filter_symbol_combo.bind('<<ComboboxSelected>>', lambda e: self._apply_filter())
|
|
|
|
ttk.Label(filter_row, text="周期:").pack(side='left', padx=10)
|
|
self.filter_period_var = tk.StringVar(value='全部')
|
|
self.filter_period_combo = ttk.Combobox(filter_row, textvariable=self.filter_period_var, width=8, state='readonly')
|
|
self.filter_period_combo.pack(side='left', padx=5)
|
|
self.filter_period_combo.bind('<<ComboboxSelected>>', lambda e: self._apply_filter())
|
|
|
|
ttk.Button(filter_row, text="重置筛选", command=self._reset_filter).pack(side='left', padx=10)
|
|
|
|
options_frame = ttk.LabelFrame(main_frame, text="排序选项")
|
|
options_frame.pack(fill='x', padx=5, pady=5)
|
|
|
|
sort_row = ttk.Frame(options_frame)
|
|
sort_row.pack(anchor='w', padx=5, pady=3)
|
|
|
|
ttk.Label(sort_row, text="排序字段:").pack(side='left', padx=5)
|
|
self.sort_field = ttk.Combobox(sort_row, width=15, values=[
|
|
"profit_factor", "net_profit", "gross_profit", "win_rate",
|
|
"total_trades", "max_drawdown", "sharpe_ratio", "expert", "symbol", "period"
|
|
])
|
|
self.sort_field.set("profit_factor")
|
|
self.sort_field.pack(side='left', padx=5)
|
|
|
|
self.sort_asc = tk.BooleanVar(value=False)
|
|
ttk.Radiobutton(sort_row, text="降序", variable=self.sort_asc, value=False).pack(side='left', padx=5)
|
|
ttk.Radiobutton(sort_row, text="升序", variable=self.sort_asc, value=True).pack(side='left', padx=5)
|
|
|
|
ttk.Button(sort_row, text="应用排序", command=self._apply_filter).pack(side='left', padx=10)
|
|
|
|
export_row = ttk.Frame(options_frame)
|
|
export_row.pack(fill='x', padx=5, pady=3)
|
|
|
|
ttk.Label(export_row, text="导出CSV:").pack(side='left', padx=5)
|
|
self.csv_path_var = tk.StringVar()
|
|
self.csv_path = ttk.Entry(export_row, textvariable=self.csv_path_var, width=50, state='readonly')
|
|
self.csv_path.pack(side='left', padx=5, fill='x', expand=True)
|
|
ttk.Button(export_row, text="另存为...", command=self._export_csv_as).pack(side='left', padx=2)
|
|
ttk.Button(export_row, text="导出", command=self._export_csv).pack(side='left', padx=2)
|
|
|
|
self.csv_export_dir = os.path.join(self.config["mt5_settings"]["reports_dir"], "exports")
|
|
self._update_csv_suggested_name()
|
|
|
|
tree_frame = ttk.Frame(main_frame)
|
|
tree_frame.pack(fill='both', expand=True, padx=5, pady=5)
|
|
|
|
columns = [
|
|
"expert", "symbol", "period", "net_profit", "gross_profit", "gross_loss",
|
|
"profit_factor", "win_rate", "total_trades", "max_drawdown", "sharpe_ratio"
|
|
]
|
|
self.report_tree = ttk.Treeview(tree_frame, columns=columns, show='headings', height=15, selectmode='extended')
|
|
|
|
for col in columns:
|
|
self.report_tree.heading(col, text=col)
|
|
self.report_tree.column(col, width=100)
|
|
|
|
scrollbar = ttk.Scrollbar(tree_frame, orient='vertical', command=self.report_tree.yview)
|
|
self.report_tree.configure(yscrollcommand=scrollbar.set)
|
|
|
|
self.report_tree.pack(side='left', fill='both', expand=True)
|
|
scrollbar.pack(side='right', fill='y')
|
|
|
|
self.report_tree.bind("<Double-1>", self._on_row_double_click)
|
|
self.report_tree.bind("<Button-3>", self._on_right_click)
|
|
|
|
self.report_data = []
|
|
self.tree_item_to_path = {}
|
|
self._create_context_menu()
|
|
self._refresh_reports()
|
|
|
|
def _browse_report_dir(self):
|
|
path = filedialog.askdirectory(initialdir=self.report_dir.get())
|
|
if path:
|
|
self.report_dir.delete(0, tk.END)
|
|
self.report_dir.insert(0, path)
|
|
self._refresh_reports()
|
|
|
|
def _refresh_reports(self):
|
|
for item in self.report_tree.get_children():
|
|
self.report_tree.delete(item)
|
|
|
|
sys.path.insert(0, os.path.join(os.getcwd(), 'scripts'))
|
|
from result_parser import ResultParser
|
|
|
|
reports_dir = self.report_dir.get()
|
|
if not os.path.exists(reports_dir):
|
|
return
|
|
|
|
parser = ResultParser(reports_dir)
|
|
self.report_data = parser.parse_all_reports()
|
|
self._update_filter_options()
|
|
self._apply_filter()
|
|
|
|
def _get_filtered_sorted_data(self):
|
|
if not self.report_data:
|
|
return []
|
|
|
|
search_text = self.search_var.get().lower()
|
|
filter_ea = self.filter_ea_var.get()
|
|
filter_symbol = self.filter_symbol_var.get()
|
|
filter_period = self.filter_period_var.get()
|
|
|
|
filtered_data = []
|
|
for r in self.report_data:
|
|
info = r.get("test_info", {})
|
|
expert = info.get("expert", "")
|
|
symbol = info.get("symbol", "")
|
|
period = info.get("period", "")
|
|
|
|
if search_text and search_text not in expert.lower() and search_text not in symbol.lower():
|
|
continue
|
|
if filter_ea != '全部' and expert != filter_ea:
|
|
continue
|
|
if filter_symbol != '全部' and symbol != filter_symbol:
|
|
continue
|
|
if filter_period != '全部' and period != filter_period:
|
|
continue
|
|
|
|
filtered_data.append(r)
|
|
|
|
sort_by = self.sort_field.get()
|
|
reverse = not self.sort_asc.get()
|
|
|
|
def _coerce_numeric(val):
|
|
if isinstance(val, bool):
|
|
return float(val)
|
|
if isinstance(val, (int, float)):
|
|
return float(val)
|
|
if val is None or val == "":
|
|
return 0.0
|
|
try:
|
|
return float(str(val).replace(",", ""))
|
|
except (ValueError, TypeError):
|
|
return None
|
|
|
|
def _to_str(val):
|
|
if val is None:
|
|
return ""
|
|
if isinstance(val, bool):
|
|
return "1" if val else "0"
|
|
if isinstance(val, (int, float)):
|
|
return str(val)
|
|
return str(val).lower()
|
|
|
|
sort_as_number = False
|
|
for r in filtered_data:
|
|
metrics = r.get("metrics", {})
|
|
if sort_by in metrics:
|
|
v = metrics[sort_by]
|
|
if isinstance(v, (int, float)) and not isinstance(v, bool):
|
|
sort_as_number = True
|
|
break
|
|
if isinstance(v, str) and v.strip():
|
|
try:
|
|
float(v.replace(",", ""))
|
|
sort_as_number = True
|
|
break
|
|
except ValueError:
|
|
pass
|
|
if not sort_as_number:
|
|
for r in filtered_data:
|
|
test_info = r.get("test_info", {})
|
|
if sort_by in test_info:
|
|
v = test_info[sort_by]
|
|
if isinstance(v, (int, float)) and not isinstance(v, bool):
|
|
sort_as_number = True
|
|
break
|
|
if isinstance(v, str) and v.strip():
|
|
try:
|
|
float(v.replace(",", ""))
|
|
sort_as_number = True
|
|
break
|
|
except ValueError:
|
|
pass
|
|
|
|
def get_sort_key(r):
|
|
metrics = r.get("metrics", {})
|
|
test_info = r.get("test_info", {})
|
|
if sort_as_number:
|
|
if sort_by in metrics:
|
|
n = _coerce_numeric(metrics[sort_by])
|
|
if n is not None:
|
|
return n
|
|
if sort_by in test_info:
|
|
n = _coerce_numeric(test_info[sort_by])
|
|
if n is not None:
|
|
return n
|
|
return 0.0
|
|
else:
|
|
if sort_by in metrics:
|
|
return _to_str(metrics[sort_by])
|
|
if sort_by in test_info:
|
|
return _to_str(test_info[sort_by])
|
|
return ""
|
|
|
|
return sorted(filtered_data, key=get_sort_key, reverse=reverse)
|
|
|
|
def _apply_filter(self):
|
|
for item in self.report_tree.get_children():
|
|
self.report_tree.delete(item)
|
|
self.tree_item_to_path = {}
|
|
|
|
if not self.report_data:
|
|
return
|
|
|
|
sorted_data = self._get_filtered_sorted_data()
|
|
if hasattr(self, 'csv_path_var'):
|
|
self._update_csv_suggested_name()
|
|
|
|
for r in sorted_data:
|
|
info = r.get("test_info", {})
|
|
metrics = r.get("metrics", {})
|
|
|
|
values = [
|
|
info.get("expert", ""),
|
|
info.get("symbol", ""),
|
|
info.get("period", ""),
|
|
metrics.get("net_profit", ""),
|
|
metrics.get("gross_profit", ""),
|
|
metrics.get("gross_loss", ""),
|
|
metrics.get("profit_factor", ""),
|
|
metrics.get("win_rate", ""),
|
|
metrics.get("total_trades", ""),
|
|
metrics.get("max_drawdown", ""),
|
|
metrics.get("sharpe_ratio", "")
|
|
]
|
|
iid = self.report_tree.insert("", "end", values=values)
|
|
self.tree_item_to_path[iid] = r.get("file_path", "")
|
|
|
|
def _reset_filter(self):
|
|
self.search_var.set('')
|
|
self.filter_ea_var.set('全部')
|
|
self.filter_symbol_var.set('全部')
|
|
self.filter_period_var.set('全部')
|
|
self._apply_filter()
|
|
|
|
def _update_filter_options(self):
|
|
ea_list = ['全部']
|
|
symbol_list = ['全部']
|
|
period_list = ['全部']
|
|
|
|
for r in self.report_data:
|
|
info = r.get("test_info", {})
|
|
ea = info.get("expert", "")
|
|
symbol = info.get("symbol", "")
|
|
period = info.get("period", "")
|
|
if ea and ea not in ea_list:
|
|
ea_list.append(ea)
|
|
if symbol and symbol not in symbol_list:
|
|
symbol_list.append(symbol)
|
|
if period and period not in period_list:
|
|
period_list.append(period)
|
|
|
|
self.filter_ea_combo['values'] = ea_list
|
|
self.filter_symbol_combo['values'] = symbol_list
|
|
self.filter_period_combo['values'] = period_list
|
|
|
|
def _suggest_csv_filename(self):
|
|
import re
|
|
ts = time.strftime("%Y%m%d_%H%M%S")
|
|
parts = []
|
|
ea = self.filter_ea_var.get()
|
|
if ea and ea != '全部':
|
|
parts.append(ea)
|
|
sym = self.filter_symbol_var.get()
|
|
if sym and sym != '全部':
|
|
parts.append(sym)
|
|
per = self.filter_period_var.get()
|
|
if per and per != '全部':
|
|
parts.append(per)
|
|
search = self.search_var.get().strip()
|
|
if search:
|
|
parts.append("q-" + search[:10])
|
|
|
|
cleaned = []
|
|
for p in parts:
|
|
s = re.sub(r'[^\w\u4e00-\u9fff\-]', '_', p).strip('_')
|
|
if s:
|
|
cleaned.append(s)
|
|
sig = "_".join(cleaned) if cleaned else "all"
|
|
if len(sig) > 60:
|
|
sig = sig[:60].rstrip('_')
|
|
return f"results_{ts}_{sig}.csv"
|
|
|
|
def _get_csv_export_dir(self):
|
|
base = self.csv_export_dir
|
|
try:
|
|
os.makedirs(base, exist_ok=True)
|
|
except Exception:
|
|
base = self.report_dir.get()
|
|
os.makedirs(base, exist_ok=True)
|
|
return base
|
|
|
|
def _update_csv_suggested_name(self):
|
|
name = self._suggest_csv_filename()
|
|
self.csv_path_var.set(os.path.join(self._get_csv_export_dir(), name))
|
|
|
|
def _export_csv(self):
|
|
sys.path.insert(0, os.path.join(os.getcwd(), 'scripts'))
|
|
from result_parser import ResultParser
|
|
|
|
reports_dir = self.report_dir.get()
|
|
|
|
filtered_results = self._get_filtered_sorted_data()
|
|
if not filtered_results:
|
|
messagebox.showwarning("警告", "当前筛选条件下没有数据可导出")
|
|
return
|
|
|
|
self._update_csv_suggested_name()
|
|
csv_path = self.csv_path_var.get()
|
|
target_dir = os.path.dirname(csv_path) or self._get_csv_export_dir()
|
|
os.makedirs(target_dir, exist_ok=True)
|
|
|
|
if os.path.exists(csv_path):
|
|
if not messagebox.askyesno("文件已存在", f"目标文件已存在,是否覆盖?\n{csv_path}"):
|
|
return
|
|
|
|
sort_by = self.sort_field.get()
|
|
reverse = not self.sort_asc.get()
|
|
|
|
parser = ResultParser(reports_dir)
|
|
parser.export_to_csv(filtered_results, csv_path, sort_by=sort_by, reverse=reverse)
|
|
messagebox.showinfo("导出成功", f"已按当前筛选条件导出 {len(filtered_results)} 条记录到:\n{csv_path}")
|
|
self._update_csv_suggested_name()
|
|
|
|
def _export_csv_as(self):
|
|
self._update_csv_suggested_name()
|
|
default_name = os.path.basename(self.csv_path_var.get())
|
|
default_dir = os.path.dirname(self.csv_path_var.get()) or self._get_csv_export_dir()
|
|
os.makedirs(default_dir, exist_ok=True)
|
|
path = filedialog.asksaveasfilename(
|
|
title="导出 CSV",
|
|
defaultextension=".csv",
|
|
initialfile=default_name,
|
|
initialdir=default_dir,
|
|
filetypes=[("CSV 文件", "*.csv"), ("所有文件", "*.*")]
|
|
)
|
|
if not path:
|
|
return
|
|
self.csv_path_var.set(path)
|
|
|
|
def _on_row_double_click(self, event):
|
|
selection = self.report_tree.selection()
|
|
if not selection:
|
|
return
|
|
path = self.tree_item_to_path.get(selection[0], "")
|
|
if path and os.path.exists(path):
|
|
subprocess.Popen(['notepad', path])
|
|
return
|
|
values = self.report_tree.item(selection[0])["values"]
|
|
label = f"{values[0]} {values[1]} {values[2]}" if len(values) >= 3 else ""
|
|
messagebox.showwarning("警告", f"未找到对应的报告文件: {label}")
|
|
|
|
def _create_context_menu(self):
|
|
self.context_menu = tk.Menu(self.root, tearoff=0)
|
|
self.context_menu.add_command(label="打开报告", command=self._open_selected_report)
|
|
self.context_menu.add_command(label="打开所在目录", command=self._open_report_directory)
|
|
self.context_menu.add_separator()
|
|
self.context_menu.add_command(label="复制报告文字", command=self._copy_selected_reports_text)
|
|
self.context_menu.add_command(label="复制报告到...", command=self._copy_reports_to)
|
|
self.context_menu.add_command(label="删除选中报告", command=self._delete_selected_reports)
|
|
self.context_menu.add_separator()
|
|
self.context_menu.add_command(label="全选", command=lambda: self.report_tree.selection_set(self.report_tree.get_children()))
|
|
self.context_menu.add_command(label="取消选择", command=lambda: self.report_tree.selection_remove(self.report_tree.selection()))
|
|
|
|
def _on_right_click(self, event):
|
|
self.context_menu.post(event.x_root, event.y_root)
|
|
|
|
def _get_selected_report_paths(self):
|
|
selection = self.report_tree.selection()
|
|
if not selection:
|
|
return []
|
|
paths = []
|
|
for item in selection:
|
|
p = self.tree_item_to_path.get(item, "")
|
|
if p and os.path.exists(p):
|
|
paths.append(p)
|
|
return paths
|
|
|
|
def _open_selected_report(self):
|
|
paths = self._get_selected_report_paths()
|
|
if not paths:
|
|
messagebox.showwarning("警告", "未找到报告文件")
|
|
return
|
|
for path in paths[:1]:
|
|
subprocess.Popen(['notepad', path])
|
|
|
|
def _open_report_directory(self):
|
|
selection = self.report_tree.selection()
|
|
if not selection:
|
|
return
|
|
path = self.tree_item_to_path.get(selection[0], "")
|
|
if path and os.path.exists(path):
|
|
os.startfile(os.path.dirname(path))
|
|
return
|
|
values = self.report_tree.item(selection[0])["values"]
|
|
label = f"{values[0]} {values[1]} {values[2]}" if len(values) >= 3 else ""
|
|
messagebox.showwarning("警告", f"未找到对应的报告文件: {label}")
|
|
|
|
def _copy_selected_reports_text(self):
|
|
selection = self.report_tree.selection()
|
|
if not selection:
|
|
messagebox.showwarning("警告", "请先选择要复制文字的报告")
|
|
return
|
|
|
|
columns = list(self.report_tree["columns"])
|
|
headers = [self.report_tree.heading(col, "text") or col for col in columns]
|
|
|
|
rows = []
|
|
for item in selection:
|
|
values = self.report_tree.item(item, "values")
|
|
rows.append([str(v) if v is not None else "" for v in values])
|
|
|
|
col_widths = [len(h) for h in headers]
|
|
for row in rows:
|
|
for i, cell in enumerate(row):
|
|
if i < len(col_widths):
|
|
col_widths[i] = max(col_widths[i], len(cell))
|
|
|
|
def _format_row(cells):
|
|
return " ".join(cells[i].ljust(col_widths[i]) for i in range(len(cells)))
|
|
|
|
lines = [_format_row(headers), _format_row(["-" * w for w in col_widths])]
|
|
for row in rows:
|
|
lines.append(_format_row(row))
|
|
|
|
text = "\n".join(lines)
|
|
if len(selection) > 1:
|
|
text += f"\n\n(共 {len(selection)} 条报告)"
|
|
|
|
try:
|
|
self.root.clipboard_clear()
|
|
self.root.clipboard_append(text)
|
|
self.root.update()
|
|
except Exception as e:
|
|
messagebox.showerror("错误", f"复制到剪贴板失败: {str(e)}")
|
|
return
|
|
|
|
self.root.after(100, lambda: self._show_copy_toast(len(selection), len(text)))
|
|
|
|
def _show_copy_toast(self, count, length):
|
|
try:
|
|
self.context_menu.unpost()
|
|
except Exception:
|
|
pass
|
|
if hasattr(self, "_copy_toast") and self._copy_toast is not None:
|
|
try:
|
|
self._copy_toast.destroy()
|
|
except Exception:
|
|
pass
|
|
toast = tk.Toplevel(self.root)
|
|
toast.overrideredirect(True)
|
|
toast.attributes("-topmost", True)
|
|
x = self.root.winfo_rootx() + self.root.winfo_width() // 2 - 100
|
|
y = self.root.winfo_rooty() + self.root.winfo_height() // 2 - 25
|
|
toast.geometry(f"200x50+{x}+{y}")
|
|
ttk.Label(toast, text=f"已复制 {count} 条报告文字\n(共 {length} 字符)", anchor="center", justify="center").pack(expand=True, fill='both')
|
|
self._copy_toast = toast
|
|
self.root.after(1500, lambda: self._destroy_copy_toast(toast))
|
|
|
|
def _destroy_copy_toast(self, toast):
|
|
try:
|
|
toast.destroy()
|
|
except Exception:
|
|
pass
|
|
if getattr(self, "_copy_toast", None) is toast:
|
|
self._copy_toast = None
|
|
|
|
def _copy_reports_to(self):
|
|
paths = self._get_selected_report_paths()
|
|
if not paths:
|
|
messagebox.showwarning("警告", "请先选择要复制的报告")
|
|
return
|
|
|
|
target_dir = filedialog.askdirectory(title="选择目标目录")
|
|
if not target_dir:
|
|
return
|
|
|
|
import shutil
|
|
copied = 0
|
|
for src in paths:
|
|
try:
|
|
filename = os.path.basename(src)
|
|
target = os.path.join(target_dir, filename)
|
|
shutil.copy2(src, target)
|
|
copied += 1
|
|
except Exception as e:
|
|
messagebox.showerror("错误", f"复制失败: {str(e)}")
|
|
return
|
|
|
|
messagebox.showinfo("成功", f"已复制 {copied} 个报告到: {target_dir}")
|
|
|
|
def _delete_selected_reports(self):
|
|
paths = self._get_selected_report_paths()
|
|
if not paths:
|
|
messagebox.showwarning("警告", "请先选择要删除的报告")
|
|
return
|
|
|
|
count = len(paths)
|
|
if messagebox.askyesno("确认删除", f"确定要删除选中的 {count} 个报告文件吗?\n此操作不可撤销。"):
|
|
deleted = 0
|
|
for path in paths:
|
|
try:
|
|
os.remove(path)
|
|
deleted += 1
|
|
except Exception as e:
|
|
messagebox.showerror("错误", f"删除失败: {str(e)}")
|
|
return
|
|
|
|
messagebox.showinfo("成功", f"已删除 {deleted} 个报告")
|
|
self._refresh_reports()
|
|
|
|
def _create_optimizer_tab(self, parent):
|
|
main_frame = ttk.Frame(parent)
|
|
main_frame.pack(fill='both', expand=True)
|
|
|
|
opt_frame = ttk.LabelFrame(main_frame, text="EA 参数优化")
|
|
opt_frame.pack(fill='both', expand=True, padx=5, pady=5)
|
|
|
|
top_row = ttk.Frame(opt_frame)
|
|
top_row.pack(fill='x', padx=5, pady=5)
|
|
|
|
ttk.Label(top_row, text="优化目录:").pack(side='left', padx=5)
|
|
self.opt_dir = ttk.Entry(top_row, width=35)
|
|
self.opt_dir.insert(0, "optimizer")
|
|
self.opt_dir.pack(side='left', padx=5)
|
|
ttk.Button(top_row, text="浏览", command=self._browse_opt_dir).pack(side='left', padx=5)
|
|
|
|
ea_row = ttk.Frame(opt_frame)
|
|
ea_row.pack(fill='x', padx=5, pady=5)
|
|
|
|
ttk.Label(ea_row, text="选择EA:").pack(side='left', padx=5)
|
|
self.opt_ea_var = tk.StringVar()
|
|
self.opt_ea_combo = ttk.Combobox(ea_row, textvariable=self.opt_ea_var, width=35, state='readonly')
|
|
self.opt_ea_combo.pack(side='left', padx=5)
|
|
self.opt_ea_combo.bind('<<ComboboxSelected>>', self._on_ea_selected)
|
|
ttk.Button(ea_row, text="刷新EA列表", command=self._refresh_opt_ea_list).pack(side='left', padx=5)
|
|
ttk.Button(ea_row, text="自动扫描MT5", command=self._auto_scan_eas).pack(side='left', padx=5)
|
|
ttk.Button(ea_row, text="批量执行优化", command=self._run_batch_optimize).pack(side='left', padx=5)
|
|
|
|
param_frame = ttk.LabelFrame(opt_frame, text="参数配置 (点击编辑)")
|
|
param_frame.pack(fill='both', expand=True, padx=5, pady=5)
|
|
|
|
tree_scroll_y = ttk.Scrollbar(param_frame, orient='vertical')
|
|
tree_scroll_y.pack(side='right', fill='y')
|
|
tree_scroll_x = ttk.Scrollbar(param_frame, orient='horizontal')
|
|
tree_scroll_x.pack(side='bottom', fill='x')
|
|
|
|
cols = ('param_name', 'param_type', 'default', 'min', 'max', 'step', 'optimize')
|
|
self.param_tree = ttk.Treeview(param_frame, columns=cols, show='headings',
|
|
yscrollcommand=tree_scroll_y.set,
|
|
xscrollcommand=tree_scroll_x.set, height=8)
|
|
tree_scroll_y.config(command=self.param_tree.yview)
|
|
tree_scroll_x.config(command=self.param_tree.xview)
|
|
|
|
self.param_tree.heading('param_name', text='参数名')
|
|
self.param_tree.heading('param_type', text='类型')
|
|
self.param_tree.heading('default', text='默认值')
|
|
self.param_tree.heading('min', text='最小值')
|
|
self.param_tree.heading('max', text='最大值')
|
|
self.param_tree.heading('step', text='步进')
|
|
self.param_tree.heading('optimize', text='优化')
|
|
|
|
self.param_tree.column('param_name', width=150, anchor='w')
|
|
self.param_tree.column('param_type', width=80, anchor='center')
|
|
self.param_tree.column('default', width=80, anchor='center')
|
|
self.param_tree.column('min', width=80, anchor='center')
|
|
self.param_tree.column('max', width=80, anchor='center')
|
|
self.param_tree.column('step', width=60, anchor='center')
|
|
self.param_tree.column('optimize', width=50, anchor='center')
|
|
|
|
self.param_tree.pack(fill='both', expand=True)
|
|
self.param_tree.bind('<Double-1>', self._on_param_double_click)
|
|
|
|
btn_frame = ttk.Frame(opt_frame)
|
|
btn_frame.pack(fill='x', padx=5, pady=5)
|
|
ttk.Button(btn_frame, text="添加参数", command=self._add_param).pack(side='left', padx=3)
|
|
ttk.Button(btn_frame, text="删除选中", command=self._del_param).pack(side='left', padx=3)
|
|
ttk.Button(btn_frame, text="保存配置", command=self._save_ea_config).pack(side='left', padx=3)
|
|
ttk.Button(btn_frame, text="加载SET文件", command=self._load_set_file).pack(side='left', padx=3)
|
|
ttk.Button(btn_frame, text="切换优化", command=self._toggle_param_optimize).pack(side='left', padx=3)
|
|
|
|
test_frame = ttk.LabelFrame(opt_frame, text="回测配置")
|
|
test_frame.pack(fill='x', padx=5, pady=5)
|
|
|
|
action_row = ttk.Frame(test_frame)
|
|
action_row.pack(fill='x', padx=5, pady=5)
|
|
ttk.Button(action_row, text="开始回测", command=self._on_start_backtest_from_opt).pack(side='left', padx=5)
|
|
|
|
sym_row = ttk.Frame(test_frame)
|
|
sym_row.pack(fill='x', padx=5, pady=3)
|
|
ttk.Label(sym_row, text="品种:").pack(side='left', padx=5)
|
|
self.opt_symbol = ttk.Combobox(sym_row, width=12, values=['EURUSD', 'GBPUSD', 'USDJPY', 'USDCAD', 'USDCHF', 'AUDUSD', 'NZDUSD', 'EURGBP', 'EURJPY', 'GBPJPY', 'EURCHF', 'AUDJPY', 'EURAUD', 'GBPAUD', 'EURCAD', 'GBPCAD', 'XAUUSD', 'XAGUSD', 'XBRUSD', 'XTIUSD', 'BTCUSD', 'ETHUSD', 'LTCUSD', 'USDCNH', 'USDHKD', 'USDCZK', 'USDDKK', 'USDHUF', 'USDMXN', 'USDPLN', 'USDTHB', 'NOKJPY', 'SEKJPY', 'XRPUSD', 'XLMUSD', 'AUS200', 'CHINA50', 'ES35', 'STOXX50', 'F40', 'HK50', 'IT40', 'JP225', 'UK100', 'US2000', 'US30', 'US500', 'USTEC', 'CA60', 'NETH25', 'SE30', 'SWI20', 'CHINAH', 'NOR25', 'TecDE30'])
|
|
self.opt_symbol.set('XAUUSD')
|
|
self.opt_symbol.pack(side='left', padx=5)
|
|
|
|
ttk.Label(sym_row, text="周期:").pack(side='left', padx=10)
|
|
self.opt_period = ttk.Combobox(sym_row, width=8, values=['M1', 'M5', 'M15', 'M30', 'H1', 'H4', 'D1', 'W1'])
|
|
self.opt_period.set('M1')
|
|
self.opt_period.pack(side='left', padx=5)
|
|
|
|
ttk.Label(sym_row, text="模式:").pack(side='left', padx=10)
|
|
self.opt_model = ttk.Combobox(sym_row, width=18, values=['Every Tick (0)', '1分钟OHLC (1)', '仅开盘价 (2)', '数学计算 (3)', '真实Tick (4)'])
|
|
self.opt_model.set('1分钟OHLC (1)')
|
|
self.opt_model.pack(side='left', padx=5)
|
|
|
|
delay_row = ttk.Frame(test_frame)
|
|
delay_row.pack(fill='x', padx=5, pady=3)
|
|
ttk.Label(delay_row, text="延迟:").pack(side='left', padx=5)
|
|
self.opt_delay_type = ttk.Combobox(delay_row, width=18, values=['无延迟 (0)', '随机延迟 (-1)', '固定延迟'])
|
|
self.opt_delay_type.set('无延迟 (0)')
|
|
self.opt_delay_type.pack(side='left', padx=5)
|
|
ttk.Label(delay_row, text="ms:").pack(side='left', padx=2)
|
|
self.opt_delay_value = ttk.Entry(delay_row, width=8)
|
|
self.opt_delay_value.insert(0, "200")
|
|
self.opt_delay_value.pack(side='left', padx=2)
|
|
|
|
ttk.Label(delay_row, text="优化指标:").pack(side='left', padx=10)
|
|
self.opt_criterion = ttk.Combobox(delay_row, width=18, values=[
|
|
'余额最大值 (0)', '盈利因子最大值 (1)', '期望收益 (2)', '回撤最小值 (3)',
|
|
'恢复因子 (4)', '夏普比率 (5)', '自定义 (6)', '复合指标 (7)'])
|
|
self.opt_criterion.set('盈利因子最大值 (1)')
|
|
self.opt_criterion.pack(side='left', padx=5)
|
|
|
|
mode_row = ttk.Frame(test_frame)
|
|
mode_row.pack(fill='x', padx=5, pady=3)
|
|
ttk.Label(mode_row, text="优化模式:").pack(side='left', padx=5)
|
|
self.opt_optimization_mode = ttk.Combobox(mode_row, width=20, values=[
|
|
'禁用 (0)', '慢速完整算法 (1)', '快速遗传算法 (2)', 'MarketWatch所有符号 (3)'])
|
|
self.opt_optimization_mode.set('快速遗传算法 (2)')
|
|
self.opt_optimization_mode.pack(side='left', padx=5)
|
|
|
|
date_row = ttk.Frame(test_frame)
|
|
date_row.pack(fill='x', padx=5, pady=3)
|
|
ttk.Label(date_row, text="开始:").pack(side='left', padx=5)
|
|
self.opt_from_date = ttk.Entry(date_row, width=12)
|
|
self.opt_from_date.insert(0, "2026.01.01")
|
|
self.opt_from_date.pack(side='left', padx=5)
|
|
ttk.Label(date_row, text="结束:").pack(side='left', padx=10)
|
|
self.opt_to_date = ttk.Entry(date_row, width=12)
|
|
self.opt_to_date.insert(0, "2026.01.04")
|
|
self.opt_to_date.pack(side='left', padx=5)
|
|
|
|
acct_row = ttk.Frame(test_frame)
|
|
acct_row.pack(fill='x', padx=5, pady=3)
|
|
ttk.Label(acct_row, text="保证金:").pack(side='left', padx=5)
|
|
self.opt_deposit = ttk.Entry(acct_row, width=10)
|
|
self.opt_deposit.insert(0, "10000")
|
|
self.opt_deposit.pack(side='left', padx=5)
|
|
ttk.Label(acct_row, text="杠杆:").pack(side='left', padx=10)
|
|
self.opt_leverage = ttk.Combobox(acct_row, width=8, values=["1:10", "1:50", "1:100", "1:200", "1:500"])
|
|
self.opt_leverage.set("1:100")
|
|
self.opt_leverage.pack(side='left', padx=5)
|
|
|
|
wf_frame = ttk.LabelFrame(test_frame, text="Walk-Forward 防过拟合")
|
|
wf_frame.pack(fill='x', padx=5, pady=5)
|
|
|
|
wf_row1 = ttk.Frame(wf_frame)
|
|
wf_row1.pack(fill='x', padx=5, pady=3)
|
|
self.wf_enabled = tk.BooleanVar(value=False)
|
|
ttk.Checkbutton(wf_row1, text="启用Walk-Forward", variable=self.wf_enabled).pack(side='left', padx=5)
|
|
|
|
ttk.Label(wf_row1, text="OOS比例:").pack(side='left', padx=5)
|
|
self.wf_mode = ttk.Combobox(wf_row1, width=12, values=['1/2 (50%)', '1/3 (33%)', '1/4 (25%)'])
|
|
self.wf_mode.set('1/3 (33%)')
|
|
self.wf_mode.pack(side='left', padx=5)
|
|
|
|
self.wf_mode_map = {'1/2 (50%)': 1, '1/3 (33%)': 2, '1/4 (25%)': 3}
|
|
|
|
action_frame = ttk.Frame(opt_frame)
|
|
action_frame.pack(fill='x', padx=5, pady=5)
|
|
ttk.Button(action_frame, text="运行优化", command=self._run_mt5_optimize).pack(side='left', padx=5)
|
|
ttk.Button(action_frame, text="分析结果", command=self._analyze_opt_results).pack(side='left', padx=5)
|
|
|
|
self.opt_progress = ttk.Progressbar(action_frame, mode='determinate')
|
|
self.opt_progress.pack(side='left', fill='x', expand=True, padx=10)
|
|
|
|
log_frame = ttk.LabelFrame(main_frame, text="执行日志")
|
|
log_frame.pack(fill='both', expand=True, padx=5, pady=5)
|
|
self.opt_log = tk.Text(log_frame, height=10, width=80)
|
|
self.opt_log.pack(fill='both', expand=True, padx=5, pady=5)
|
|
|
|
self._refresh_opt_ea_list()
|
|
|
|
def _browse_opt_dir(self):
|
|
path = filedialog.askdirectory(initialdir=self.opt_dir.get())
|
|
if path:
|
|
self.opt_dir.delete(0, tk.END)
|
|
self.opt_dir.insert(0, path)
|
|
|
|
def _refresh_opt_ea_list(self):
|
|
configs_dir = os.path.join(self.opt_dir.get(), 'configs')
|
|
if not os.path.exists(configs_dir):
|
|
self.opt_log.insert('end', f"配置目录不存在: {configs_dir}\n")
|
|
return
|
|
sys.path.insert(0, os.path.join(os.getcwd(), 'scripts', 'optimizer'))
|
|
from ea_config_parser import load_all_ea_configs
|
|
configs = load_all_ea_configs(configs_dir)
|
|
|
|
installed_names = None
|
|
mt5_data = self.config.get('mt5_settings', {}).get('data_dir', '')
|
|
if mt5_data:
|
|
try:
|
|
from ea_auto_scanner import EAAutoScanner
|
|
scanner = EAAutoScanner(mt5_data)
|
|
experts = scanner.scan_experts()
|
|
if experts:
|
|
installed_names = {e['name'] for e in experts}
|
|
for e in experts:
|
|
self.opt_ea_paths[e['name']] = e['path']
|
|
except Exception as e:
|
|
self.opt_log.insert('end', f"扫描MT5失败,跳过校验: {e}\n")
|
|
installed_names = None
|
|
|
|
filtered_names = []
|
|
removed_names = []
|
|
for name in configs.keys():
|
|
if installed_names is not None and name not in installed_names:
|
|
removed_names.append(name)
|
|
else:
|
|
filtered_names.append(name)
|
|
|
|
if removed_names:
|
|
self.opt_log.insert('end', f"已过滤 {len(removed_names)} 个MT5中不存在的EA: {', '.join(removed_names)}\n")
|
|
for name in removed_names:
|
|
self.opt_log.insert('end', f" - {name}.json\n")
|
|
|
|
configs = {k: configs[k] for k in filtered_names}
|
|
ea_names = list(configs.keys())
|
|
self.opt_ea_combo['values'] = ea_names
|
|
if ea_names:
|
|
self.opt_ea_var.set(ea_names[0])
|
|
self._load_ea_params(ea_names[0])
|
|
self.opt_log.insert('end', f"已加载 {len(configs)} 个EA配置\n")
|
|
|
|
def _auto_scan_eas(self):
|
|
mt5_data = self.config.get('mt5_settings', {}).get('data_dir', '')
|
|
if not mt5_data:
|
|
messagebox.showwarning('警告', '请先在回测配置中设置MT5数据目录')
|
|
return
|
|
sys.path.insert(0, os.path.join(os.getcwd(), 'scripts', 'optimizer'))
|
|
from ea_auto_scanner import EAAutoScanner
|
|
self.opt_log.insert('end', f"扫描MT5目录: {mt5_data}\n")
|
|
self.opt_log.update()
|
|
scanner = EAAutoScanner(mt5_data)
|
|
experts = scanner.scan_experts()
|
|
if not experts:
|
|
self.opt_log.insert('end', "未找到EA文件\n")
|
|
return
|
|
self.opt_log.insert('end', f"找到 {len(experts)} 个EA:\n")
|
|
self.opt_log.update()
|
|
|
|
ea_list = []
|
|
mt5_tester_dir = os.path.join(mt5_data, 'MQL5', 'Profiles', 'Tester')
|
|
|
|
for ea_info in experts:
|
|
ea_name = ea_info['name']
|
|
ea_list.append(ea_name)
|
|
self.opt_ea_paths[ea_name] = ea_info['path']
|
|
|
|
set_filename = ea_name + '_optimization.set'
|
|
set_path = os.path.join(mt5_tester_dir, set_filename)
|
|
if os.path.exists(set_path):
|
|
self.opt_log.insert('end', f" ✓ {ea_name} (有SET)\n")
|
|
else:
|
|
set_files = [f for f in os.listdir(mt5_tester_dir) if f.endswith('.set') and ea_name.lower() in f.lower().replace('.set', '')]
|
|
if set_files:
|
|
self.opt_log.insert('end', f" ~ {ea_name} (匹配: {set_files[0]})\n")
|
|
else:
|
|
self.opt_log.insert('end', f" - {ea_name}\n")
|
|
|
|
self.opt_log.insert('end', f"\n共 {len(ea_list)} 个EA\n")
|
|
self.opt_log.update()
|
|
|
|
self.opt_ea_combo['values'] = ea_list
|
|
if ea_list:
|
|
self.opt_ea_var.set(ea_list[0])
|
|
self._auto_load_ea_with_set(mt5_data, ea_list[0])
|
|
|
|
def _auto_load_ea_with_set(self, mt5_data, ea_name):
|
|
for item in self.param_tree.get_children():
|
|
self.param_tree.delete(item)
|
|
|
|
mt5_tester_dir = os.path.join(mt5_data, 'MQL5', 'Profiles', 'Tester')
|
|
set_filename = ea_name + '_optimization.set'
|
|
set_path = os.path.join(mt5_tester_dir, set_filename)
|
|
|
|
if not os.path.exists(set_path):
|
|
set_files = [f for f in os.listdir(mt5_tester_dir) if f.endswith('.set') and ea_name.lower() in f.lower().replace('.set', '')]
|
|
if set_files:
|
|
set_path = os.path.join(mt5_tester_dir, set_files[0])
|
|
self.opt_log.insert('end', f"加载SET: {set_files[0]}\n")
|
|
|
|
if os.path.exists(set_path):
|
|
params = self._parse_mt5_set_file(set_path)
|
|
for pname, pinfo in params.items():
|
|
self.param_tree.insert('', 'end', values=(
|
|
pname,
|
|
pinfo.get('type', 'int'),
|
|
pinfo.get('default', 0),
|
|
pinfo.get('min', 0),
|
|
pinfo.get('max', 100),
|
|
pinfo.get('step', 1),
|
|
'Y' if pinfo.get('optimize', True) else 'N'
|
|
))
|
|
self.opt_log.insert('end', f"已加载 {len(params)} 个参数\n")
|
|
else:
|
|
self.opt_log.insert('end', f"无SET文件,请手动添加参数\n")
|
|
|
|
def _on_ea_selected(self, event=None):
|
|
ea_name = self.opt_ea_var.get()
|
|
if ea_name:
|
|
mt5_data = self.config.get('mt5_settings', {}).get('data_dir', '')
|
|
self._auto_load_ea_with_set(mt5_data, ea_name)
|
|
|
|
def _load_ea_params(self, ea_name):
|
|
for item in self.param_tree.get_children():
|
|
self.param_tree.delete(item)
|
|
configs_dir = os.path.join(self.opt_dir.get(), 'configs')
|
|
sys.path.insert(0, os.path.join(os.getcwd(), 'scripts', 'optimizer'))
|
|
from ea_config_parser import load_ea_config
|
|
mt5_data = self.config.get('mt5_settings', {}).get('data_dir', '')
|
|
|
|
config_path = os.path.join(configs_dir, ea_name + '.json')
|
|
if os.path.exists(config_path):
|
|
config = load_ea_config(config_path)
|
|
if config.parameters:
|
|
for pname, param in config.parameters.items():
|
|
optimize = 'Y' if param.optimize else 'N'
|
|
self.param_tree.insert('', 'end', values=(
|
|
pname, param.param_type, param.default, param.min_value, param.max_value, param.step, optimize
|
|
))
|
|
self.opt_log.insert('end', f"从配置加载 {len(config.parameters)} 个参数\n")
|
|
return
|
|
|
|
self._auto_load_ea_with_set(mt5_data, ea_name)
|
|
|
|
def _add_param(self):
|
|
self.param_tree.insert('', 'end', values=('NewParam', 'int', 0, 0, 100, 1, 'Y'))
|
|
|
|
def _del_param(self):
|
|
sel = self.param_tree.selection()
|
|
if sel:
|
|
self.param_tree.delete(sel)
|
|
|
|
def _toggle_param_optimize(self):
|
|
sel = self.param_tree.selection()
|
|
if sel:
|
|
cur = self.param_tree.item(sel[0], 'values')
|
|
new_opt = 'N' if cur[6] == 'Y' else 'Y'
|
|
self.param_tree.item(sel[0], values=(cur[0], cur[1], cur[2], cur[3], cur[4], cur[5], new_opt))
|
|
|
|
def _on_param_double_click(self, event):
|
|
item = self.param_tree.selection()
|
|
if not item:
|
|
return
|
|
col = self.param_tree.identify_column(event.x)
|
|
col_idx = int(col.replace('#', '')) - 1
|
|
cols = ('param_name', 'param_type', 'default', 'min', 'max', 'step', 'optimize')
|
|
if col_idx < 2:
|
|
return
|
|
cur_vals = self.param_tree.item(item[0], 'values')
|
|
entry = ttk.Entry(self.param_tree)
|
|
entry.insert(0, cur_vals[col_idx])
|
|
entry.select_clear()
|
|
entry.focus()
|
|
|
|
def on_validate():
|
|
new_val = entry.get()
|
|
new_vals = list(cur_vals)
|
|
new_vals[col_idx] = new_val
|
|
self.param_tree.item(item[0], values=new_vals)
|
|
entry.destroy()
|
|
entry.bind('<Return>', lambda e: on_validate())
|
|
entry.bind('<Escape>', lambda e: entry.destroy())
|
|
entry.place(x=event.x, y=event.y, width=100)
|
|
|
|
def _save_ea_config(self):
|
|
ea_name = self.opt_ea_var.get()
|
|
if not ea_name:
|
|
messagebox.showwarning('警告', '请先选择一个EA')
|
|
return
|
|
configs_dir = os.path.join(self.opt_dir.get(), 'configs')
|
|
os.makedirs(configs_dir, exist_ok=True)
|
|
params = {}
|
|
for item in self.param_tree.get_children():
|
|
vals = self.param_tree.item(item, 'values')
|
|
pname, ptype, pdefault, pmin, pmax, pstep, poptimize = vals
|
|
if pname and pname != 'NewParam':
|
|
try:
|
|
default_val = float(pdefault) if pdefault else 0
|
|
min_val = float(pmin) if pmin else 0
|
|
max_val = float(pmax) if pmax else 100
|
|
step_val = float(pstep) if pstep else 1
|
|
except:
|
|
default_val, min_val, max_val, step_val = 0, 0, 100, 1
|
|
params[pname] = {
|
|
'type': ptype if ptype in ('int', 'double', 'bool') else 'int',
|
|
'default': default_val,
|
|
'min': min_val,
|
|
'max': max_val,
|
|
'step': step_val,
|
|
'optimize': poptimize == 'Y' if poptimize else True
|
|
}
|
|
import json
|
|
config_path = os.path.join(configs_dir, ea_name + '.json')
|
|
config_data = {
|
|
'ea_name': ea_name,
|
|
'ea_path': self.opt_ea_paths.get(ea_name, f"MY-EA\\{ea_name}.ex5"),
|
|
'description': 'Edited via GUI',
|
|
'search_strategy': 'auto',
|
|
'parameters': params,
|
|
'test_config': {
|
|
'symbol': self.opt_symbol.get().split()[0] if ' ' in self.opt_symbol.get() else self.opt_symbol.get(),
|
|
'period': self.opt_period.get(),
|
|
'from_date': self.opt_from_date.get(),
|
|
'to_date': self.opt_to_date.get(),
|
|
'model': int(self.opt_model.get().split('(')[1].split(')')[0]) if '(' in self.opt_model.get() else 1,
|
|
'execution_delay': self._get_opt_execution_delay(),
|
|
'optimization_mode': self._get_opt_optimization_mode(),
|
|
'optimization_criterion': self._get_opt_optimization_criterion(),
|
|
'deposit': int(self.opt_deposit.get()) if self.opt_deposit.get().isdigit() else 10000,
|
|
'leverage': self.opt_leverage.get()
|
|
},
|
|
'walk_forward': {
|
|
'enabled': self.wf_enabled.get(),
|
|
'forward_mode': self.wf_mode_map.get(self.wf_mode.get(), 2)
|
|
}
|
|
}
|
|
with open(config_path, 'w', encoding='utf-8') as f:
|
|
json.dump(config_data, f, indent=4, ensure_ascii=False)
|
|
self.opt_log.insert('end', f"配置已保存: {config_path}\n")
|
|
|
|
def _get_opt_execution_delay(self):
|
|
delay_type = self.opt_delay_type.get()
|
|
if '无延迟' in delay_type:
|
|
return 0
|
|
elif '随机延迟' in delay_type:
|
|
return -1
|
|
else:
|
|
try:
|
|
return int(self.opt_delay_value.get())
|
|
except ValueError:
|
|
return 0
|
|
|
|
def _get_opt_optimization_mode(self):
|
|
mode_str = self.opt_optimization_mode.get()
|
|
if '(' in mode_str:
|
|
return int(mode_str.split('(')[1].split(')')[0])
|
|
return 2
|
|
|
|
def _get_opt_optimization_criterion(self):
|
|
criterion_map = {
|
|
'余额最大值': 0,
|
|
'盈利因子最大值': 1,
|
|
'期望收益': 2,
|
|
'回撤最小值': 3,
|
|
'恢复因子': 4,
|
|
'夏普比率': 5,
|
|
'自定义': 6,
|
|
'复合指标': 7,
|
|
}
|
|
criterion_str = self.opt_criterion.get()
|
|
for key, val in criterion_map.items():
|
|
if key in criterion_str:
|
|
return val
|
|
return 1
|
|
|
|
def _load_set_file(self):
|
|
ea_name = self.opt_ea_var.get()
|
|
if not ea_name:
|
|
messagebox.showwarning('警告', '请先选择一个EA')
|
|
return
|
|
file_path = filedialog.askopenfilename(
|
|
title='选择SET文件',
|
|
initialdir=os.path.join(self.opt_dir.get(), 'set_files'),
|
|
filetypes=[('SET files', '*.set'), ('All files', '*.*')]
|
|
)
|
|
if not file_path or not os.path.exists(file_path):
|
|
return
|
|
self.opt_log.insert('end', f"加载SET文件: {file_path}\n")
|
|
for item in self.param_tree.get_children():
|
|
self.param_tree.delete(item)
|
|
params = self._parse_set_file(file_path)
|
|
if params:
|
|
for pname, ptype, pval, min_val, max_val, step, optimize_flag in params:
|
|
self.param_tree.insert('', 'end', values=(pname, ptype, pval, 0, max_val, step, optimize_flag))
|
|
self.opt_log.insert('end', f"成功导入 {len(params)} 个参数\n")
|
|
else:
|
|
self.opt_log.insert('end', f"解析SET文件失败或文件为空\n")
|
|
messagebox.showwarning('警告', '无法解析SET文件内容')
|
|
|
|
def _parse_set_file(self, set_path):
|
|
params = []
|
|
try:
|
|
with open(set_path, 'rb') as f:
|
|
raw = f.read()
|
|
if raw[:2] in (b'\xff\xfe', b'\xfe\xff'):
|
|
content = raw[2:].decode('utf-16-le', errors='ignore')
|
|
else:
|
|
content = raw.decode('utf-8', errors='ignore')
|
|
for line in content.split('\n'):
|
|
line = line.strip()
|
|
if not line or line.startswith(';') or line.startswith('#'):
|
|
continue
|
|
if '=' in line:
|
|
parts = line.split('=')
|
|
pname = parts[0].strip()
|
|
try:
|
|
values_str = parts[1].strip() if len(parts) > 1 else ''
|
|
values = [v.strip() for v in values_str.split('||')]
|
|
if values and values[0]:
|
|
first_val = values[0].lower()
|
|
if first_val == 'true':
|
|
pval = 1.0
|
|
ptype = 'bool'
|
|
elif first_val == 'false':
|
|
pval = 0.0
|
|
ptype = 'bool'
|
|
else:
|
|
pval = float(values[0])
|
|
ptype = 'double' if pval != int(pval) or '.' in values[0] else 'int'
|
|
max_val_str = values[3] if len(values) > 3 else ''
|
|
if max_val_str.lower() == 'true':
|
|
max_val = 1.0
|
|
elif max_val_str.lower() == 'false':
|
|
max_val = 0.0
|
|
elif max_val_str:
|
|
max_val = float(max_val_str)
|
|
else:
|
|
max_val = max(abs(pval) * 10, 100) if pval != 0 else 100
|
|
min_val = 0
|
|
step = 0.01 if ptype == 'double' else 1
|
|
optimize_flag = 'Y' if len(values) > 4 and values[4] == 'Y' else 'N'
|
|
params.append((pname, ptype, pval, min_val, max_val, step, optimize_flag))
|
|
except:
|
|
pass
|
|
except Exception as e:
|
|
pass
|
|
return params
|
|
|
|
def _parse_mt5_set_file(self, set_path):
|
|
params = {}
|
|
try:
|
|
with open(set_path, 'rb') as f:
|
|
raw = f.read()
|
|
if raw[:2] in (b'\xff\xfe', b'\xfe\xff'):
|
|
content = raw[2:].decode('utf-16-le', errors='ignore')
|
|
else:
|
|
content = raw.decode('utf-8', errors='ignore')
|
|
for line in content.split('\n'):
|
|
line = line.strip()
|
|
if not line or line.startswith(';') or line.startswith('#'):
|
|
continue
|
|
if '=' in line:
|
|
parts = line.split('=')
|
|
pname = parts[0].strip()
|
|
try:
|
|
values_str = parts[1].strip() if len(parts) > 1 else ''
|
|
values = [v.strip() for v in values_str.split('||')]
|
|
if values and values[0]:
|
|
first_val = values[0].lower()
|
|
if first_val == 'true':
|
|
pval = 1.0
|
|
ptype = 'bool'
|
|
elif first_val == 'false':
|
|
pval = 0.0
|
|
ptype = 'bool'
|
|
else:
|
|
pval = float(values[0])
|
|
ptype = 'double' if pval != int(pval) or '.' in values[0] else 'int'
|
|
max_val_str = values[3] if len(values) > 3 else ''
|
|
if max_val_str.lower() == 'true':
|
|
max_val = 1.0
|
|
elif max_val_str.lower() == 'false':
|
|
max_val = 0.0
|
|
elif max_val_str:
|
|
max_val = float(max_val_str)
|
|
else:
|
|
max_val = max(abs(pval) * 10, 100) if pval != 0 else 100
|
|
min_val_str = values[2] if len(values) > 2 else ''
|
|
if min_val_str.lower() == 'true':
|
|
min_val = 1.0
|
|
elif min_val_str.lower() == 'false':
|
|
min_val = 0.0
|
|
elif min_val_str:
|
|
min_val = float(min_val_str)
|
|
else:
|
|
min_val = 0
|
|
step = 0.01 if ptype == 'double' else 1
|
|
optimize_flag = values[4] == 'Y' if len(values) > 4 else True
|
|
params[pname] = {
|
|
'type': ptype,
|
|
'default': pval,
|
|
'min': min_val,
|
|
'max': max_val,
|
|
'step': step,
|
|
'optimize': optimize_flag
|
|
}
|
|
except:
|
|
pass
|
|
except Exception as e:
|
|
pass
|
|
return params
|
|
|
|
def _run_mt5_optimize(self):
|
|
ea_name = self.opt_ea_var.get()
|
|
if not ea_name:
|
|
messagebox.showwarning('警告', '请先选择一个EA')
|
|
return
|
|
mt5_path = self.config.get('mt5_settings', {}).get('terminal_path', '')
|
|
if not mt5_path or not os.path.exists(mt5_path):
|
|
messagebox.showerror('错误', 'MT5终端路径无效')
|
|
return
|
|
sys.path.insert(0, os.path.join(os.getcwd(), 'scripts', 'optimizer'))
|
|
from ea_batch_optimizer import EABatchOptimizer
|
|
from ea_config_parser import load_ea_config
|
|
configs_dir = os.path.join(self.opt_dir.get(), 'configs')
|
|
config_path = os.path.join(configs_dir, ea_name + '.json')
|
|
if not os.path.exists(config_path):
|
|
messagebox.showerror('错误', '请先保存EA配置')
|
|
return
|
|
try:
|
|
config = load_ea_config(config_path)
|
|
optimizer = EABatchOptimizer(self.opt_dir.get())
|
|
self.opt_log.insert('end', f"正在生成优化配置...\n")
|
|
self.opt_log.update()
|
|
ini_path = optimizer.generate_ini_for_ea(config, None)
|
|
self.opt_log.insert('end', f"INI: {ini_path}\n")
|
|
self.opt_log.update()
|
|
abs_ini_path = os.path.abspath(ini_path)
|
|
self.opt_log.insert('end', "正在启动MT5优化器...\n")
|
|
self.opt_log.update()
|
|
mt5_data_dir = self.config.get('mt5_settings', {}).get('data_dir', '')
|
|
_launch_mt5(mt5_path, abs_ini_path, mt5_data_dir)
|
|
self.opt_log.insert('end', "MT5已启动\n")
|
|
mode = self.opt_optimization_mode.get().split('(')[0] if '(' in self.opt_optimization_mode.get() else '遗传算法'
|
|
self.opt_log.insert('end', f"MT5优化器运行中({mode})\n")
|
|
self.opt_log.insert('end', "优化完成后点击'分析结果'查看\n")
|
|
except Exception as e:
|
|
self.opt_log.insert('end', f"错误: {str(e)}\n")
|
|
|
|
def _run_batch_optimize(self):
|
|
mt5_path = self.config.get('mt5_settings', {}).get('terminal_path', '')
|
|
if not mt5_path or not os.path.exists(mt5_path):
|
|
messagebox.showerror('错误', 'MT5终端路径无效')
|
|
return
|
|
sys.path.insert(0, os.path.join(os.getcwd(), 'scripts', 'optimizer'))
|
|
from ea_batch_optimizer import EABatchOptimizer
|
|
from ea_config_parser import load_all_ea_configs
|
|
|
|
configs_dir = os.path.join(self.opt_dir.get(), 'configs')
|
|
if not os.path.exists(configs_dir):
|
|
messagebox.showwarning('警告', '配置目录不存在')
|
|
return
|
|
|
|
configs = load_all_ea_configs(configs_dir)
|
|
if not configs:
|
|
messagebox.showwarning('警告', '没有找到EA配置')
|
|
return
|
|
|
|
result = messagebox.askyesno('确认', f'将对 {len(configs)} 个EA进行批量优化\n优化完成后自动进入下一个\n\n是否继续?')
|
|
if not result:
|
|
return
|
|
|
|
self.opt_log.insert('end', f"开始批量优化,共 {len(configs)} 个EA\n")
|
|
self.opt_log.insert('end', "每个EA优化完成后会自动关闭MT5并启动下一个\n")
|
|
self.opt_log.update()
|
|
|
|
optimizer = EABatchOptimizer(self.opt_dir.get())
|
|
mt5_data_dir = self.config.get('mt5_settings', {}).get('data_dir', '')
|
|
reports_dir = os.path.join(self.opt_dir.get(), 'reports')
|
|
os.makedirs(reports_dir, exist_ok=True)
|
|
|
|
for i, (ea_name, ea_config) in enumerate(configs.items(), 1):
|
|
self.opt_log.insert('end', f"\n[{i}/{len(configs)}] 优化: {ea_name}\n")
|
|
self.opt_log.update()
|
|
|
|
try:
|
|
ini_path = optimizer.generate_ini_for_ea(ea_config, None)
|
|
abs_ini_path = os.path.abspath(ini_path)
|
|
self.opt_log.insert('end', f"启动MT5: {ea_name}\n")
|
|
self.opt_log.update()
|
|
|
|
process = _launch_mt5(mt5_path, abs_ini_path, mt5_data_dir)
|
|
|
|
xml_name = ea_name + '_optimization.xml'
|
|
xml_path = os.path.join(mt5_data_dir, xml_name)
|
|
|
|
self.opt_log.insert('end', f"等待XML结果: {xml_path}\n")
|
|
self.opt_log.update()
|
|
|
|
wait_timeout = 600
|
|
start_time = time.time()
|
|
while time.time() - start_time < wait_timeout:
|
|
if os.path.exists(xml_path):
|
|
time.sleep(2)
|
|
try:
|
|
with open(xml_path, 'r', encoding='utf-8') as f:
|
|
if len(f.read()) > 100:
|
|
self.opt_log.insert('end', "结果XML已生成\n")
|
|
self.opt_log.update()
|
|
break
|
|
except:
|
|
pass
|
|
time.sleep(5)
|
|
|
|
if process.poll() is not None:
|
|
if os.path.exists(xml_path):
|
|
self.opt_log.insert('end', "MT5已退出,结果文件存在\n")
|
|
self.opt_log.update()
|
|
break
|
|
self.opt_log.insert('end', "MT5已退出,等待结果文件...\n")
|
|
self.opt_log.update()
|
|
time.sleep(3)
|
|
if os.path.exists(xml_path):
|
|
break
|
|
|
|
if process.poll() is None:
|
|
self.opt_log.insert('end', "正在关闭MT5...\n")
|
|
self.opt_log.update()
|
|
try:
|
|
process.terminate()
|
|
process.wait(timeout=5)
|
|
except:
|
|
pass
|
|
|
|
dst_xml = os.path.join(reports_dir, ea_name, xml_name)
|
|
if os.path.exists(xml_path):
|
|
os.makedirs(os.path.dirname(dst_xml), exist_ok=True)
|
|
shutil.copy2(xml_path, dst_xml)
|
|
self.opt_log.insert('end', f"已保存结果: {dst_xml}\n")
|
|
|
|
self.opt_log.insert('end', f"{ea_name} 优化完成\n")
|
|
self.opt_log.update()
|
|
|
|
except Exception as e:
|
|
self.opt_log.insert('end', f"错误: {str(e)}\n")
|
|
|
|
self.opt_log.insert('end', f"\n所有EA优化完成!\n")
|
|
self.opt_log.insert('end', "点击'分析结果'查看优化效果\n")
|
|
|
|
def _analyze_opt_results(self):
|
|
ea_name = self.opt_ea_var.get()
|
|
sys.path.insert(0, os.path.join(os.getcwd(), 'scripts', 'optimizer'))
|
|
from ea_batch_optimizer import EABatchOptimizer
|
|
from analyze_results import ResultAnalyzer
|
|
optimizer = EABatchOptimizer(self.opt_dir.get())
|
|
mt5_terminal_dir = self.config.get('mt5_settings', {}).get('data_dir', '')
|
|
|
|
self.opt_log.insert('end', "正在分析结果...\n")
|
|
self.opt_log.update()
|
|
|
|
results = []
|
|
|
|
if mt5_terminal_dir:
|
|
reports_dir = os.path.join(self.opt_dir.get(), 'reports', ea_name)
|
|
analyzer = ResultAnalyzer(reports_dir)
|
|
xml_path = analyzer.copy_xml_results_to_reports(mt5_terminal_dir, ea_name, reports_dir)
|
|
if xml_path:
|
|
self.opt_log.insert('end', f"从MT5复制结果: {xml_path}\n")
|
|
xml_results = analyzer.parse_mt5_xml_optimization(xml_path)
|
|
results.extend(xml_results)
|
|
self.opt_log.insert('end', f"解析到 {len(xml_results)} 条优化结果\n")
|
|
|
|
if not results:
|
|
results = optimizer.analyze_results()
|
|
|
|
if not results:
|
|
self.opt_log.insert('end', "未找到结果文件\n")
|
|
return
|
|
|
|
self.opt_log.insert('end', f"共 {len(results)} 个结果\n")
|
|
criterion = self.opt_criterion.get()
|
|
|
|
criterion_key_map = {
|
|
'余额最大值': 'net_profit',
|
|
'盈利因子最大值': 'profit_factor',
|
|
'期望收益': 'expected_payoff',
|
|
'回撤最小值': 'max_drawdown',
|
|
'恢复因子': 'recovery_factor',
|
|
'夏普比率': 'sharpe_ratio',
|
|
'自定义': 'custom',
|
|
'复合指标': 'result',
|
|
}
|
|
criterion_key = 'profit_factor'
|
|
for key, val in criterion_key_map.items():
|
|
if key in criterion:
|
|
criterion_key = val
|
|
break
|
|
|
|
filtered = []
|
|
for r in results:
|
|
trades = r.get('total_trades', 0)
|
|
pf = r.get('profit_factor', 0)
|
|
if trades >= 1 and pf > 0:
|
|
filtered.append(r)
|
|
|
|
if filtered:
|
|
filtered.sort(key=lambda x: x.get(criterion_key, 0), reverse=True)
|
|
best = filtered[0]
|
|
self.opt_log.insert('end', f"\n最优配置 ({criterion}):\n")
|
|
self.opt_log.insert('end', f" PF: {best.get('profit_factor', 0)}\n")
|
|
self.opt_log.insert('end', f" 净利润: {best.get('net_profit', 0)}\n")
|
|
self.opt_log.insert('end', f" 交易数: {best.get('total_trades', 0)}\n")
|
|
self.opt_log.insert('end', f" 最大回撤: {best.get('max_drawdown', 0)}\n")
|
|
self.opt_log.insert('end', f" 夏普比率: {best.get('sharpe_ratio', 0)}\n")
|
|
report_path = os.path.join(self.opt_dir.get(), 'results', f'{ea_name}_report.txt')
|
|
analyzer.generate_report(filtered[:10], report_path)
|
|
self.opt_log.insert('end', f"\n报告已保存: {report_path}\n")
|
|
else:
|
|
self.opt_log.insert('end', "没有符合条件的优化结果\n")
|
|
|
|
self.opt_log.insert('end', "\n--- Walk-Forward 分析 ---\n")
|
|
configs_dir = os.path.join(self.opt_dir.get(), 'configs')
|
|
config_path = os.path.join(configs_dir, ea_name + '.json')
|
|
if os.path.exists(config_path):
|
|
with open(config_path, 'r', encoding='utf-8') as f:
|
|
import json
|
|
ea_cfg = json.load(f)
|
|
wf = ea_cfg.get('walk_forward', {})
|
|
if wf.get('enabled'):
|
|
wf_result = analyzer.analyze_walk_forward(mt5_terminal_dir, ea_name, reports_dir)
|
|
is_pf = wf_result.get('in_sample', {}).get('profit_factor', 0)
|
|
oos_pf = wf_result.get('out_of_sample', {}).get('profit_factor', 0)
|
|
self.opt_log.insert('end', f"In-Sample PF: {is_pf}\n")
|
|
self.opt_log.insert('end', f"Out-of-Sample PF: {oos_pf}\n")
|
|
self.opt_log.insert('end', f"PF衰减: {wf_result.get('pf_decay', 'N/A')}%\n")
|
|
self.opt_log.insert('end', f"回撤增长: {wf_result.get('dd_increase', 'N/A')}%\n")
|
|
self.opt_log.insert('end', f"判定: {wf_result.get('verdict', 'Unknown')}\n")
|
|
else:
|
|
self.opt_log.insert('end', "未启用Walk-Forward,跳过分析\n")
|
|
else:
|
|
self.opt_log.insert('end', "无配置文件,跳过Walk-Forward分析\n")
|
|
|
|
|
|
def main():
|
|
root = tk.Tk()
|
|
app = MT5BacktestGUI(root)
|
|
root.mainloop()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main() |