- mtlib/: shared data loader, backtest engine, metrics, cli wrapper, walkforward, GBK-safe console, smoke test (python -m mtlib smoke) - strategies/xauusd_scalp/: single-source signal (generate_signals + generate_signal_at share _check), params dataclass, all runners - Unify 3 known drifts: session filter (discrete set), spread 0.16, backtest signature defaults 1.2/0.6/1.0/0.2 - Fix walkforward verdict (both halves PF>=1) + drop fake stability ratio - Root shims keep old commands working; data/ holds JSON files - Baseline check: new vs old engine identical trades, PnL, final equity Co-Authored-By: Claude Code <noreply@anthropic.com>
12 KiB
12 KiB
MetaTrader 5 策略开发避坑指南
本指南由 XAUUSD M1 超短线策略开发全过程(2026-09)总结而来。 适用于:任何商品、任何周期的指标策略,在 mtdata-cli + 回测 + 实盘这套工具链下的开发。 每开发一个新策略,先走一遍文末的《上线检查清单》。
1. 工具链环境速览
| 项 | 值/事实 |
|---|---|
| 数据/交易接口 | mtdata-cli(MetaTrader 5 MCP 的命令行封装),必须 --json 输出 |
| 本机 | Windows 11,Git Bash 环境,控制台 GBK 编码 |
| 数据文件 | xauusd_m1_20k.json 等(data_fetch_candles 导出的 JSON,含全部指标列) |
| 回测/实盘 | 策略逻辑必须共用同一套信号代码,禁止各自写一份 |
2. mtdata-cli 契约与易踩坑点(第一条命)
以下每条都是实测踩过的坑,任何脚本编写前必看。写错直接导致无法下单或静默错误。
2.1 交易命令的参数名必须全写
- 方向:
--order-type BUY|SELL(必须大写)。 写buy或--side会报:Missing required argument(s): order_type/__side. Unrecognized argument(s): --sl, --tp. - 止损/止盈:
--stop-loss、--take-profit(不是--sl/--tp)。trade_modify同样用--stop-loss。 - 平仓:
trade_close --ticket <ticket>。 - 命名参数拼接时注意:List 参数
["trade_place", SYMBOL, "--order-type", ..., "--dry-run", "false"]不要用字符串里带空格的方式,容易在 subprocess 里被拆错。
2.2 --dry-run 默认是 true(预览校验,不发单)
- 这是安全默认,但也是新手最大的坑:以为下了单,实际上只是个预览。
- 真实发单必须显式
--dry-run false,且会被环境校验阻断,常见 blocker:raw_quote_timestamp_ahead_of_clock→ 见第 4 节时钟同步invalid_protection_levels→ 账户保护级别/杠杆配置问题
- 脚本里建议做成环境变量开关,默认预览:
DRY_RUN = os.environ.get("XAUUSD_DRY_RUN", "1") == "1" # 默认预览 DRY_FLAG = "--dry-run false" if not DRY_RUN else "--dry-run true"
2.3 trade_get_open 的返回字段是 items,不是 positions
- 读取持仓列表:
raw.get("items") or []。 - 持仓条目字段:
ticket、side、entry_price、sl、tp、time、volume、magic、symbol。 side取值不统一(buy/sell、BUY/SELL、long/short、1/-1都出现过),必须归一化:s = str(raw_side).lower() if s in ("buy","long","1","+1"): return 1 if s in ("sell","short","-1"): return -1
2.4 data_fetch_candles --limit N 返回的是固定 N 根滚动窗口
- 每次调用永远返回恰好 N 根,默认跳过未完成K线(
forming_candle_status: "skipped")。 - 绝不能用
len(candles)判断"是否来新K线"——长度永远不变。 - 必须用最后一行的时间戳判断:
cur_bar_time != last_bar_time。 - 需要包含成形中K线时传
include_incomplete=true(回测默认用收盘K线,别混用)。
2.5 幂等与标记
--idempotency-key <key>:防重复下单(用"时间戳+方向"做 key)。--magic <int>:给该策略的持仓打标记,用于重启后"认领"自己的单子。- 下单成功与否以返回里的
success为准;ticket 字段名不统一(ticket/order_id/deal/position),写个递归查找函数兜底。
2.6 Windows 控制台是 GBK 编码
- 脚本里不要打印非 GBK 字符(如
⚠⚠️),会直接UnicodeEncodeError崩掉整个循环。 - 用 ASCII 替代:
!!代替⚠。打印完记得sys.stdout.flush()(重定向到日志时默认缓冲)。
3. 回测与实盘必须"同一套信号逻辑"(第二条命)
3.1 信号代码重复拷贝是最大的隐患
本项目最初有 backtest / walkforward / optimize / live 四个文件,各自实现了一份 generate_signals。陷阱:
xauusd_optimize.py的时段过滤是(7<=h<=16) or (12<=h<=21),与另外三个文件的离散集合{7,8,9,10,16,17,18,20}不一致。- optimize 做出来的参数在 backtest 里完全可能是另一回事。
规范(已重构落地,2026-09):
- 信号逻辑只存在
strategies/<策略>/strategy.py一处:generate_signals(向量版)与generate_signal_at(单K线版)共用私有_check(i)。 - 回测/样本外/优化/时段分析/实盘全部
import同一函数;实盘传入mtlib.cli.df_from_candles()构造的滚动窗口 DataFrame,调generate_signal_at(cdf, len(cdf)-1),不再存在第二份规则顺序。 - 商品事实(点差/合约/时段)收在
mtlib/instruments.py,策略参数收在strategies/<策略>/params.py。 - 一致性由冒烟测试把关:
python -m mtlib smoke逐根K线断言generate_signal_at == generate_signals,并做新旧引擎基准数值校验。
3.2 回测里的三个常见偏差(在本项目中各踩了一个)
- 移动止损的 lookahead 偏差:回测在 bar 收盘时更新 SL,但同根 bar 内实际先用哪段价格、SL 是否"提前生效"在回测里处理得较乐观。对剥头皮这种薄边际策略,回测结果系统性偏高。
- 成交假设偏差:回测假设信号在收盘价成交;实盘是下一根/即时价成交,还有滑点。
- 参数默认值漂移:
backtest()函数签名里tp_atr_mult=2.0, sl_atr_mult=1.0, trailing_step_atr=0.5,而main()实际传的是1.2/0.6/0.2。函数默认值像"官方参数",其实从未被使用——阅读代码的人会被骗。签名默认值必须 == main 实际传参,或用常量统一收口。
4. 时钟同步:真单下发前的第一道关卡(第三条命)
4.1 现象
本机系统时钟比 MT5 服务器 tick 时间慢了约 56 秒,导致 trade_place --dry-run false 校验被
raw_quote_timestamp_ahead_of_clock 阻断——第一条真实订单会直接卡死。
4.2 检查与修复
- 查看时差:
w32tm /stripchart 时间服务器或脚本打印now()与报价时间对比。 - 一键同步:
w32tm /resync(Git Bash 下要用cmd //c w32tm /resync,避免正斜杠被解析)。 - 若
0x80070005拒绝访问 /0x80070057参数错误:用管理员 PowerShell 写注册表再重启服务:Type=NTP、NtpServer=time.windows.com pool.ntp.orgMaxPosPhaseCorrection/MaxNegPhaseCorrection = 0xFFFFFFFF- 重启
w32time服务后w32tm /resync /force。
- 标准:修复后时差应在 ±1s 内,再测
trade_place的blockers: []。
4.3 预防
- 实盘脚本启动时打印本机 UTC 时间与最后报价时间误差,>5s 告警。
- 任何"预览通过但真实单下不去"的环境校验,先查时钟。
5. 交易成本建模:点差必须实测,不能拍脑袋(第四条命)
5.1 血的教训
- 早期回测假设点差 0.06 USD;实测黄金真实点差约 0.16(16 points),是假设的 2.7 倍。
- 对持单仅 2~3 根 M1 的剥头皮策略,0.16 的口径就能把训练段从"+3.7% 勉强正"打成 "−1.4% 亏损"(PF 0.96 < 1)。
5.2 规范动作
- 每个商品、每个时段都要实测点差(bid/ask 差),写进回测参数,不要沿用默认。
- 压力测试点差:至少跑 预期点差 / 最高点差(如 0.16 / 0.22)两档。策略若在点差×1.5 时转负,说明边际太薄,实盘风险高。
- 点差对进出场的方向影响也要建模:买入按
close + spread/2入场,卖出按close - spread/2,出场扣 full spread。
6. 样本外验证:机器结论不可信,要人工判读(已在 XAUUSD 上验证)
6.1 本项目结果(walk-forward @0.16)
| 指标 | 训练段(前半 10k) | 测试段(后半 10k) |
|---|---|---|
| 交易次数 | 50 | 52 |
| 胜率 | 34% | 50% |
| 盈亏比 PF | 0.96 | 2.22 |
| 收益 | −1.4% | +42.0% |
| 最大回撤 | 9.7% | 10.0% |
- 脚本自动打印"策略具有一定泛化能力"(因为测试段 PF>1)。
- 但人工判读:训练段 PF<1 说明策略边际本来就很薄,前半段行情不支持这个策略;全部负成本靠后半段的单边趋势行情撑住。这是典型的"行情依赖型"表现,不足以说"稳定盈利策略"。
- 稳定性比率
test_ret / max(train_ret, 0.01)这个指标本身会骗人——训练段收益≈0 时比值直接爆炸成几千,"看起来稳"其实是除零近似。
6.2 验证规范
- Walk-forward 至少前后 50/50 切分;有条件做 k-fold(多窗口)看逐窗口一致性。
- 判读顺序:两段都 PF>1 ± 各自收益 >0 才是基本通过;只看"总收益为正"不通过。
- 50 笔/段的样本量远远不够下结论——回测只是筛选器,最终以 demo 真实成交 2~4 周对照为准(实际滑点、实际点差、实际成交时机)。
- 优化器按
total_ret排序 Top 组合同样是过拟合陷阱;应同时看ret/DD、样本外表现,并对最优参数做邻域扰动测试(参数微调动摇不算真最优)。
7. 实盘脚本工程化要点(本项目 5 处致命 BUG 逐一对应)
以下每一项在 XAUUSD 实盘脚本早期都真实出现过并导致无法正常交易:
| # | 坑 | 正确做法 |
|---|---|---|
| 1 | CLI 参数名写错(--sl/--tp/小写方向)→ 预览直接报错 |
见 §2.1 |
| 2 | 读持仓用 positions 字段 → 永远读到空 |
见 §2.3 |
| 3 | 忘记 --dry-run false → 永远预览,从不下单 |
见 §2.2 |
| 4 | 没有时间止损 → 单子可以无限挂 | 只用回测的 max_bars,实盘转成秒数:MAX_BARS*60,超时 trade_close |
| 5 | 用 len(candles) 判断新K线 → 滚动窗口恒长导致"只用第一根K线"的信号复算/漏算 |
见 §2.4 时间戳去重 |
7.1 状态与自愈(推荐模式)
- 状态落盘 JSON(ticket/方向/入场价/入场ATR/SL/TP/入场时间),重启可恢复。
- 每轮循环对账:本地 ticket 不在
items中时,用--magic认领自己的单子补回 ticket;认领不到才清状态。 - 平仓失败不要清状态,返回 bool,下一轮重试(防"以为平了其实没平")。
7.2 止损口径一致
- 移动止损用入场时的 ATR(不是当前 ATR),
trailing_start/step与回测保持一致,且每根新收盘K线只更新一次。
7.3 主循环骨架
while True:
拉数据 → 时间戳判断新K线
对账持仓 → 认领/清状态
[有仓] 时间止损检查 > 移动止损(新K线一次)
[无仓] 新K线时才评估信号 → 下单(带 power key + magic + dry-run)
打印 + flush + sleep
8. 数据分析环节注意
- 去掉 NaN 行再算指标(
dropna()),否则 EMA/ATR 前几十行全是 NaN,指标算出来是错的。 - 时段过滤用 datetime 的
.hour/.minute,注意时区取 UTC,别混入本地时间。 - 统计 ADX>25(趋势市)占比、RSI 超买超卖占比,先了解市场形态再定策略类型。
- 回测图表(价格+信号+权益曲线+盈亏分布)必出,用眼睛看比看数字更能发现问题。
9. 上线检查清单(每新开发一个策略都过一遍)
- 回测、样本外、优化器、实盘四处的信号逻辑来自同一个函数/常量(时段、指标参数、阈值)
- 函数签名默认参数 == 实际调用参数
- 点差已用该商品实测值(非拍脑袋),并跑过 ×1.5 点差压力测试
--order-type大写、--stop-loss/--take-profit全名、--dry-run false显式、--magic+--idempotency-key带上- 持仓读取用
items,side 已归一化,ticket 字段有兜底查找 - 新K线判断用时间戳,锁仓时间止损已实现
- 状态可落盘/重启恢复/魔术号自愈
- 时钟与 MT5 服务器偏差 <5s(真单前必查)
- 打印内容无 GBK 非法字符,每循环 flush
- walk-forward 两段都 PF≥1 且收益>0(否则人工复核),样本量≥某阈值
- 先在 demo 真实跑 2~4 周,用实际成交记录对照回测预期