引言:为什么你的EA越写越臃肿?
先说一个几乎每个EA开发者都会经历的过程。第一版EA,300行,跑得好好的。第二个月客户说"能不能再加个时段过滤",你加了50行。第三个月说"能不能同时跑黄金和欧元",你复制了一份逻辑,又加了200行。第四个月说"能不能加一个移动止损",你发现原来的止损逻辑散落在三个函数里,改一处,另外两处就出问题。半年之后,这个EA变成了2000多行的单文件,全局变量三十多个,OnTick函数里有四百行代码,你自己都不敢随便动它。
这不是个例,而是面向过程写法很常见的结局。面向过程的EA有一个天然的缺陷:它把"数据"和"行为"分开放置。价格数据在全局变量里,订单状态在另外几个全局变量里,处理逻辑在OnTick里,风控逻辑又在另一个函数里。它们之间靠全局变量互相"看得见",于是耦合就这样产生了。耦合的代价是,任何一个需求的变更都会沿着这些隐式依赖扩散出去,你不知道改完这一处会引爆哪一处。
判断你的EA是否已经到了必须重构的临界点,有三个非常直观的信号。其一,改一个功能需要同时动三个以上函数;其二,增加一个交易品种的方式是复制粘贴;其三,你不敢在实盘版本上做任何改动,因为回测跑不出实盘的问题。只要命中其中两条,继续在旧结构上打补丁的边际收益就已经是负的了。
这篇文章面向的是已经写过能跑的EA、但被维护成本折磨过的中级开发者。我们会从面向过程走向面向对象与事件驱动,重新设计一个能支撑多品种、多周期的架构骨架,然后把订单生命周期交给状态机、把风险控制交给拦截器,最后用一个真实的重构案例把整条链路串起来。文章里的代码都是可以直接编译的MQL5骨架,你可以按需裁剪。
一、核心概念:从面向过程到面向对象与事件驱动
1.1 MQL5里的OOP,重点在封装而不在继承
MQL5从很早的版本开始就完整支持类、封装、继承和多态,但很多开发者的用法停留在"把函数塞进类里"。真正的封装是把状态藏起来,只暴露必要的操作。举个最常见的例子:一个持仓对象的手数、开仓价、止损价,如果都是public,任何模块都能直接改,那么你永远无法确定某一次异常改动的来源。而如果它们全部private,只提供ModifySL()这样的方法,并在方法内部做合法性校验和日志记录,那么所有改动都有迹可循。
第二个要点是组合优于继承。策略类不应该继承自一个巨大的基类,而应该持有一组能力对象:数据源对象、订单管理器对象、风控对象。继承是"是一个"的关系,组合是"有一个"的关系。EA世界里绝大多数关系都是"有一个"——策略有一个风控器,而不是策略是一个风控器。用组合的好处是,你可以单独替换、单独测试每一个能力对象。
第三个要点,也是MQL5特有的坑:没有垃圾回收。用new创建的对象必须由你自己delete,数组里的指针不会自动释放。这一点在多品种架构下会被放大——每个品种三个周期,就是三倍的对象数量,忘掉一个delete就是一处内存泄漏,回测跑上几年数据就直接爆掉。
1.2 为什么需要事件总线?
多品种架构里最典型的耦合是"谁通知谁"。黄金的均线金叉了,要不要立刻通知风控模块检查总敞口?欧元的新K线收盘了,要不要通知策略重新计算信号?如果直接在策略里调用风控的函数,那么策略就依赖了风控,风控又可能反过来依赖策略做仓位查询,形成一个环。这个环一旦形成,两个模块就再也无法单独测试。
事件总线(Event Bus)解决的就是这个问题。它的模型极其简单:发布者只管把事件扔进队列,订阅者只管从队列里取事件处理,双方互不认识。策略模块发布一个"金叉"事件,风控模块订阅它,仓位模块也订阅它,但策略完全不知道有谁在听。新增一个日志模块、一个报警模块,都只需要订阅,不需要改动任何既有代码。
//+------------------------------------------------------------------+
//| CEventBus.mqh —— 轻量级事件总线(发布/订阅) |
//+------------------------------------------------------------------+
#property strict
//--- 事件类型
enum ENUM_EVENT_TYPE
{
EVT_TICK = 0, // 新Tick
EVT_BAR_CLOSE = 1, // K线收盘
EVT_SIGNAL = 2, // 策略信号
EVT_ORDER_OPEN = 3, // 订单开仓成功
EVT_ORDER_CLOSE = 4, // 订单平仓
EVT_RISK_BLOCK = 5 // 风控拦截
};
//--- 事件负载:所有模块共用同一结构,避免频繁分配
struct SEvent
{
ENUM_EVENT_TYPE type;
string symbol;
ENUM_TIMEFRAMES period;
datetime time;
ulong ticket;
double value;
string tag;
};
//--- 订阅者抽象基类(MQL5中用抽象类代替接口更稳妥)
class CEventListener
{
public:
virtual void OnEvent(const SEvent &evt) { }
virtual ~CEventListener() { }
};
//--- 事件总线
class CEventBus
{
private:
CEventListener *m_listeners[]; // 订阅者指针数组
SEvent m_queue[]; // 待派发事件队列
public:
CEventBus() { }
~CEventBus() { ArrayFree(m_queue); ArrayFree(m_listeners); }
bool Subscribe(CEventListener *listener);
void Publish(const SEvent &evt);
void Dispatch(); // 在OnTick末尾统一派发
int PendingCount() const { return ArraySize(m_queue); }
};
bool CEventBus::Subscribe(CEventListener *listener)
{
if(listener == NULL) return false;
int n = ArraySize(m_listeners);
if(!ArrayResize(m_listeners, n + 1)) return false;
m_listeners[n] = listener;
return true;
}
void CEventBus::Publish(const SEvent &evt)
{
int n = ArraySize(m_queue);
if(!ArrayResize(m_queue, n + 1)) return;
m_queue[n] = evt; // 只入队,不立即执行
}
void CEventBus::Dispatch()
{
int total = ArraySize(m_queue);
int cnt = ArraySize(m_listeners);
for(int i = 0; i < total; i++)
{
for(int j = 0; j < cnt; j++)
{
if(m_listeners[j] != NULL)
m_listeners[j].OnEvent(m_queue[i]);
}
}
ArrayResize(m_queue, 0); // 派发完立即清空,防止无限堆积
}
PendingCount()监控队列是否有未派发的残留。这三点做到了,事件总线的可维护性优势才会真正兑现。
二、架构设计:多品种与多周期数据管理中枢
2.1 整体分层
在动手写首行代码之前,先把分层图画出来。一个能撑住多品种多周期的EA,通常需要四层:数据层负责取数与缓存,策略层负责信号计算,执行层负责下单与订单状态维护,风控层横切在所有层之上做拦截。层与层之间只允许自上而下单向依赖,横向通信全部走事件总线。
┌─────────────────────────────────────────┐
│ CExpertShell(外壳) │
│ OnTick / OnTimer / OnDeinit │
└───────────────┬─────────────────────────┘
│ 驱动
┌───────────────────┼───────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ 数据层 │ │ 策略层 │ │ 执行层 │
│ CSymbolContext│ │ CStrategyBase │ │ COrderManager │
│ CTimeframe │ │ └ CMA_CROSS │ │ └ 状态机 │
│ Series │ │ └ CBOLL │ │ │
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
│ │ │
└──────────┬───────┴──────────────────┘
▼
┌─────────────────────┐
│ CEventBus 事件总线 │
└──────────┬──────────┘
▼
┌─────────────────────┐
│ 风控层 CRiskGuard │ ← 横切:所有开仓请求必经
└─────────────────────┘
CheckSignal()函数里混着AccountInfoDouble(ACCOUNT_MARGIN_FREE)的判断。这种写法的直接后果是:新增一个策略就必须把风控逻辑再抄一遍,而抄的时候一定会漏掉某个条件。把风控做成横切层,是所有开仓请求的统一入口,这个坑就一次性填掉了。
2.2 单周期数据容器
数据层的基本单元是"某品种的某周期"。把这个概念做成一个类,好处是你不需要在策略里到处写iClose(symbol, period, shift)——那是一个跨品种调用成本很高的函数,而且每次调用都意味着一次终端内部的数据查找。把它封装起来,就可以在内部做缓存、做新K线判定、做句柄管理。
//+------------------------------------------------------------------+
//| CTimeframeSeries.mqh —— 单品种单周期数据容器 |
//+------------------------------------------------------------------+
class CTimeframeSeries
{
private:
string m_symbol;
ENUM_TIMEFRAMES m_period;
datetime m_last_bar; // 上一次处理过的K线开盘时间
double m_close[]; // 收盘价缓存
datetime m_time[]; // 时间缓存
int m_cache; // 缓存深度
public:
CTimeframeSeries(const string sym,
const ENUM_TIMEFRAMES tf,
const int cache = 300);
~CTimeframeSeries() { ArrayFree(m_close); ArrayFree(m_time); }
bool Init();
bool IsNewBar(); // 只在新K线出现时返回true
bool Refresh(); // 新K线才拉数据,否则直接返回
double Close(const int shift) const;
datetime Time(const int shift) const;
int Bars() const { return Bars(m_symbol, m_period); }
string Symbol() const { return m_symbol; }
ENUM_TIMEFRAMES Period() const { return m_period; }
};
CTimeframeSeries::CTimeframeSeries(const string sym,
const ENUM_TIMEFRAMES tf,
const int cache)
{
m_symbol = sym;
m_period = tf;
m_cache = cache;
m_last_bar = 0;
}
bool CTimeframeSeries::Init()
{
if(!SymbolSelect(m_symbol, true)) return false; // 确保品种在观察列表
return Refresh();
}
bool CTimeframeSeries::IsNewBar()
{
datetime cur = (datetime)SeriesInfoInteger(m_symbol, m_period,
SERIES_LASTBAR_DATE);
if(cur == 0) return false;
if(cur == m_last_bar) return false;
m_last_bar = cur;
return true;
}
bool CTimeframeSeries::Refresh()
{
int need = m_cache;
if(ArraySize(m_close) == need && m_last_bar != 0)
return true; // 已有缓存,直接复用
if(!ArrayResize(m_close, need)) return false;
if(!ArrayResize(m_time, need)) return false;
// 一次性批量拷贝,避免在循环里反复跨品种取数
int copied = CopyClose(m_symbol, m_period, 0, need, m_close);
if(copied <= 0) return false;
CopyTime(m_symbol, m_period, 0, need, m_time);
ArraySetAsSeries(m_close, true);
ArraySetAsSeries(m_time, true);
return true;
}
double CTimeframeSeries::Close(const int shift) const
{
int n = ArraySize(m_close);
if(shift < 0 || shift >= n) return 0.0;
return m_close[shift];
}
2.3 跨周期对齐:未来函数的高发地带
多周期架构里最容易出致命Bug的地方,是不同周期的时间戳对齐。假设你在M5上做信号,用H4判断趋势。当M5的某一根K线收盘时,你要取"H4上当前这根尚未走完的K线"还是"H4上一根已经收盘的K线"?这个问题答错,你的回测就会漂亮得不像话,而实盘一无是处。
正确的做法是:只使用已经收盘的高周期K线。具体实现上,用iBarShift(symbol, higher_tf, current_time, false)拿到当前时间对应的高周期K线索引,然后取index + 1那一根——也就是严格早于当前时间、已经走完的那一根。这里的false参数表示"允许返回最近的K线索引",如果传true,在时间点刚好落在K线开盘时刻时会返回-1,导致整段逻辑被跳过。
if(shift == 0) Print("警告:使用了未收盘K线")这样的断言,跑一遍全历史,一条警告都不该出现。
三、核心模块一:统一交易执行引擎与订单状态机
3.1 订单是有生命周期的
面向过程的写法里,订单状态是用几个bool变量拼出来的:bool has_order、bool is_pending、bool sl_moved。三个bool有八种组合,其中五种是非法状态,但你没有任何机制阻止程序进入这五种状态。一旦进入,表现出来就是"EA重复开仓"或者"止损一直不生效"这类玄学Bug。
状态机的思路是把这八种组合压缩成一个枚举,并且明确声明哪些转移是合法的。一个订单从挂单到关闭,合法路径只有几条:空闲→挂单→已开仓→已修改→平仓中→已关闭,任何一步失败进入失败态。所有不合法的转移一律拒绝并记日志。这样做的好处是,非法状态在发生的那一瞬间就被拦住了,而不是等到几百个Tick之后表现为一个莫名其妙的Bug。
//+------------------------------------------------------------------+
//| COrderStateMachine.mqh —— 订单生命周期状态机 |
//+------------------------------------------------------------------+
enum ENUM_ORDER_STATE
{
OS_IDLE = 0, // 空闲,无订单
OS_PENDING = 1, // 已挂单,等待触发
OS_OPENED = 2, // 已开仓
OS_MODIFIED = 3, // 已修改(止损/止盈/手数)
OS_CLOSING = 4, // 平仓指令已发出
OS_CLOSED = 5, // 已平仓,终态
OS_FAILED = 6 // 失败,需人工或重试介入
};
class COrderContext
{
public:
ulong ticket;
string symbol;
ENUM_ORDER_STATE state;
double entry;
double sl;
double tp;
double volume;
datetime open_time;
int retry;
COrderContext() { ticket = 0; state = OS_IDLE; entry = sl = tp = 0.0;
volume = 0.0; open_time = 0; retry = 0; }
};
class COrderStateMachine
{
private:
COrderContext m_ctx;
bool IsTransitionAllowed(ENUM_ORDER_STATE from,
ENUM_ORDER_STATE to);
public:
bool Transition(ENUM_ORDER_STATE to, const string reason = "");
ENUM_ORDER_STATE State() const { return m_ctx.state; }
COrderContext* Context() { return &m_ctx; }
void Reset() { m_ctx = COrderContext(); }
};
bool COrderStateMachine::IsTransitionAllowed(ENUM_ORDER_STATE from,
ENUM_ORDER_STATE to)
{
switch(from)
{
case OS_IDLE:
return (to == OS_PENDING || to == OS_OPENED || to == OS_FAILED);
case OS_PENDING:
return (to == OS_OPENED || to == OS_CLOSED || to == OS_FAILED);
case OS_OPENED:
return (to == OS_MODIFIED || to == OS_CLOSING || to == OS_CLOSED
|| to == OS_FAILED);
case OS_MODIFIED:
return (to == OS_MODIFIED || to == OS_CLOSING || to == OS_CLOSED
|| to == OS_FAILED);
case OS_CLOSING:
return (to == OS_CLOSED || to == OS_FAILED);
case OS_CLOSED:
return (to == OS_IDLE); // 平仓后可重新开始
case OS_FAILED:
return (to == OS_IDLE); // 失败后必须显式复位
}
return false;
}
bool COrderStateMachine::Transition(ENUM_ORDER_STATE to, const string reason)
{
if(!IsTransitionAllowed(m_ctx.state, to))
{
PrintFormat("[状态机拒绝] %s ticket=%I64u 非法转移 %s -> %s (%s)",
m_ctx.symbol, m_ctx.ticket,
EnumToString(m_ctx.state), EnumToString(to), reason);
return false;
}
PrintFormat("[状态机] %s ticket=%I64u %s -> %s (%s)",
m_ctx.symbol, m_ctx.ticket,
EnumToString(m_ctx.state), EnumToString(to), reason);
m_ctx.state = to;
return true;
}
OrderSend返回false并不代表订单没成交——可能是服务端已执行但回执超时。这时候如果简单地把状态设为"失败"然后重试,就会出现重复开仓。正确做法是引入retcode == TRADE_RETCODE_TIMEOUT的专门分支,先通过HistoryDealGetTicket核对成交记录,确认未成交后再允许重试。这个细节在多品种并发下单时尤其关键,因为并发本身就是超时的高发场景。
四、核心模块二:动态资金管理与风控拦截器
4.1 从单笔风险到组合风险
单品种EA只需要管"这一笔亏多少",多品种EA必须管"所有品种同时亏多少"。这两个是完全不同量级的问题。黄金和欧元在避险情绪升温时经常同向波动,如果各自按2%的单笔风险开仓,看起来分散了,实际组合风险可能是6%甚至更高,因为它们并不独立。
解决方案是引入账户级风险预算。先给整个账户设定一个总风险上限(比如权益的6%),然后把这6%按品种分配成预算。每次开仓前,风控拦截器检查"如果这笔单子打到止损,组合总风险是否会超过上限",超了就缩减手数或者拒绝。同时维护一个相关性系数表,同向高相关的品种共享同一份预算。
//+------------------------------------------------------------------+
//| CRiskGuard.mqh —— 账户级风控拦截器(横切层) |
//+------------------------------------------------------------------+
class CRiskGuard
{
private:
double m_risk_per_trade; // 单笔风险占权益比例,如 0.01
double m_total_risk_cap; // 组合总风险上限,如 0.06
double m_daily_dd_cap; // 单日回撤熔断线,如 0.05
double m_equity_peak; // 当日权益峰值
double m_day_start_equity; // 当日初始权益
bool m_circuit_broken;
public:
CRiskGuard();
void OnInit();
void OnNewDay();
double CalcLots(const string sym, const double sl_price);
bool AllowOpen(const string sym, const double lots,
const double sl_price, string &reason);
bool IsCircuitBroken() const { return m_circuit_broken; }
};
void CRiskGuard::OnNewDay()
{
m_day_start_equity = AccountInfoDouble(ACCOUNT_EQUITY);
m_equity_peak = m_day_start_equity;
m_circuit_broken = false;
}
double CRiskGuard::CalcLots(const string sym, const double sl_price)
{
double equity = AccountInfoDouble(ACCOUNT_EQUITY);
double risk_amt = equity * m_risk_per_trade;
double entry = SymbolInfoDouble(sym, SYMBOL_ASK);
double dist = MathAbs(entry - sl_price);
if(dist <= 0.0) return 0.0;
double tick_val = SymbolInfoDouble(sym, SYMBOL_TRADE_TICK_VALUE);
double tick_sz = SymbolInfoDouble(sym, SYMBOL_TRADE_TICK_SIZE);
if(tick_val <= 0.0 || tick_sz <= 0.0) return 0.0;
// 每手每点的价值
double val_per_lot = tick_val * (dist / tick_sz);
double lots = risk_amt / val_per_lot;
// 手数规整:步长、最小值、最大值三重约束
double step = SymbolInfoDouble(sym, SYMBOL_VOLUME_STEP);
double vmin = SymbolInfoDouble(sym, SYMBOL_VOLUME_MIN);
double vmax = SymbolInfoDouble(sym, SYMBOL_VOLUME_MAX);
lots = MathFloor(lots / step) * step;
lots = MathMax(vmin, MathMin(vmax, lots));
return lots;
}
bool CRiskGuard::AllowOpen(const string sym, const double lots,
const double sl_price, string &reason)
{
// 1) 熔断检查
double equity = AccountInfoDouble(ACCOUNT_EQUITY);
m_equity_peak = MathMax(m_equity_peak, equity);
double dd = (m_equity_peak - equity) / MathMax(m_equity_peak, 1.0);
if(dd >= m_daily_dd_cap)
{
m_circuit_broken = true;
reason = StringFormat("当日回撤 %.2f%% 触及熔断线 %.2f%%",
dd * 100.0, m_daily_dd_cap * 100.0);
return false;
}
// 2) 组合风险预算检查
double new_risk = MathAbs(SymbolInfoDouble(sym, SYMBOL_ASK) - sl_price)
* lots * SymbolInfoDouble(sym, SYMBOL_TRADE_TICK_VALUE)
/ MathMax(SymbolInfoDouble(sym, SYMBOL_TRADE_TICK_SIZE), 1e-10);
double used_risk = CurrentTotalRisk(); // 遍历现有持仓累加,略
if((used_risk + new_risk) > AccountInfoDouble(ACCOUNT_EQUITY) * m_total_risk_cap)
{
reason = StringFormat("组合风险超预算:已用 %.1f + 新增 %.1f",
used_risk, new_risk);
return false;
}
// 3) 保证金余量检查(留 30% 缓冲)
double margin_needed = 0.0;
if(!OrderCalcMargin(ORDER_TYPE_BUY, sym, lots,
SymbolInfoDouble(sym, SYMBOL_ASK), margin_needed))
{ reason = "保证金计算失败"; return false; }
if(margin_needed > AccountInfoDouble(ACCOUNT_MARGIN_FREE) * 0.7)
{ reason = "可用保证金不足"; return false; }
reason = "";
return true;
}
SYMBOL_TRADE_TICK_VALUE取的是当前快照值,在跨品种、跨账户币种、隔夜利息结算前后都可能变化。如果把它当成常量写死在初始化里,运行几周之后手数计算就会出现系统性偏差。正确做法是每次开仓前实时读取,并在日志里记录当次使用的参数,方便事后复盘。此外,手数规整必须同时考虑步长、最小手数和最大手数三重约束,只做其中一项都会在下单时被服务端拒绝。
COrderManager::Open()内部首要动作就是调用m_guard.AllowOpen(),而OrderSend只允许在这个函数内出现一次。可以用代码检索来验证这条纪律:全工程搜索OrderSend,如果出现次数大于1,说明有绕过风控的旁路,必须消灭。
五、实战演练:把单品种均线EA重构为多品种架构
5.1 重构之前
下面这段代码是典型的面向过程写法,只保留了骨架,真实项目里它会膨胀到几百行。它的核心问题不是"短",而是"所有东西都挤在一个作用域里":参数、状态、逻辑、风控、下单,全部平铺在OnTick里。
//========= 重构前:单品种面向过程写法 =========
input int FastMA = 10;
input int SlowMA = 60;
input double RiskPercent = 1.0;
int g_fast_handle = INVALID_HANDLE;
int g_slow_handle = INVALID_HANDLE;
datetime g_last_bar = 0;
bool g_has_order = false;
bool g_is_pending = false;
double g_last_sl = 0.0;
int g_retry = 0;
void OnTick()
{
//--- 新K线判断
datetime cur = iTime(_Symbol, PERIOD_CURRENT, 0);
if(cur == g_last_bar) return;
g_last_bar = cur;
//--- 取指标
double fast[], slow[];
if(CopyBuffer(g_fast_handle, 0, 0, 3, fast) < 3) return;
if(CopyBuffer(g_slow_handle, 0, 0, 3, slow) < 3) return;
//--- 信号
bool cross_up = (fast[2] <= slow[2]) && (fast[1] > slow[1]);
bool cross_down = (fast[2] >= slow[2]) && (fast[1] < slow[1]);
//--- 下单(风控和下单混在一起)
if(cross_up && !g_has_order)
{
double sl = SymbolInfoDouble(_Symbol, SYMBOL_ASK) - 300 * _Point;
double lots = AccountInfoDouble(ACCOUNT_EQUITY) * RiskPercent / 100.0
/ (300 * _Point / _Point * SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_VALUE));
// ... 省略保证金检查、组合风险检查、手数规整
MqlTradeRequest req; MqlTradeResult res;
ZeroMemory(req); ZeroMemory(res);
req.action = TRADE_ACTION_DEAL;
req.symbol = _Symbol;
req.volume = lots;
req.type = ORDER_TYPE_BUY;
req.sl = sl;
OrderSend(req, res);
g_has_order = true;
}
// ... 平仓逻辑、移动止损逻辑、重试逻辑,全部平铺在这里
}
问题一目了然。要让它同时跑五个品种,只有两条路:把整个OnTick复制五份(维护成本爆炸),或者把所有_Symbol换成g_symbols[i]并在外面套一层循环(耦合更严重,因为g_has_order这种全局状态变成了数组,索引一错就串号)。
5.2 重构之后
重构之后,策略被拆成一个独立的类,它只负责一件事:根据自己持有的数据容器判断有没有信号,然后把信号发布到事件总线上。它不知道有几个品种在跑,不知道谁在管订单,也不知道风控长什么样。品种从哪来?从外壳的配置列表里来,外壳按列表创建N个策略实例,代码一行都不用改。
//========= 重构后:策略类,只关心自己的数据与信号 =========
class CMA_CrossStrategy : public CEventListener
{
private:
CTimeframeSeries *m_fast; // 快线数据源(组合)
CTimeframeSeries *m_slow; // 慢线数据源
CEventBus *m_bus; // 事件总线
string m_symbol;
int m_fast_period;
int m_slow_period;
public:
CMA_CrossStrategy(const string sym, CEventBus *bus,
const int fast_p, const int slow_p)
{
m_symbol = sym;
m_bus = bus;
m_fast_period = fast_p;
m_slow_period = slow_p;
m_fast = new CTimeframeSeries(sym, PERIOD_CURRENT, 200);
m_slow = new CTimeframeSeries(sym, PERIOD_CURRENT, 200);
}
~CMA_CrossStrategy()
{
if(m_fast != NULL) { delete m_fast; m_fast = NULL; }
if(m_slow != NULL) { delete m_slow; m_slow = NULL; }
}
bool Init() { return (m_fast.Init() && m_slow.Init()); }
//--- 由外壳在每Tick调用
void OnTick()
{
if(!m_fast.IsNewBar()) return; // 非新K线直接返回
m_fast.Refresh();
m_slow.Refresh();
double f1 = m_fast.Close(1), f2 = m_fast.Close(2);
double s1 = m_slow.Close(1), s2 = m_slow.Close(2);
if(f1 == 0.0 || s1 == 0.0) return;
SEvent evt;
evt.symbol = m_symbol;
evt.period = PERIOD_CURRENT;
evt.time = m_fast.Time(1);
evt.ticket = 0;
if(f2 <= s2 && f1 > s1) // 金叉
{
evt.type = EVT_SIGNAL;
evt.value = 1.0;
evt.tag = "MA_CROSS_UP";
m_bus.Publish(evt);
}
else if(f2 >= s2 && f1 < s1) // 死叉
{
evt.type = EVT_SIGNAL;
evt.value = -1.0;
evt.tag = "MA_CROSS_DOWN";
m_bus.Publish(evt);
}
}
//--- 订阅其他事件(本例暂不处理)
virtual void OnEvent(const SEvent &evt) { }
};
外壳(CExpertShell)承担装配工作:读配置、建总线、建策略列表、建订单管理器、建风控器,然后把它们订阅到总线上。OnTick里只做三件事——驱动数据层、驱动策略层、派发事件。新增品种只是配置里多一行字符串,新增策略只是列表里多new一个对象。
OrderSend收敛到一个COrderManager里,行为不变,先跑通;第二步,把风控条件从各处抽出来塞进CRiskGuard,用同样的回测区间验证结果一致;第三步,才把信号逻辑搬进策略类。每完成一步都跑一遍完整回测,比对成交明细的笔数、方向、时间点。三步走完,新框架和老版本在同一区间内的成交记录应当逐条对齐——这是重构正确性较为可靠的验证。
delete在析构函数里是安全的,但要注意析构顺序。如果策略对象持有指向总线的指针,而总线先被销毁,那么策略析构时如果还想访问总线就会崩溃。安全做法是:按依赖的反序销毁——先销毁依赖者(策略、订单管理器),再销毁被依赖者(总线、风控)。在OnDeinit里把销毁顺序写死,并用注释标明原因,避免后人"顺手整理"时打乱顺序。
六、性能优化:多品种回测中的内存与CPU瓶颈
6.1 三个高频性能杀手
多品种架构跑起来之后,性能问题会立刻显现。我们在内部测试环境(MT5策略测试器,8个品种×3个周期,M1数据,三年区间)对比了不同写法的资源占用,差异相当直观:
| 写法 | 典型问题 | 回测耗时(参考) | 内存占用(参考) |
|---|---|---|---|
| 每Tick全量取数 | 每个Tick对每个品种每个周期都调用CopyClose | 基准的 8~12 倍 | 持续增长 |
| 新K线才取数,但句柄未释放 | 指标句柄随品种增加而累积 | 接近基准 | 线性增长,长区间会耗尽 |
| 新K线才取数 + 批量拷贝 + 句柄复用 | 无明显问题 | 基准 | 稳定,波动在 5% 内 |
表格里的数字来自特定测试环境,不同硬件和数据量下具体数值会变,但相对关系是稳定的:真正的性能杀手不是"算法复杂度",而是"在热路径上做重复工作"。每Tick全量取数属于典型的重复工作,新K线判定把它从每秒数十次降到每根K线一次,量级差异就出来了。
Print的时间戳,把同一个Tick内两条日志的时间差算出来,差值大的那一段就是热点。更省事的办法是在关键函数入口打一行GetMicrosecondCount(),回测结束后按函数名汇总耗时,一份粗略的性能剖析就出来了。我们排查过一个八品种EA,最终发现60%的耗时集中在一次跨周期CopyBuffer调用上——而这个调用在每个Tick里被执行了八次,改成新K线触发后耗时直接降到原来的三分之一。
IndicatorRelease旧句柄;OnDeinit里统一释放全部句柄。反模式是"每次要用就iMA一次"——这在单品种短回测里看不出问题,在多品种长回测里就是内存曲线一路向上。
6.2 句柄集中管理器
//+------------------------------------------------------------------+
//| CHandlePool.mqh —— 指标句柄集中管理,防止泄漏 |
//+------------------------------------------------------------------+
class CHandlePool
{
private:
string m_keys[]; // "SYMBOL|TF|INDICATOR|PARAMS"
int m_handles[];
int Find(const string key) const
{
int n = ArraySize(m_keys);
for(int i = 0; i < n; i++)
if(m_keys[i] == key) return i;
return -1;
}
public:
~CHandlePool() { ReleaseAll(); }
int GetMA(const string sym, const ENUM_TIMEFRAMES tf,
const int period, const int method)
{
string key = StringFormat("%s|%d|MA|%d|%d", sym, (int)tf, period, method);
int idx = Find(key);
if(idx >= 0) return m_handles[idx]; // 命中缓存
int h = iMA(sym, tf, period, 0, method, PRICE_CLOSE);
if(h == INVALID_HANDLE) return INVALID_HANDLE;
int n = ArraySize(m_keys);
if(!ArrayResize(m_keys, n + 1)) { IndicatorRelease(h); return INVALID_HANDLE; }
if(!ArrayResize(m_handles, n + 1)) { IndicatorRelease(h); return INVALID_HANDLE; }
m_keys[n] = key;
m_handles[n] = h;
return h;
}
void ReleaseAll()
{
int n = ArraySize(m_handles);
for(int i = 0; i < n; i++)
if(m_handles[i] != INVALID_HANDLE)
IndicatorRelease(m_handles[i]);
ArrayFree(m_handles);
ArrayFree(m_keys);
}
};
CopyBuffer,各自拿到的数组是独立的,这没问题;但如果其中一个模块修改了数组内容,另一个不会受影响——这是好消息。坏消息是,共享句柄意味着共享"数据就绪状态":当某个品种的历史数据还没下载完,句柄存在但BarsCalculated()会返回很小的值。所以每次CopyBuffer之前都应该检查BarsCalculated(handle) >= 需要数量,不满足就跳过这个Tick,等数据到位。这个检查在多品种冷启动阶段是必须的。
iTime(...,0)改成按秒计时,结果在周末跳空和服务器时间调整时漏掉了K线,策略逻辑随之偏移。任何涉及"跳过计算"的优化,都必须用优化前后的成交明细做逐笔比对,确认完全一致才能上线。回测耗时从8小时降到2小时很有价值,但如果成交记录变了,那这个优化就是负价值。
6.3 上线前检查清单
iMA(、iATR(、iRSI(,出现次数应当等于句柄池里的分支数,不应出现在策略类中。
2. new与delete配平:统计new出现次数与delete出现次数,前者不应大于后者。
3. OnDeinit反序销毁:销毁顺序为策略→订单管理器→风控→总线→句柄池,注释标明原因。
4. 新K线判定集中化:全工程集中在一个地方判断新K线,其余模块依赖事件驱动,不各自判断。
5. 跨周期取数不带未来信息:所有高周期取数都经过iBarShift(..., false) + 1的封装函数,不允许裸调。
6. 风控统一入口:全工程OrderSend只出现一次。
7. 日志可追溯:每一次状态转移、每一次风控拦截、每一次下单请求都有带时间戳和ticket的日志。
七、总结与展望:从能跑到能维护
回到开头那个问题:为什么EA会越写越臃肿?因为面向过程的写法没有"边界"。所有的变量都在同一个作用域里,所有的逻辑都在同一个函数里,边界消失了,复杂度就只能线性堆积。重构的本质,是重新画出边界——数据有数据的边界,策略有策略的边界,执行有执行的边界,风控有风控的边界。边界清晰之后,每一个改动都被限制在一个小盒子里,维护成本才不会随功能数量指数上升。
这篇文章给出的四个构件,是这套边界的骨架:事件总线让模块之间不互相认识,数据容器把跨品种跨周期的取数收在一处,状态机把订单生命周期的非法状态堵在源头,风控拦截器把所有开仓请求收成一个入口。这四个构件各自都不复杂,加起来也不到八百行,但它们决定了你的框架能不能撑到第五个品种、第十个策略。
再往前走一步,这套骨架还有明显的扩展空间。数据层可以接外部持久化,把Tick和K线落到SQLite里,避免每次回测都重新下载;执行层可以接Python交互,用ZeroMQ或者本地socket把信号计算搬到Python,MQL5只做执行与风控,这样就能用上成熟的机器学习生态;监控层可以接外部告警,把熔断事件、异常滑点、连接中断推送到手机。这些扩展有一个共同前提:框架的边界足够清晰,才留得出接口。
如果你正在维护一个超过一千行的单文件EA,不妨今天就做一件小事:把所有OrderSend找出来,收敛到一个函数里。这一步不需要改架构,不需要改逻辑,只需要半天时间,但它会让你的风控首次真正成为"统一入口"。重构从来不是一次壮举,而是一连串小到不可能失败的动作。
愿每一位MQL5开发者都能写出结构清晰、边界分明、经得起时间考验的EA代码。
扫码关注,获取更多EA开发干货
微信公众号
视频号
