从头开始开发智能交易系统(第 24 部分):提供系统健壮性(I)(基础篇)
先给 EA 套上错误捕获的壳
MT5 里跑 EA,最怕的不是逻辑错,而是某次调用直接让整个程序崩在实时行情里。给系统加健壮性,第一步不是优化信号,而是先拦住异常。 MQL5 提供 try-catch 机制,但很多人只在 OnInit 里写,却忘了 OnTick 才是高频出错点。一次无效的 OrderSend 或指标句柄失效,就可能让 EA 静默停摆。 下面这段演示了在 OnTick 里包裹核心逻辑的最小骨架。实际项目里,你应该把交易动作和指标读取都分别包起来,而不是只包最外层。 外汇与贵金属杠杆高,实盘任何未捕获错误都可能瞬间放大亏损,建议先在策略测试器用 2022 年 10 月数据跑一遍异常路径。
class="type">int OnInit() { class=class="str">"cmt">// 初始化指标句柄等 class="kw">return(INIT_SUCCEEDED); } class="type">void OnTick() { try { class=class="str">"cmt">// 核心交易逻辑 } class="kw">catch(Exception &e) { Print("Error: ", e.what()); } }
「为什么图表挂单系统在高波动下会失控」
很多人以为在 MT5 图表上堆挂单、用 EA 管持仓是顺理成章的事,但订单系统远没那么简单。早期我们做过一个由 MetaTrader 5 自身维护对象、而非 EA 维护的幻影系统(见第 23 部分思路),本意是让订单、止损止盈的管理更灵活,不限制图表上的订单数量。 开发到第 23 部分时,幻影系统已能跑起来,但有个现象很反直觉:你肉眼可见和代码里用到的对象数,永远低于 MT5 实际支持的对象数,平时能忍,高波动时就露馅。 市场剧烈波动时,这套系统有两个硬伤。一是挂单发到服务器后,响应耗时偶尔远超平时,界面某一刻显示“有单”、下一刻又“无单”;二是持仓执行完毕的边界判定和订单/仓位差异纠缠,你根本拿不准服务器会不会按指令成交。外汇与贵金属杠杆高,这种状态错判可能直接诱发错误下单。 解法分简单和复杂两类,但底线只有一条:你得信 EA 的队列与状态同步逻辑,否则任何行情下都别把它当执行依赖。
◍ 速度与可靠性的两难权衡
给 MT5 写实时交易系统,最头疼的就是速度和可靠性两头都要抓。实时场景下价格跳动不等人,系统必须立刻对事件做出反应;可一旦为了快而牺牲健壮性,崩一次就可能把血汗钱搭进去。很多架构从根上就难两全,只能在两者之间找平衡点。 提升速度最直接的办法,是在语言层面避免多余的函数调用——能不调就不调,能少调就少调。MQL5 基于 C++,做到这点就能拿到近乎极限的执行效率。真要再榨性能,得下沉到汇编,但实战里基本没必要,C 级优化已经够用。 健壮性靠两招:一是尽量复用经过反复测试的代码模块,二是用 OOP 把类职责隔离。只要每个类不直接碰别的类数据(继承除外),系统就稳得多。代价是执行会慢一点点,但这点损耗相比封装带来的指数级稳健提升,完全可以忽略。外汇和贵金属杠杆高、波动猛,这种稳健不是可选项。 别指望三四篇文章就能交付一个完美系统。代码写出来只是起点,跑起来才知道瓶颈在哪,然后持续改、持续发更新。开放系统里把演进过程摊开讲,比直接给成品更有价值——看懂东西怎么随时间长出来,比套用黑盒方案重要得多。
仓位对象命名的坑与宏替换改造
新代码里第一处要留意的是函数改成了宏替换。即便用了 inline 保留字让编译器在每处引用点展开代码,也不能想当然认为它跑得够快——这个函数在 EA 里被多次调用,必须尽量压低单次开销。下面的 MountName 函数和新宏就是为此重写的,旧版宏的数据结构与此不同,改动理由后文会拆,但它直接牵连了另一个函数的索引写法。 索引改动只解决一件事:分清「单号」和「指标编号」,否则调用时数据会串。有人会问「原来不是能跑吗?」确实能跑,但平台方不时会优化 MQL5 里你没自己造的函数。规则是别重新造轮子,直接吃语言现成的函数红利——哪天 MetaTrader 5 改了底层实现,你用到同名函数就白捡性能和安全性。 不过刚才那点宏改动本身并不让 EA 自动蹭到库优化,它只是把对象名称建模矫正,为将来接住平台改进铺路。典型例子是 ObjectsDeleteAll:只要建模正确,传前缀就能让平台替你清对象,不用写一堆细节。新代码高亮处就是这么调的。 真正的暗坑在单号从 1 开始。假设挂单输入值设为 1(指标 0 实际对应值 1,0 留给 EA 内部测试),系统开了真单号 1221766803,对象名前缀是 SMD_OT#1221766803。若用 ObjectsDeleteAll 删指标 0,传 SMD_OT#1 会把所有以此开头对象全删掉,连新单一起误杀。解法是末尾加字符:删指标 0 传 SMD_OT#1#,真单传 SMD_OT#1221766803*,平台就能区分。 函数末尾的 ChartRedraw 也不是多余。MetaTrader 5 自己会刷新,但时机不可控,且图表对象的放置/删除调用是同步的、不一定落在你预期的时间点。订单系统靠对象显示和管理挂单,必须确认对象已上屏,所以强制刷一次,确保存在性判断靠谱。外汇与贵金属杠杆高,这类对象误删可能导致盯盘图形丢失、信号错位,实盘前务必在策略测试器里复现一遍。
class="kw">inline class="type">class="kw">string MountName(class="type">ulong ticket, eIndicatorTrade it, eEventType ev, class="type">bool isGhost = false) { class="kw">return StringFormat("%s%c%c%c%llu%c%c%c%s", def_NameObjectsTrade, def_SeparatorInfo, (class="type">char)it, def_SeparatorInfo, ticket, def_SeparatorInfo, (class="type">char)(isGhost ? ev + class="num">32 : ev), def_SeparatorInfo, (isGhost ? def_IndicatorGhost : def_IndicatorReal)); } class="macro">#define macroMountName(ticket, it, ev, Ghost) \ StringFormat("%s%c%llu%c%c%c%c%c%c%c", def_NameObjectsTrade, def_SeparatorInfo, ticket, def_SeparatorInfo, (class="type">char)it, def_SeparatorInfo, (class="type">char)(Ghost ? ev + class="num">32 : ev), def_SeparatorInfo, (Ghost ? def_IndicatorGhost : def_IndicatorReal))
「从字符串反解指标事件标识」
在 MT5 的图表对象通讯里,常把交易指标的票据号、类型、事件压成一个带分隔符的字符串。下面这段内联函数负责把这个字符串拆回结构化字段,供后续逻辑判断。 函数先按 def_SeparatorInfo 切分参数,若得到的段数小于 2 就直接返回 false,说明不是合法包。接着比对第 0 段是否等于 def_NameObjectsTrade,不等也拒收,这一步过滤掉非本系统的对象名。 ticket 用 StringToInteger 把第 1 段转成 ulong;第 2 段通过 StringToCharArray 写进 szInfo 字节数组,再取 szInfo[0] 强转为 eIndicatorTrade 枚举。这样一次调用就能拿到票据与指标类型,省去多处解析。 实际排错时,若发现 ticket 恒为 0,优先查 def_SeparatorInfo 是否和写入端一致——两端分隔符不同,StringSplit 返回的段数就会小于 2,函数静默失败。外汇与贵金属品种波动大,这类解析错误可能让你漏掉关键信号,上线前务必用样例字符串在 MT5 脚本里跑一遍。
class="kw">inline class="type">bool GetIndicatorInfos(const class="type">class="kw">string sparam, class="type">ulong &ticket, eIndicatorTrade &it, eEventType &ev) { class="type">class="kw">string szRet[]; class="type">char szInfo[]; if (StringSplit(sparam, def_SeparatorInfo, szRet) < class="num">2) class="kw">return false; if (szRet[class="num">0] != def_NameObjectsTrade) class="kw">return false; ticket = (class="type">ulong) StringToInteger(szRet[class="num">1]); StringToCharArray(szRet[class="num">2], szInfo); it = (eIndicatorTrade)szInfo[class="num">0];
◍ 对象清理的两条删除路径
图形对象生命周期管理里,删除动作分两种写法:批量清和定点拆。前者用 ObjectsDeleteAll 按命名前缀一口气抹掉挂单、结果或普通标注;后者用宏逐类删,把地面线、触发线、平仓点、编辑框甚至移动/利润标签挨个 ObjectDelete 掉。 RemoveIndicator 的第一版重载先关掉图表对象删除事件广播(CHART_EVENT_OBJECT_DELETE=false),避免删对象时递归触发自身逻辑;删除完再打开并 ChartRedraw 强制重绘,m_InfoSelection.bIsMovingSelect 置 false 表示当前没在拖拽选中态。 宏 macroDestroy(A,B) 里有个细节:当 A 不是 IT_RESULT 时删 EV_MOVE 对象,否则删 EV_PROFIT 对象——结果类图形用利润标签替代移动手柄,这是面板和结果块在交互上的分叉点。外汇与贵金属图表对象密集时,这种定点清理能明显降低 MT5 图表卡顿概率。 开 MT5 把这段贴进 EA 的 indicator 管理类,改 def_NameObjectsTrade 前缀跑一单模拟挂单,能在对象列表里直接看到删除粒度差异。
StringToCharArray(szRet[class="num">3], szInfo); ev = (eEventType)szInfo[class="num">0]; class="kw">return true; } class="kw">inline class="type">void RemoveIndicator(class="type">ulong ticket, eIndicatorTrade it = IT_NULL) { ChartSetInteger(Terminal.Get_ID(), CHART_EVENT_OBJECT_DELETE, false); if ((it == IT_NULL) || (it == IT_PENDING) || (it == IT_RESULT)) ObjectsDeleteAll(Terminal.Get_ID(), StringFormat("%s%c%llu%c", def_NameObjectsTrade, def_SeparatorInfo, ticket, (ticket > class="num">1 ? &class="macro">#x27;*&class="macro">#x27; : def_SeparatorInfo))); else ObjectsDeleteAll(Terminal.Get_ID(), StringFormat("%s%c%llu%c%c", def_NameObjectsTrade, def_SeparatorInfo, ticket, def_SeparatorInfo, (class="type">char)it)); ChartSetInteger(Terminal.Get_ID(), CHART_EVENT_OBJECT_DELETE, true); m_InfoSelection.bIsMovingSelect = false; ChartRedraw(); } class="kw">inline class="type">void RemoveIndicator(class="type">ulong ticket, eIndicatorTrade it = IT_NULL) { class="macro">#define macroDestroy(A, B) { \ ObjectDelete(Terminal.Get_ID(), MountName(ticket, A, EV_GROUND, B)); \ ObjectDelete(Terminal.Get_ID(), MountName(ticket, A, EV_LINE, B)); \ ObjectDelete(Terminal.Get_ID(), MountName(ticket, A, EV_CLOSE, B)); \ ObjectDelete(Terminal.Get_ID(), MountName(ticket, A, EV_EDIT, B)); \ if (A != IT_RESULT) ObjectDelete(Terminal.Get_ID(), MountName(ticket, A, EV_MOVE, B)); \ else ObjectDelete(Terminal.Get_ID(), MountName(ticket, A, EV_PROFIT, B)); \ } ChartSetInteger(Terminal.Get_ID(), CHART_EVENT_OBJECT_DELETE, false);