添加后缀映射功能

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
Binary file not shown.
+2523
View File
File diff suppressed because it is too large Load Diff
+85 -2
View File
@@ -11,10 +11,13 @@ mt5_backtest/
│ ├── ini_generator.py # 生成MT5配置文件
│ ├── mt5_auto_runner.py # 自动运行回测
│ ├── batch_executor.py # 批量执行器
│ ├── result_parser.py # 解析HTM/XML报告
── report_generator.py # 生成汇总报告
│ ├── result_parser.py # 解析HTM/XML报告
── report_generator.py # 生成汇总报告
│ ├── symbol_resolver.py # 根品种别名解析(XAUUSD→XAUUSDc
│ └── optimizer/ # EA参数优化系统
├── config/ # 配置文件目录
├── reports/ # 回测报告输出目录
├── tests/ # 自检脚本(test_*.py / smoke_*.py
├── logs/ # 日志目录
└── README.md # 本文档
```
@@ -26,6 +29,7 @@ mt5_backtest/
- 扫描 MT5 的 `MQL5/Experts` 目录获取 EA 列表
- 根据配置的 EA品种、时间周期、参数组合生成 INI 配置文件
- 每个 INI 文件对应一次回测任务
- 写入 INI 的 `Symbol=` 行通过 `scripts/symbol_resolver.py` 查表替换(见下文"根品种别名映射")
### 2. 自动执行阶段 (mt5_auto_runner.py)
- 通过命令行调用 MT5 终端: `terminal64.exe /config:xxx.ini`
@@ -79,6 +83,10 @@ eas:
backtest_settings:
symbols: ["EURUSD", "GBPUSD", "XAUUSD", "USDJPY"]
timeframes: ["M1", "M5", "M15", "H1"]
# 可选:根品种别名映射(见下文"根品种别名映射")
# symbol_aliases:
# XAUUSD: XAUUSDc # 逻辑 XAUUSD -> 实际 XAUUSDc
# XAGUSD: XAGUSDm # 逻辑 XAGUSD -> 实际 XAGUSDm
date_range:
from: "2026.01.01"
to: "2026.01.04"
@@ -147,6 +155,66 @@ backtest_settings:
| 原油 | XBRUSD, XTIUSD |
| 数字货币 | BTCUSD, ETHUSD, LTCUSD |
## 根品种别名映射 (`symbol_aliases`)
MT5 自带品种(如 `XAUUSD`)历史深度和精度有限;用户常在 MT5 里添加自定义品种
(如 `XAUUSDc` / `XAUUSDm`)作为更长时间范围或优化用途的"替身"。本系统通过 `symbol_aliases`
让你在 yaml 里写一次逻辑品种名、由系统按表替换成实际 MT5 品种。
### 写入 `config/ea_configs.yaml`
```yaml
backtest_settings:
symbols: [XAUUSD, XAGUSD, EURUSD] # 用户的"逻辑品种"
symbol_aliases:
XAUUSD: XAUUSDc # 逻辑 XAUUSD -> 实际 XAUUSDc
XAGUSD: XAGUSDm # 逻辑 XAGUSD -> 实际 XAGUSDm
# EURUSD 不列 -> 始终 EURUSD
```
### 工作机制
1. `scripts/ini_generator.py``scripts/optimizer/ea_batch_optimizer.py`
写 INI 的 `Symbol=` 之前调用 `scripts/symbol_resolver.resolve_symbol()`
2. 解析器是**纯查表**——没有任何"基于日期/优化/周期的智能判断"——写啥用啥:
- 别名表里有这个逻辑品种 → 用别名替换
- 没有 → 原样写入 `Symbol=`
3. 两条调用路径共用同一份解析器(`scripts/symbol_resolver.py`),保证 GUI 回测页
和优化器生成的 INI 行为一致。
### GUI 优化页
GUI 优化页下拉只显示**逻辑品种**(不再同时列出 XAUUSD / XAUUSDc)。旁边有一个
`→ XAUUSDc` 提示标签,**实时反映 yaml 里 `symbol_aliases` 当前配置下实际会用什么品种**。
改 yaml 后重启 GUI 即可看到提示变化,无需在 GUI 里反复切换。
### 验证
无需 MT5 也能验:
```bash
python tests/test_symbol_resolver.py # 9 条查表断言
python tests/test_ini_generator.py # 5 条端到端断言
```
需要 MT5 验证完整流水线:
```bash
python tests/smoke_alias_backtest.py
# 跑两次真实回测:一次无别名 (Symbol=XAUUSD),一次有别名 (Symbol=XAUUSDc)
# 期望:C:\Users\Administrator\Desktop\mt5-backtest\reports 下生成两份 .htm 报告
# 报告"交易品种"字段分别是 XAUUSD 和 XAUUSDc
```
> 注:`smoke_alias_backtest.py` 顶部硬编码了 MT5 路径和 data dir hash,复制到其他电脑后
> 需要相应修改 `MT5_PATH` / `MT5_DATA_DIR` 常量。
### 扩展点(ponytail
`scripts/symbol_resolver.py` 是共享函数入口,将来要把"配置源"切换成数据库或要做
MT5 `SymbolSelect` 校验,**只改这一个文件**即可,所有调用方(ini 生成器、批量优化器、
GUI 预览)同步生效。
## 如何添加新的时间周期
编辑 `config/ea_configs.yaml`
@@ -537,6 +605,7 @@ mt5_backtest/
│ ├── batch_executor.py # 批量执行器
│ ├── result_parser.py # 解析HTM/XML报告
│ ├── report_generator.py # 生成汇总报告
│ ├── symbol_resolver.py # 根品种别名解析(symbol_aliases 查表)
│ └── optimizer/ # EA优化系统
│ ├── ea_config_parser.py # EA配置解析
│ ├── smart_search.py # 搜索策略
@@ -545,6 +614,7 @@ mt5_backtest/
│ └── ea_batch_optimizer.py # 批量优化器
├── config/ # 配置文件
├── reports/ # 回测报告目录
├── tests/ # 自检脚本(test_symbol_resolver / test_ini_generator / smoke_alias_backtest
├── optimizer/ # 优化系统目录
│ ├── configs/ # EA配置
│ ├── set_files/ # SET文件
@@ -566,6 +636,19 @@ mt5_backtest/
### Q: 中文显示乱码
HTM 报告采用 UTF-16-LE 编码,result_parser.py 会自动处理,无需手动转换
### Q: 想用 MT5 自定义品种(如 XAUUSDc)跑同一 EA
`config/ea_configs.yaml``backtest_settings` 下加:
```yaml
symbol_aliases:
XAUUSD: XAUUSDc
```
yaml 符号表里仍写 `XAUUSD`,系统会自动把 INI 的 `Symbol=` 替换为 `XAUUSDc`
详见上文"根品种别名映射"一节。
### Q: 改了 `symbol_aliases` 后 GUI 提示没变
GUI 启动时读取一次 `config/ea_configs.yaml`,改完后重启 GUI 即可看到
`→ XAUUSDc` 提示标签更新。无需重新生成报告历史。
## 许可证
MIT License
+36
View File
@@ -14,10 +14,46 @@ backtest_settings:
shutdown_terminal: true
symbols:
- EURUSD
- GBPUSD
- USDJPY
- USDCHF
- EURJPY
- EURGBP
- GBPJPY
- XAUUSD
timeframes:
- H1
visual: 0
# ---- 根品种别名映射(可选,留空/删除整块 = 关闭此功能,行为与传统一致)----
# 纯查表,没有任何"自动判断"。逻辑品种写在上面的 symbols 列表里,
# 想让系统实际用哪个 MT5 品种就在这里写死。
#
# 用法:取消下面想启用的那一行(或多行)的注释即可。
# 同一时间只能有一条 XAUUSD 映射生效;切换另一种后缀时把上一条注释掉。
# 注意:GUI 的"保存配置"不会动这块内容(symbol_aliases 在 GUI 里没有编辑控件),
# 改完直接保存即可,存盘不会被覆盖。GUI 重启后提示标签自动更新。
# symbol_aliases:
# XAUUSD: XAUUSDc # 常用:自定义品种 XAUUSDc(带扩展历史 / 精度)
# XAUUSD: XAUUSDm # 切换:自定义品种 XAUUSDm
# XAUUSD: Gold.v2 # 切换:完全不同的名字(broker 自定义 / ECN 后缀等)
# XAUUSD: XAUUSD # 显式直通:等价于不写这条(写出来仅为自解释)
# XAGUSD: XAGUSDc # 银同理
# XAGUSD: XAGUSDm
# BTCUSD: BTCUSD.sb # 加密货币自定义后缀
# # EURUSD 没列 # 永远不需要列:未列出的逻辑品种按原名直传
#
# 关闭整个功能:把整块(包括 symbol_aliases: 这行)注释掉或删除。
# 关闭后 GUI "实际品种"提示行自动消失,INI 里的 Symbol= 与逻辑名完全一致。
symbol_aliases:
EURUSD: EURUSDc
GBPUSD: GBPUSDc
USDJPY: USDJPYc
USDCHF: USDCHFc
EURJPY: EURJPYc
EURGBP: EURGBPc
GBPJPY: GBPJPYc
XAUUSD: XAUUSDc
eas: []
execution:
kill_between: true
+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()
+28
View File
@@ -0,0 +1,28 @@
2026-07-06 20:26:24,598 - INFO - Executing: MACD_MA_XAUUSD_5M_XAUUSD_H1_default.ini
2026-07-06 20:26:24,620 - INFO - MT5 started PID: 18668
2026-07-06 20:27:22,638 - INFO - MT5 process ended
2026-07-06 20:27:22,638 - INFO - Report copied to: C:\Users\Administrator\Desktop\mt5-backtest\reports\MACD_MA_XAUUSD_5M_XAUUSD_H1_default.htm
2026-07-06 20:27:22,638 - INFO - Success: MACD_MA_XAUUSD_5M_XAUUSD_H1_default.ini (58s)
2026-07-06 20:27:22,644 - INFO - Executing: MACD_MA_XAUUSD_5M_XAUUSDc_H1_default.ini
2026-07-06 20:27:22,670 - INFO - MT5 started PID: 21372
2026-07-06 20:27:48,676 - INFO - MT5 process ended
2026-07-06 20:27:48,679 - INFO - Report copied to: C:\Users\Administrator\Desktop\mt5-backtest\reports\MACD_MA_XAUUSD_5M_XAUUSDc_H1_default.htm
2026-07-06 20:27:48,679 - INFO - Success: MACD_MA_XAUUSD_5M_XAUUSDc_H1_default.ini (26s)
=== run 1: no alias (should pick XAUUSD) ===
[no_alias] INI Symbol line: Symbol=XAUUSD
[no_alias] runner status: completed, report_path: C:\Users\Administrator\Desktop\mt5-backtest\reports\MACD_MA_XAUUSD_5M_XAUUSD_H1_default.htm
=== run 2: alias XAUUSD -> XAUUSDc (should pick XAUUSDc) ===
[with_alias] INI Symbol line: Symbol=XAUUSDc
[with_alias] runner status: completed, report_path: C:\Users\Administrator\Desktop\mt5-backtest\reports\MACD_MA_XAUUSD_5M_XAUUSDc_H1_default.htm
=== summary ===
INI1 (MACD_MA_XAUUSD_5M_XAUUSD_H1_default.ini) -> completed, C:\Users\Administrator\Desktop\mt5-backtest\reports\MACD_MA_XAUUSD_5M_XAUUSD_H1_default.htm
INI2 (MACD_MA_XAUUSD_5M_XAUUSDc_H1_default.ini) -> completed, C:\Users\Administrator\Desktop\mt5-backtest\reports\MACD_MA_XAUUSD_5M_XAUUSDc_H1_default.htm
Reports in C:\Users\Administrator\Desktop\mt5-backtest\reports:
- MACD_MA_XAUUSD_5M_XAUUSD_H1_default.htm (52680 bytes) symbol=XAUUSD
- MACD_MA_XAUUSD_5M_XAUUSDc_H1_default.htm (28932 bytes) symbol=XAUUSDc
Distinct symbols found: ['XAUUSD', 'XAUUSDc']
SUCCESS: both XAUUSD and XAUUSDc reports present in target dir.
+11 -5
View File
@@ -7,6 +7,7 @@ from typing import Dict, List, Any, Optional
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from mt5_paths import resolve_mt5_settings
from symbol_resolver import resolve_symbol
class INIGenerator:
@@ -115,12 +116,16 @@ class INIGenerator:
else:
ini_lines.append("ExpertParameters=")
# MT5 tester delay is controlled by ExecutionMode itself:
# 0 = no delay
# -1 = random delay
# > 0 = fixed delay in milliseconds
execution_delay = int(bt_settings.get('execution_delay', 0) or 0)
ini_lines.extend([
f"Symbol={symbol}",
f"Period={self._get_timeframe_code(timeframe)}",
f"Model={bt_settings['model']}",
f"ExecutionMode={bt_settings.get('execution_mode', 0)}",
f"ExecutionDelay={bt_settings.get('execution_delay', 0)}",
f"ExecutionMode={execution_delay}",
f"Optimization={bt_settings['optimization']}",
])
if bt_settings.get("optimization"):
@@ -184,14 +189,15 @@ class INIGenerator:
param_combinations = self._generate_parameter_combinations(parameters)
for symbol in bt_settings["symbols"]:
resolved_symbol = resolve_symbol(symbol, bt_settings)
for timeframe in bt_settings["timeframes"]:
for param_combo in param_combinations:
ini_content = self._build_ini_content(
ea_filename, symbol, timeframe,
ea_filename, resolved_symbol, timeframe,
bt_settings, param_combo, ea_name, set_file
)
ini_filename = self._generate_filename(
ea_name, symbol, timeframe, param_combo
ea_name, resolved_symbol, timeframe, param_combo
)
ini_path = os.path.join(self.output_dir, ini_filename)
@@ -253,4 +259,4 @@ def main():
if __name__ == "__main__":
main()
main()
+8 -1
View File
@@ -225,6 +225,13 @@ class MT5AutoRunner:
logger.info(f"Total INI files: {total}")
# First run must also start from a clean tester state. Otherwise the first test
# can inherit an already-open terminal's previous deposit/delay/symbol settings,
# while later tests look correct only because kill_between_tests runs after them.
if kill_between_tests:
self._kill_mt5()
time.sleep(2)
for idx, ini_path in enumerate(ini_files, 1):
logger.info(f"[{idx}/{total}] {os.path.basename(ini_path)}")
@@ -352,4 +359,4 @@ def main():
if __name__ == "__main__":
main()
main()
+2 -1
View File
@@ -13,6 +13,7 @@ from ea_config_parser import load_ea_config, load_all_ea_configs, EAConfig
from parameter_constraint import create_constraint_engine
from smart_search import SmartSearch
from analyze_results import ResultAnalyzer
from symbol_resolver import resolve_symbol
class EABatchOptimizer:
@@ -157,7 +158,7 @@ class EABatchOptimizer:
'[Tester]',
'Expert=' + ea_config.ea_path,
'ExpertParameters=' + set_filename,
'Symbol=' + test_config.get('symbol', 'EURUSD'),
'Symbol=' + resolve_symbol(test_config.get('symbol', 'EURUSD'), test_config),
'Period=' + test_config.get('period', 'H1'),
'Model=' + str(test_config.get('model', 1)),
'FromDate=' + date_from,
+28
View File
@@ -0,0 +1,28 @@
"""Root symbol alias resolver (pure lookup, no auto-switching).
用户视角的"逻辑品种" XAUUSD映射到 MT5 里实际使用的品种
ponytail:
- 没有别名表 / 没条目 -> 原样返回零行为变化向后兼容
- 有条目 -> 一比一映射写啥用啥不做任何基于日期/优化/周期的判断
- 升级路径要从 yaml 切到数据库 / MT5 SymbolSelect 校验只改这里
配置示例ea_configs.yaml:
symbol_aliases:
XAUUSD: XAUUSDc # 逻辑 XAUUSD -> 实际 XAUUSDc
XAGUSD: XAGUSDm # 逻辑 XAGUSD -> 实际 XAGUSDm
# EURUSD 没列 -> 始终 EURUSD
"""
from typing import Any, Dict
def resolve_symbol(symbol: str, bt_settings: Dict[str, Any]) -> str:
"""逻辑品种 -> 实际 MT5 品种。无任何自动判断,按表直查。"""
if not symbol:
return symbol
aliases = bt_settings.get("symbol_aliases") or {}
mapped = aliases.get(symbol)
if not mapped: # None / "" / 缺失 -> 直通
return symbol
return mapped
+143
View File
@@ -0,0 +1,143 @@
# Real backtest smoke test for the symbol alias resolver.
# Runs two backtests via the project's pipeline (INIGenerator + MT5AutoRunner):
# 1) XAUUSD (no alias) -> INI Symbol=XAUUSD
# 2) XAUUSD (alias -> XAUUSDc) -> INI Symbol=XAUUSDc
# Success: two .htm reports in C:\Users\Administrator\Desktop\mt5-backtest\reports,
# one with Symbol=XAUUSD, one with Symbol=XAUUSDc.
import os
import sys
import re
import shutil
HERE = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.normpath(os.path.join(HERE, ".."))
SCRIPTS = os.path.join(PROJECT_ROOT, "scripts")
EXAMPLES_ROOT = os.path.normpath(os.path.join(PROJECT_ROOT, "..", "examples"))
TARGET_REPORTS = r"C:\Users\Administrator\Desktop\mt5-backtest\reports"
MT5_PATH = r"C:\Program Files\MetaTrader 5 IC Markets Global\terminal64.exe"
MT5_DATA_DIR = r"C:\Users\Administrator\AppData\Roaming\MetaQuotes\Terminal\010E047102812FC0C18890992854220E"
MT5_TESTER_DIR = os.path.join(MT5_DATA_DIR, "MQL5", "Profiles", "Tester")
EA_NAME = "MACD_MA_XAUUSD_5M"
EA_BASENAME = f"{EA_NAME}.ex5"
EA_FULL_PATH = os.path.join(MT5_DATA_DIR, "MQL5", "Experts", EA_BASENAME)
SET_SRC = os.path.join(EXAMPLES_ROOT, "configs", f"{EA_NAME}.set")
SET_DEST_NAME = "MACD_MA_XAUUSD_5M_smoke.set"
SET_DEST_PATH = os.path.join(MT5_TESTER_DIR, SET_DEST_NAME)
os.makedirs(TARGET_REPORTS, exist_ok=True)
shutil.copy2(SET_SRC, SET_DEST_PATH)
sys.path.insert(0, SCRIPTS)
from ini_generator import INIGenerator
from mt5_auto_runner import MT5AutoRunner
def build_cfg(aliases, out_ini_dir):
return {
"backtest_settings": {
"currency": "USD",
"date_range": {"from": "2026.05.01", "to": "2026.06.05"},
"deposit": 10000,
"execution_delay": 0,
"forward_date": "",
"forward_mode": 0,
"leverage": "1:100",
"model": 0,
"optimization": 0,
"replace_report": True,
"shutdown_terminal": True,
"symbols": ["XAUUSD"],
"timeframes": ["H1"],
"visual": 0,
"symbol_aliases": aliases or {},
},
"eas": [
{"name": EA_NAME, "filename": EA_FULL_PATH, "set_file": SET_DEST_NAME},
],
"mt5_settings": {
"terminal_path": MT5_PATH,
"data_dir": MT5_DATA_DIR,
"ini_dir": out_ini_dir,
"reports_dir": TARGET_REPORTS,
},
}
def patch_ini_to_inline(ini_path):
"""Patch the INI to use relative Expert + inline [TesterInputs]
(the format MT5 actually auto-starts with on this terminal)."""
with open(ini_path, encoding="utf-8") as f:
text = f.read()
text = text.replace(f"Expert={EA_FULL_PATH}", f"Expert={EA_BASENAME}")
text = text.replace(f"ExpertParameters={SET_DEST_NAME}", "ExpertParameters=")
with open(SET_DEST_PATH, encoding="utf-8", errors="ignore") as f:
set_text = f.read()
if not text.rstrip().endswith("[TesterInputs]"):
text = text.rstrip() + "\n\n[TesterInputs]\n" + set_text
with open(ini_path, "w", encoding="utf-8") as f:
f.write(text)
def run_one(label, aliases, out_dir):
cfg = build_cfg(aliases=aliases, out_ini_dir=out_dir)
gen = INIGenerator(cfg)
ini_files = gen.generate_ini_files()
assert len(ini_files) == 1, f"{label}: expected 1 INI, got {len(ini_files)}"
ini = ini_files[0]
patch_ini_to_inline(ini)
with open(ini, encoding="utf-8") as f:
sym_line = next(l for l in f if l.startswith("Symbol="))
print(f"[{label}] INI Symbol line: {sym_line.strip()}")
runner = MT5AutoRunner(cfg)
res = runner._execute_single_ini(ini, timeout_min=15)
print(f"[{label}] runner status: {res['status']}, report_path: {res.get('report_path')}")
return ini, res
def main():
tmp_root = os.path.join(PROJECT_ROOT, "config", "generated_smoke")
if os.path.exists(tmp_root):
shutil.rmtree(tmp_root)
os.makedirs(tmp_root, exist_ok=True)
out1 = os.path.join(tmp_root, "no_alias"); os.makedirs(out1, exist_ok=True)
out2 = os.path.join(tmp_root, "with_alias"); os.makedirs(out2, exist_ok=True)
print("=== run 1: no alias (should pick XAUUSD) ===")
ini1, r1 = run_one("no_alias", aliases=None, out_dir=out1)
print()
print("=== run 2: alias XAUUSD -> XAUUSDc (should pick XAUUSDc) ===")
ini2, r2 = run_one("with_alias", aliases={"XAUUSD": "XAUUSDc"}, out_dir=out2)
print()
print("=== summary ===")
print(f" INI1 ({os.path.basename(ini1)}) -> {r1['status']}, {r1.get('report_path')}")
print(f" INI2 ({os.path.basename(ini2)}) -> {r2['status']}, {r2.get('report_path')}")
reports = sorted([f for f in os.listdir(TARGET_REPORTS)
if f.startswith('MACD_MA_XAUUSD_5M_') and f.endswith('.htm')])
print(f"\nReports in {TARGET_REPORTS}:")
found = {"XAUUSD": [], "XAUUSDc": []}
for r in reports:
path = os.path.join(TARGET_REPORTS, r)
size = os.path.getsize(path)
# MT5 saves reports as UTF-16 LE with BOM
text = open(path, encoding='utf-16', errors='ignore').read()
m = re.search(r'交易品种:</td>\s*<td[^>]*><b>([^<]+)</b>', text)
sym = m.group(1) if m else "?"
found[sym].append(r)
print(f" - {r} ({size} bytes) symbol={sym}")
print(f"\nDistinct symbols found: {sorted({k for k, v in found.items() if v})}")
if not found["XAUUSD"]:
raise AssertionError("No report with Symbol=XAUUSD")
if not found["XAUUSDc"]:
raise AssertionError("No report with Symbol=XAUUSDc")
print("SUCCESS: both XAUUSD and XAUUSDc reports present in target dir.")
if __name__ == "__main__":
main()
+96
View File
@@ -0,0 +1,96 @@
"""End-to-end: INIGenerator picks the right MT5 Symbol from explicit alias map.
Run: python tests/test_ini_generator.py
"""
import os
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.normpath(os.path.join(HERE, "..", "scripts")))
from ini_generator import INIGenerator
def _make_cfg(aliases, symbols, out_dir):
return {
"mt5_settings": {"terminal_path": "", "data_dir": "",
"reports_dir": "{PROJECT_ROOT}/reports", "ini_dir": out_dir},
"backtest_settings": {
"currency": "USD",
"date_range": {"from": "2010.01.01", "to": "2024.12.31"},
"deposit": 10000, "execution_delay": 0, "forward_date": "",
"forward_mode": 0, "leverage": "1:100", "model": 0,
"optimization": 0, "replace_report": True, "shutdown_terminal": True,
"symbols": symbols, "timeframes": ["H1"], "visual": 0,
"symbol_aliases": aliases,
},
"eas": [{"name": "DummyEA", "filename": "MQL5/Experts/DummyEA.ex5", "parameters": {}}],
}
def _grep_symbol(ini_path, target):
with open(ini_path, "r", encoding="utf-8") as f:
for line in f:
if line.startswith("Symbol="):
return line.strip().split("=", 1)[1] == target
return False
def _get_ini_value(ini_path, key):
with open(ini_path, "r", encoding="utf-8") as f:
for line in f:
if line.startswith(f"{key}="):
return line.strip().split("=", 1)[1]
return None
def expect(label, ok):
print(f" {'PASS' if ok else 'FAIL'} {label}")
if not ok:
raise AssertionError(label)
def main():
with tempfile.TemporaryDirectory() as tmp:
cfg = _make_cfg({"XAUUSD": "XAUUSDc"}, ["XAUUSD", "EURUSD"], tmp)
files = INIGenerator(cfg).generate_ini_files()
expect("2 files", len(files) == 2)
expect("XAUUSD alias applied", any(_grep_symbol(f, "XAUUSDc") for f in files))
expect("EURUSD passes through", any(_grep_symbol(f, "EURUSD") for f in files))
with tempfile.TemporaryDirectory() as tmp:
cfg = _make_cfg({}, ["XAUUSD"], tmp)
files = INIGenerator(cfg).generate_ini_files()
expect("no alias -> bare", any(_grep_symbol(f, "XAUUSD") for f in files))
with tempfile.TemporaryDirectory() as tmp:
cfg = _make_cfg({"XAUUSD": "XAUUSDm"}, ["XAUUSD"], tmp)
files = INIGenerator(cfg).generate_ini_files()
expect("explicit override wins", any(_grep_symbol(f, "XAUUSDm") for f in files))
with tempfile.TemporaryDirectory() as tmp:
cfg = _make_cfg({}, ["XAUUSD"], tmp)
cfg["backtest_settings"]["execution_delay"] = 0
files = INIGenerator(cfg).generate_ini_files()
expect("delay=0 -> ExecutionMode=0", _get_ini_value(files[0], "ExecutionMode") == "0")
expect("delay=0 -> no ExecutionDelay line", _get_ini_value(files[0], "ExecutionDelay") is None)
with tempfile.TemporaryDirectory() as tmp:
cfg = _make_cfg({}, ["XAUUSD"], tmp)
cfg["backtest_settings"]["execution_delay"] = -1
files = INIGenerator(cfg).generate_ini_files()
expect("delay=-1 -> ExecutionMode=-1", _get_ini_value(files[0], "ExecutionMode") == "-1")
with tempfile.TemporaryDirectory() as tmp:
cfg = _make_cfg({}, ["XAUUSD"], tmp)
cfg["backtest_settings"]["execution_delay"] = 250
files = INIGenerator(cfg).generate_ini_files()
expect("delay=250 -> ExecutionMode=250", _get_ini_value(files[0], "ExecutionMode") == "250")
print("\nAll INI generator alias checks passed.")
if __name__ == "__main__":
main()
+60
View File
@@ -0,0 +1,60 @@
"""Symbol resolver self-check (pure lookup, no auto logic).
Run: python tests/test_symbol_resolver.py
"""
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.normpath(os.path.join(HERE, "..", "scripts")))
from symbol_resolver import resolve_symbol
def _bt(aliases=None):
return {"symbol_aliases": aliases or {}}
def expect(label, got, want):
ok = got == want
print(f" {'PASS' if ok else 'FAIL'} {label}: got={got!r} want={want!r}")
if not ok:
raise AssertionError(label)
def main():
print("[1] has alias -> use alias")
expect("XAUUSD -> XAUUSDc", resolve_symbol("XAUUSD", _bt({"XAUUSD": "XAUUSDc"})), "XAUUSDc")
print("[2] has alias for another symbol -> that one passes through")
expect("EURUSD no alias", resolve_symbol("EURUSD", _bt({"XAUUSD": "XAUUSDc"})), "EURUSD")
print("[3] no aliases config -> bare (back-compat)")
expect("no config", resolve_symbol("XAUUSD", _bt()), "XAUUSD")
print("[4] alias value can be any string (suffix, full name, even identical)")
expect("alias = c suffix", resolve_symbol("XAUUSD", _bt({"XAUUSD": "c"})), "c")
print("[5] empty symbol -> empty")
expect("empty", resolve_symbol("", _bt({"": "XAUUSDc"})), "")
print("[6] aliases value None -> bare")
expect("None value", resolve_symbol("XAUUSD", _bt({"XAUUSD": None})), "XAUUSD")
print("[7] symbol_aliases missing key entirely -> bare")
expect("missing key", resolve_symbol("XAUUSD", {"date_range": {"from": "2020.01.01", "to": "2024.01.01"}}), "XAUUSD")
print("[8] long date span does NOT auto-switch (explicit override only)")
expect("long span no alias", resolve_symbol("XAUUSD", {"date_range": {"from": "2010.01.01", "to": "2024.01.01"}}), "XAUUSD")
print("[9] explicit alias always wins regardless of date/optimization context")
ctx = {"date_range": {"from": "2010.01.01", "to": "2024.01.01"},
"optimization": 1, "symbol_aliases": {"XAUUSD": "XAUUSDm"}}
expect("explicit + opt", resolve_symbol("XAUUSD", ctx), "XAUUSDm")
print("\nAll symbol resolver checks passed.")
if __name__ == "__main__":
main()
+16
View File
@@ -0,0 +1,16 @@
# Verify the two backtest reports.
import os, re
TARGET = r"C:\Users\Administrator\Desktop\mt5-backtest\reports"
reports = sorted([f for f in os.listdir(TARGET) if f.startswith('MACD_MA_XAUUSD_5M_') and f.endswith('.htm')])
print(f"Found {len(reports)} candidate reports:")
for r in reports:
p = os.path.join(TARGET, r)
size = os.path.getsize(p)
text = open(p, encoding='utf-8', errors='ignore').read()
m = re.search(r'交易品种:</td>\s*<td[^>]*><b>([^<]+)</b>', text)
sym = m.group(1) if m else "(not found)"
has_xauusd = 'XAUUSD' in text
has_xauusdc = 'XAUUSDc' in text
print(f" {r} size={size} symbol_in_report={sym} contains_XAUUSD={has_xauusd} contains_XAUUSDc={has_xauusdc}")