添加后缀映射功能

This commit is contained in:
2026-07-06 21:58:56 +08:00
parent f94728fa88
commit 29428b415b
16 changed files with 3157 additions and 48 deletions
+121 -39
View File
@@ -21,6 +21,7 @@ try:
from mt5_auto_runner import MT5AutoRunner
from result_parser import ResultParser
from report_generator import ReportGenerator
from symbol_resolver import resolve_symbol
except Exception as _e:
_IMPORT_ERRORS.append(repr(_e))
sys.stderr.write(f"[IMPORT FAIL] {_e}\n")
@@ -29,6 +30,7 @@ except Exception as _e:
MT5AutoRunner = None # type: ignore
ResultParser = None # type: ignore
ReportGenerator = None # type: ignore
resolve_symbol = None # type: ignore
def _gui_log(msg: str) -> None:
@@ -66,6 +68,18 @@ def _mt5_startupinfo():
return si
# MT5 策略测试器回测模式(与 MT5 官方界面"每个报价/1分钟OHLC/仅开盘价/数学计算/基于每个真实报价"一致)
# 值是写入 INI 的 Model= 编号。批量页和优化页共用本列表,切换显示文字时只改这一处。
MT5_MODELS = [
'每个报价 (0)',
'1分钟OHLC (1)',
'仅开盘价 (2)',
'数学计算 (3)',
'基于每个真实报价 (4)',
]
MT5_MODEL_MAP = {int(s.split('(')[1].rstrip(')')): s for s in MT5_MODELS}
def _find_mt5_hwnds(pid, max_seconds=3.0):
hwnds = []
deadline = time.time() + max_seconds
@@ -401,6 +415,13 @@ class MT5BacktestGUI:
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
var.trace_add('write', lambda *_: self._update_batch_symbol_hint())
self.batch_sym_hint_var = tk.StringVar(value="")
ttk.Label(sym_frame, textvariable=self.batch_sym_hint_var,
foreground='#0066cc', wraplength=900, justify='left'
).grid(row=99, column=0, columnspan=10, sticky='w', padx=2, pady=(4, 2))
self._update_batch_symbol_hint()
tf_frame = ttk.LabelFrame(main_frame, text="时间周期")
tf_frame.pack(fill='x', padx=5, pady=5)
@@ -450,21 +471,32 @@ class MT5BacktestGUI:
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)
for m in MT5_MODELS:
val = int(m.split('(')[1].rstrip(')'))
ttk.Radiobutton(model_frame, text=m, variable=self.model_var, value=val).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)
saved_delay = int((self.config.get("backtest_settings") or {}).get("execution_delay", 0) or 0)
if saved_delay == 0:
delay_type_value = 0
delay_value_text = "100"
elif saved_delay == -1:
delay_type_value = 1
delay_value_text = "100"
else:
delay_type_value = 2
delay_value_text = str(saved_delay)
self.delay_type = tk.IntVar(value=delay_type_value)
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.insert(0, delay_value_text)
self.delay_value.pack(side='left')
ttk.Label(delay_row, text="ms").pack(side='left')
@@ -645,7 +677,9 @@ class MT5BacktestGUI:
"shutdown_terminal": True,
"optimization": 0,
"forward_mode": forward_mode,
"forward_date": forward_date
"forward_date": forward_date,
# symbol_aliases 在 GUI 里没有编辑控件,直接从内存配置透传(避免保存时把它清掉)
"symbol_aliases": (self.config.get("backtest_settings") or {}).get("symbol_aliases") or {},
},
"mt5_settings": {
"terminal_path": self.mt5_path.get(),
@@ -1469,18 +1503,21 @@ class MT5BacktestGUI:
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)
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)
self.opt_symbol_resolved = ttk.Label(sym_row, text="→ XAUUSD", foreground='#666666')
self.opt_symbol_resolved.pack(side='left', padx=(10, 2))
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 = ttk.Combobox(sym_row, width=20, values=MT5_MODELS)
self.opt_model.set('1分钟OHLC (1)')
self.opt_model.pack(side='left', padx=5)
@@ -1521,6 +1558,9 @@ class MT5BacktestGUI:
self.opt_to_date.insert(0, "2026.01.04")
self.opt_to_date.pack(side='left', padx=5)
self.opt_symbol.bind('<<ComboboxSelected>>', lambda e: self._update_resolved_symbol_hint())
self.opt_symbol.bind('<FocusOut>', lambda e: self._update_resolved_symbol_hint())
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)
@@ -1574,6 +1614,7 @@ class MT5BacktestGUI:
self.opt_log.pack(fill='both', expand=True, padx=5, pady=5)
self._refresh_opt_ea_list()
self._update_resolved_symbol_hint()
def _browse_opt_dir(self):
path = filedialog.askdirectory(initialdir=self.opt_dir.get())
@@ -1736,18 +1777,12 @@ class MT5BacktestGUI:
if test_cfg.get('symbol'):
self.opt_symbol.set(str(test_cfg.get('symbol')))
self._update_resolved_symbol_hint()
if test_cfg.get('period'):
self.opt_period.set(str(test_cfg.get('period')))
if 'model' in test_cfg:
model_val = int(test_cfg.get('model', 1))
model_map = {
0: 'Every Tick (0)',
1: '1分钟OHLC (1)',
2: '仅开盘价 (2)',
3: '数学计算 (3)',
4: '真实Tick (4)',
}
self.opt_model.set(model_map.get(model_val, '1分钟OHLC (1)'))
self.opt_model.set(MT5_MODEL_MAP.get(model_val, '1分钟OHLC (1)'))
if test_cfg.get('from_date'):
self.opt_from_date.delete(0, tk.END)
self.opt_from_date.insert(0, str(test_cfg.get('from_date')))
@@ -1861,6 +1896,53 @@ class MT5BacktestGUI:
mode_val = self.wf_mode_map.get(self.wf_mode.get(), 2) if enabled else 0
self.wf_custom_date.configure(state='normal' if (enabled and mode_val == 4) else 'disabled')
def _format_resolved_hint(self, symbols):
"""Format a hint string showing logical -> resolved symbol mapping.
Shared by batch backtest tab (multi-select) and optimizer tab (single).
Returns "" when nothing is selected or no aliases are configured.
"""
if not symbols:
return ""
aliases = (self.config.get('backtest_settings') or {}).get('symbol_aliases') or {}
if not aliases:
return ""
parts = []
for sym in symbols:
if not sym:
continue
resolved = resolve_symbol(sym, {'symbol_aliases': aliases}) if resolve_symbol else sym
if resolved == sym:
parts.append(f"{sym}(直通)")
else:
parts.append(f"{sym}{resolved}")
return "实际品种:" + ", ".join(parts) if parts else ""
def _update_batch_symbol_hint(self):
if not hasattr(self, 'batch_sym_hint_var'):
return
checked = [s for s, v in self.symbol_vars.items() if v.get()]
self.batch_sym_hint_var.set(self._format_resolved_hint(checked))
def _resolve_test_config(self):
"""Build the minimal dict the resolver needs (only symbol_aliases)."""
return {
'symbol_aliases': (self.config.get('backtest_settings') or {}).get('symbol_aliases') or {},
}
def _update_resolved_symbol_hint(self):
if not hasattr(self, 'opt_symbol_resolved') or resolve_symbol is None:
return
try:
sym = self.opt_symbol.get().split()[0] if ' ' in self.opt_symbol.get() else self.opt_symbol.get()
resolved = resolve_symbol(sym, self._resolve_test_config())
except Exception:
resolved = self.opt_symbol.get()
if resolved == sym:
self.opt_symbol_resolved.config(text=f"{resolved}", foreground='#666666')
else:
self.opt_symbol_resolved.config(text=f"{resolved} (别名)", foreground='#0066cc')
def _save_ea_config(self, quiet=False):
ea_name = self.opt_ea_var.get()
if not ea_name:
@@ -1895,39 +1977,39 @@ class MT5BacktestGUI:
'step': step_val,
'optimize': poptimize == 'Y' if poptimize else True
}
import json
config_path = os.path.join(configs_dir, ea_name + '.json')
config_data = {
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': {
'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(),
'timeout_min': int(self.opt_timeout_min.get()) if self.opt_timeout_min.get().isdigit() else 60
},
'walk_forward': {
'enabled': self.wf_enabled.get(),
'forward_mode': wf_mode_val,
'forward_date': wf_forward_date if (self.wf_enabled.get() and wf_mode_val == 4) else ''
}
'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(),
'timeout_min': int(self.opt_timeout_min.get()) if self.opt_timeout_min.get().isdigit() else 60
},
'walk_forward': {
'enabled': self.wf_enabled.get(),
'forward_mode': wf_mode_val,
'forward_date': wf_forward_date if (self.wf_enabled.get() and wf_mode_val == 4) else ''
}
}
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")
if not quiet:
self.opt_log.update()
return True
self.opt_log.insert('end', f"配置已保存: {config_path}\n")
if not quiet:
self.opt_log.update()
return True
def _get_opt_execution_delay(self):
delay_type = self.opt_delay_type.get()