过去六年靠写EA为生,我逐渐意识到,大部分让人头疼的问题不在策略逻辑本身,而在于那些边缘情况。凌晨两点的追加保证金通知。突然改了报价小数位数的经纪商。回测显示一个结果,实盘图表却显示另一个。
MQL4官方文档(docs.mql4.com)列出了几百个函数。大多数程序员会倾向于那些看起来很炫酷的:OrderSend()、iMA()、iRSI()。这些是中坚力量。但我自己建了一个“B-team”函数工具箱。它们在代码示例中看起来不起眼,但正是这些函数,救我实盘账户的次数多得数不清。
下面就是其中四个。每一个都解决了一个主流教程里不太会深入讨论的实际问题。
1. MarketInfo() 用于经纪商健康状况检查
MarketInfo() 函数被严重低估了。大多数码农只会用它来取 MODE_BID 或 MODE_ASK。但如果你翻阅一下枚举列表,你会发现里面藏着一个关于经纪商特定元数据的金矿,可以帮你避免灾难性的交易错误。场景是这样的:你的EA根据固定的止损点数计算头寸规模。你把它部署到新经纪商那里,突然发现手数完全不对。为什么?因为
MarketInfo(Symbol(), MODE_TICKVALUE) 和 MarketInfo(Symbol(), MODE_TICKSIZE) 变了。官方文档说:“tick value是一个点值,以存款货币表示。”技术上没错,但危险地不完整。在一些经纪商那里,特别是那些有5位报价的,tick size可能是0.00001而不是0.0001。如果你的EA硬编码了点值计算,它就会算错头寸规模。
下面是我现在严格遵守的防御性编程模式:
``
mql4
//+------------------------------------------------------------------+
//| GetSanitizedLotSize - 带有经纪商健康检查的头寸计算 |
//+------------------------------------------------------------------+
double GetSanitizedLotSize(double riskPercent, int stopLossInPoints)
{
// 第1步:获取经纪商实际的tick size
double tickSize = MarketInfo(Symbol(), MODE_TICKSIZE);
// 第2步:获取tick value(一个tick在存款货币中的价值)
double tickValue = MarketInfo(Symbol(), MODE_TICKVALUE);
// 第3步:计算每个点的真实价值
// 这会自动适应4位和5位报价的经纪商
double pointValue = tickValue / tickSize;
// 第4步:获取账户净值
double equity = AccountInfoDouble(ACCOUNT_EQUITY);
// 第5步:计算以存款货币表示的风险金额
double riskAmount = equity (riskPercent / 100.0);
// 第6步:计算止损距离(以价格单位计)
double stopDistance = stopLossInPoints tickSize;
// 第7步:最终的手数
double lotSize = riskAmount / (stopDistance pointValue);
// 第8步:应用经纪商的最小/最大手数限制
double minLot = MarketInfo(Symbol(), MODE_MINLOT);
double maxLot = MarketInfo(Symbol(), MODE_MAXLOT);
double lotStep = MarketInfo(Symbol(), MODE_LOTSTEP);
if(lotSize < minLot) lotSize = minLot;
if(lotSize > maxLot) lotSize = maxLot;
// 第9步:舍入到最接近的手数步长
lotSize = MathFloor(lotSize / lotStep) lotStep;
return(lotSize);
}
`
这里的关键在于 pointValue 会自行重新校准,无论经纪商的报价格式是什么。2021年我花了三周时间调试一个只在OANDA的5位报价上才出现的头寸计算错误。这个函数彻底消除了那一类错误。
一个容易被忽略的细微之处:MODE_TICKVALUE 是动态的。它会随着基础货币的价格变化而变化。对于EURGBP这样的交叉货币对,tick value一直在波动。在每个tick上重新计算它不是低效——而是必须的。我看到太多码农在 OnInit() 里缓存tick value,这简直就是一颗定时炸弹。
2. OrderPrint() 用于取证式调试
当你的EA亏钱时,你需要一个取证式的审计追踪。Print() 是显而易见的选择,但它太乱了。OrderPrint() 则精准得多。
大多数人忽略的技巧是:OrderPrint() 不仅仅打印订单号。它会打印整个交易上下文——开仓价、止损、止盈、佣金、隔夜利息和利润——格式和终端历史记录视图一模一样。当你要对比EA的内部逻辑和经纪商的交易历史时,这非常宝贵。
`mql4
//+------------------------------------------------------------------+
//| DebugTrade - 打印特定订单号的完整交易上下文 |
//+------------------------------------------------------------------+
void DebugTrade(int ticket)
{
if(OrderSelect(ticket, SELECT_BY_TICKET, MODE_TRADES))
{
Print("=== 交易调试开始 ===");
OrderPrint(); // 这是交易日志记录的金标准
Print("当前买价: ", Bid);
Print("当前卖价: ", Ask);
Print("点差: ", MarketInfo(Symbol(), MODE_SPREAD));
// 额外的健康检查:止损/止盈是否有效?
double slDistance = MathAbs(OrderStopLoss() - OrderOpenPrice());
double tpDistance = MathAbs(OrderTakeProfit() - OrderOpenPrice());
double minStop = MarketInfo(Symbol(), MODE_STOPLEVEL);
if(slDistance > 0 && slDistance < minStop Point)
Print("警告: 止损距离开仓价太近!");
if(tpDistance > 0 && tpDistance < minStop Point)
Print("警告: 止盈距离开仓价太近!");
Print("=== 交易调试结束 ===");
}
else
{
Print("选择订单 #", ticket, " 失败 错误号: ", GetLastError());
}
}
`
为什么有用?因为 OrderPrint() 显示的是MT4服务器实际处理的内容,而不是你的EA以为它发送的内容。我抓到过好几次经纪商因为滑点或市场跳空而调整开仓价,而 OrderPrint() 立刻暴露了差异。文档里对它的简短介绍远远配不上它在取证方面的实用价值。
3. StrToTime() 用于基于时间的策略逻辑
每个人都知道怎么用 TimeCurrent() 或 Time[0]。但 StrToTime() 是一个隐藏的宝石,用于设置基于时间的过滤器,尤其是在回测中。
问题在于:如果你硬编码 if(Hour() >= 8 && Hour() <= 16),你的回测就被锁定在经纪商的服务器时间上。如果你换到不同时区的经纪商,你的交易时间就变了。StrToTime() 允许你解析人类可读的UTC时间字符串,并将其转换到经纪商的本地时间。
下面这个模式是我把一个EA从英国经纪商迁移到澳大利亚经纪商时开发的:
`mql4
//+------------------------------------------------------------------+
//| IsTradingHour - 检查当前时间是否在交易窗口内 |
//+------------------------------------------------------------------+
bool IsTradingHour(string startTimeStr, string endTimeStr)
{
// 输入字符串:"08:30" 和 "16:00",以经纪商本地时间为准
// 但我们要透明地处理时区偏移
datetime now = TimeCurrent();
MqlDateTime dt;
TimeToStruct(now, dt);
// 为今天的开始/结束时间构建datetime对象
string todayDate = StringFormat("%04d.%02d.%02d", dt.year, dt.mon, dt.day);
string startDateTimeStr = todayDate + " " + startTimeStr;
string endDateTimeStr = todayDate + " " + endTimeStr;
datetime startTime = StrToTime(startDateTimeStr);
datetime endTime = StrToTime(endDateTimeStr);
// 处理隔夜时段(例如22:00到06:00)
if(StrToTime("23:59") < endTime) // endTime是次日
{
endTime += 24 60 60; // 加24小时
}
if(now >= startTime && now <= endTime)
return(true);
// 处理隔夜边缘情况
if(now >= startTime || now <= endTime)
return(true);
return(false);
}
`
这里容易被忽略的细节是 StrToTime() 接受多种日期格式。文档说它支持"YYYY.MM.DD HH:MI",但它也处理"YYYY/MM/DD"甚至"YYYY-MM-DD"。我用它构建了一个配置系统,交易者可以在人类可读的INI文件中设置交易时间,EA无缝解析。
不过真正的威力在于回测。通过在测试器中使用 StrToTime() 配合 TimeCurrent(),你可以在不修改任何代码的情况下模拟不同的交易时段。我发现EURUSD的时段变化(伦敦-纽约重叠时段)为我突破策略的盈利能力增加了大约15%。
4. GlobalVariableGet() 用于跨重启的持久状态
这是整个MQL4库中最被低估的函数。GlobalVariableGet() 及其搭档 GlobalVariableSet() 允许你在终端重启、策略重新编译甚至断电后存储持久数据。
大多数人用它们来做简单的计数器。那是浪费。我用它们来存储运行在EA内部的机器学习模型的状态。
这是一个真实例子:我构建了一个波动率自适应止损,它根据过去50根K线收益的标准差来调整。在每个tick上计算这个在计算上是浪费的。相反,我每根K线计算一次,并将结果存储在全局变量中。
`mql4
//+------------------------------------------------------------------+
//| GetVolatilityAdaptiveSL - 动态获取或计算止损 |
//+------------------------------------------------------------------+
double GetVolatilityAdaptiveSL(string symbol)
{
string varName = symbol + "_VolatilitySL";
datetime lastCalcTime = (datetime)GlobalVariableGet(varName + "_Time");
datetime currentBarTime = Time[0];
// 如果已经为这根K线计算过,返回缓存值
if(lastCalcTime == currentBarTime)
{
return(GlobalVariableGet(varName));
}
// 否则,重新计算
double atrValue = iATR(symbol, 0, 14, 1);
double standardDeviation = 0;
double sum = 0;
double sumSq = 0;
double returns[50];
for(int i = 1; i < 50; i++)
{
returns[i] = (Close[i] - Close[i+1]) / Close[i+1];
sum += returns[i];
sumSq += returns[i] returns[i];
}
double mean = sum / 49;
double variance = (sumSq / 49) - (mean mean);
standardDeviation = MathSqrt(variance);
// 自适应止损:2.5倍标准差加ATR成分
double stopDistance = (2.5 standardDeviation + 0.5 (atrValue / Close[1])) Close[1];
// 舍入到最近的点值
double point = Point;
stopDistance = MathFloor(stopDistance / point) point;
// 存入全局变量
GlobalVariableSet(varName, stopDistance);
GlobalVariableSet(varName + "_Time", currentBarTime);
return(stopDistance);
}
`
这里隐藏的技巧是,全局变量在 #property 重新编译后仍然存在。如果你正在积极开发一个EA,一天重新编译多次,你不会丢失状态。这在实盘运行时至关重要,特别是当策略依赖于顺序交易追踪时。
但有一个文档没有明确警告的陷阱:即使你从图表上删除了EA,全局变量仍然存在。只有当你显式使用 GlobalVariableDel() 或终端工具箱的“全局变量”标签页时,它们才会被清除。我在六个月里不知不觉积累了47个过时变量。现在我总是用EA的幻数作为变量前缀,并包含一个清理例程:
`mql4
//+------------------------------------------------------------------+
//| CleanupGlobalVariables - 删除该EA的所有变量 |
//+------------------------------------------------------------------+
void CleanupGlobalVariables()
{
int magic = 123456;
string prefix = IntegerToString(magic) + "_";
int totalVars = GlobalVariablesTotal();
for(int i = totalVars - 1; i >= 0; i--)
{
string varName = GlobalVariableName(i);
if(StringFind(varName, prefix) == 0)
{
GlobalVariableDel(varName);
Print("已清理: ", varName);
}
}
}
`
在 OnDeinit() 中调用这个函数可以确保你的全局命名空间保持干净。
当文档误导你时
最后想提一个细微但重要的细节。MQL4文档中关于 GlobalVariableGet() 的描述说:“该函数返回现有全局变量的值,出错时返回0。” “出错时返回0”这个说法有误导性。如果你存储了一个合法的0.0值,你怎么区分它是合法值还是错误?
我的解决办法是先检查是否存在:
`mql4
//+------------------------------------------------------------------+
//| SafeGlobalVariableGet - 区分0值和错误 |
//+------------------------------------------------------------------+
double SafeGlobalVariableGet(string varName, bool &exists)
{
exists = false;
int totalVars = GlobalVariablesTotal();
for(int i = 0; i < totalVars; i++)
{
if(GlobalVariableName(i) == varName)
{
exists = true;
return(GlobalVariableGet(varName));
}
}
return(0.0);
}
``这个模式救我避免了将合法的零值误判为错误,次数多得数不清。这是一个小细节,但在实盘交易中,小细节积累起来就是大问题。
参考来源:
本文首发于FXEAR.com,原创内容,未经授权禁止转载。