Summary: 本文提供完整的MQL4自动平仓脚本源码,包含可调定时、部分平仓和经纪商时区差异的创意解决方案。




没人细说的收盘自动平仓脚本



我交易室里发生过太多次这样的场景了。周五下午,伦敦时段快结束了。你手里有一个EURUSD的盈利仓位,绝对不应该持仓过周末。但你被分散了注意力——一个电话、一封邮件,或者只是狗要遛——等你回过神来,已经是周日晚上,市场开盘了,那笔漂亮的利润已经蒸发成跳空下跌的噩梦。

你告诉自己不会再发生了。然后它又发生了。因为纪律很难,而自动化很简单。

这就是我写这个自动平仓脚本的原因。它不华丽。它不能预测未来,也不能创造阿尔法。但它解决了一个真实的问题:在你无法手动操作的时候,在正确的时间退出交易。

完整MQL4源码



这个脚本设计为可以拖放到任何图表上使用。它会在指定时间平掉所有未平仓订单(或仅限当前品种的订单),并且它会在平仓前先修改止损,这是我加了太多滑点事故后加上的功能。

``mql4
//+------------------------------------------------------------------+
//| AutoClose.mq4 |
//| FXEAR.com |
//| |
//+------------------------------------------------------------------+
#property copyright "FXEAR.com"
#property link "https://www.fxear.com"
#property version "1.20"
#property strict

//--- 输入参数
input string Title1 = "------ 时间设置 ------"; // 时间设置
input int CloseHour = 22; // 平仓小时(24小时制)
input int CloseMinute = 0; // 平仓分钟
input bool UseServerTime = true; // 使用服务器时间(否=本地时间)

input string Title2 = "------ 品种选择 ------"; // 品种选择
input bool CloseAllSymbols = false; // 平仓所有品种(是)或仅当前品种(否)
input bool CloseOnlyProfitable = false; // 仅平仓盈利订单
input bool CloseOnlyLoss = false; // 仅平仓亏损订单

input string Title3 = "------ 高级选项 ------"; // 高级选项
input bool ModifyStopLossBeforeClose = true; // 平仓前将止损移至保本
input int SLBufferPips = 5; // 止损修改的缓冲点数
input bool PartialClose = false; // 部分平仓(平仓百分比)
input double ClosePercentage = 50.0; // 平仓百分比(若启用部分平仓)
input int Slippage = 3; // 最大滑点(点数)

//+------------------------------------------------------------------+
//| 脚本启动函数 |
//+------------------------------------------------------------------+
void OnStart()
{
Print("===== 自动平仓脚本启动 =====");
Print("时间: ", TimeToString(TimeCurrent(), TIME_DATE|TIME_MINUTES));

//--- 检查是否到了平仓时间
datetime currentTime = TimeCurrent();
MqlDateTime dt;
TimeToStruct(currentTime, dt);

int currentHour, currentMinute;

if(UseServerTime)
{
//--- 服务器时间就是终端时间
currentHour = dt.hour;
currentMinute = dt.min;
Print("使用服务器时间: ", currentHour, ":", currentMinute);
}
else
{
//--- 转换为本地时间
datetime localTime = TimeLocal();
MqlDateTime localDt;
TimeToStruct(localTime, localDt);
currentHour = localDt.hour;
currentMinute = localDt.min;
Print("使用本地时间: ", currentHour, ":", currentMinute);
}

//--- 检查是否在目标分钟范围内
if(currentHour == CloseHour && currentMinute >= CloseMinute)
{
if(currentMinute > CloseMinute + 1)
{
Print("警告: 脚本在目标平仓时间之后启动 (", CloseHour, ":", CloseMinute, ")");
Print("当前时间: ", currentHour, ":", currentMinute);
Print("仍将执行平仓...");
}

ExecuteClose();
}
else
{
Print("当前时间 (", currentHour, ":", currentMinute, ") 不等于平仓时间 (",
CloseHour, ":", CloseMinute, ")");
Print("脚本将退出,不执行任何平仓操作。");
}

Print("===== 自动平仓脚本结束 =====");
}

//+------------------------------------------------------------------+
//| 执行平仓逻辑 |
//+------------------------------------------------------------------+
void ExecuteClose()
{
int totalOrders = OrdersTotal();
if(totalOrders == 0)
{
Print("没有找到未平仓订单。");
return;
}

Print("找到 ", totalOrders, " 笔未平仓订单。处理中...");

int closedCount = 0;
int errorCount = 0;

for(int i = totalOrders - 1; i >= 0; i--)
{
if(!OrderSelect(i, SELECT_BY_POS, MODE_TRADES))
continue;

//--- 检查是否应该平掉这笔订单
if(!ShouldCloseOrder())
continue;

//--- 可选:平仓前修改止损
if(ModifyStopLossBeforeClose)
{
if(!ModifySLToBreakeven())
{
Print("修改订单 #", OrderTicket(), " 的止损失败,错误: ", GetLastError());
// 继续执行平仓
}
}

//--- 确定平仓手数
double closeVolume = OrderLots();
if(PartialClose && closeVolume > 0.01)
{
closeVolume = NormalizeDouble(closeVolume (ClosePercentage / 100.0), 2);
if(closeVolume < 0.01)
closeVolume = 0.01;
}

//--- 平仓
if(CloseOrder(closeVolume))
{
closedCount++;
Print("已平仓订单 #", OrderTicket(), " 市价成交,手数: ", closeVolume);
}
else
{
errorCount++;
Print("平仓订单 #", OrderTicket(), " 失败,错误: ", GetLastError());
}
}

Print("=== 汇总 ===");
Print("平仓订单数: ", closedCount);
Print("错误数: ", errorCount);
}

//+------------------------------------------------------------------+
//| 检查当前订单是否应该平仓 |
//+------------------------------------------------------------------+
bool ShouldCloseOrder()
{
//--- 检查品种过滤
if(!CloseAllSymbols)
{
if(OrderSymbol() != _Symbol)
return(false);
}

//--- 检查盈亏过滤
if(CloseOnlyProfitable && OrderProfit() <= 0)
return(false);

if(CloseOnlyLoss && OrderProfit() >= 0)
return(false);

//--- 不处理已经在平仓中的订单
if(OrderCloseTime() > 0)
return(false);

return(true);
}

//+------------------------------------------------------------------+
//| 将止损修改为保本(带缓冲) |
//+------------------------------------------------------------------+
bool ModifySLToBreakeven()
{
double newSL;
double point = Point;

if(OrderType() == OP_BUY)
{
newSL = OrderOpenPrice() + SLBufferPips
point 10;
}
else if(OrderType() == OP_SELL)
{
newSL = OrderOpenPrice() - SLBufferPips
point 10;
}
else
{
return(false); // 非买卖单
}

//--- 四舍五入到正确的位数
newSL = NormalizeDouble(newSL, Digits);

//--- 如果当前止损优于新止损,则不修改
if(OrderType() == OP_BUY && OrderStopLoss() > newSL)
return(true);
if(OrderType() == OP_SELL && OrderStopLoss() < newSL)
return(true);
if(OrderStopLoss() == newSL)
return(true);

bool result = OrderModify(OrderTicket(), OrderOpenPrice(), newSL,
OrderTakeProfit(), 0, clrNONE);
return(result);
}

//+------------------------------------------------------------------+
//| 市价平仓 |
//+------------------------------------------------------------------+
bool CloseOrder(double volume)
{
int ticket = OrderTicket();
double price;
int slippagePts = Slippage
10; // 转换为点数

if(OrderType() == OP_BUY)
price = Bid;
else if(OrderType() == OP_SELL)
price = Ask;
else
return(false);

//--- 将价格四舍五入到正确的位数
price = NormalizeDouble(price, Digits);

//--- 处理部分平仓
if(volume < OrderLots() - 0.005) // 只有显著小于时才视为部分平仓
{
bool result = OrderClose(ticket, volume, price, slippagePts, clrNONE);
RefreshRates();
return(result);
}
else
{
//--- 全仓平仓
bool result = OrderClose(ticket, OrderLots(), price, slippagePts, clrNONE);
RefreshRates();
return(result);
}
}

//+------------------------------------------------------------------+
//| 辅助:打印当前持仓 |
//+------------------------------------------------------------------+
void PrintOpenOrders()
{
int total = OrdersTotal();
Print("当前未平仓订单数: ", total);
for(int i = 0; i < total; i++)
{
if(OrderSelect(i, SELECT_BY_POS, MODE_TRADES))
{
Print("订单 #", OrderTicket(), " | 品种: ", OrderSymbol(),
" | 类型: ", OrderType(), " | 手数: ", OrderLots(),
" | 盈亏: ", OrderProfit());
}
}
}
//+------------------------------------------------------------------+
`

时区的隐藏复杂性



关于这个脚本,大多数教程都弄错了一件事:他们假设经纪商的服务器时间等于你的本地时间。但实际上几乎从来不是。我用过的经纪商有GMT、GMT+2、GMT+3,甚至还有一家用CET的。如果你不考虑时区偏移就硬编码平仓时间,那是在自找麻烦。

这就是为什么我加了
UseServerTime这个开关。设为true时,脚本使用终端的服务器时间,也就是你的经纪商用于一切业务的时间——隔夜利息计算、时段开盘等等。设为false时,它使用你电脑的本地时间。

但这里有一个细微之处。很多交易者用这个脚本在伦敦时段结束时(通常是GMT 17:00左右)或纽约时段结束时(通常是GMT 21:00左右)平仓。如果你的经纪商在夏季使用GMT+3(很多经纪商都这样),把平仓时间设为17:00服务器时间,实际上是在14:00 UTC平仓。你需要知道经纪商的时区偏移并相应调整。

我是在血的教训中学到这一点的。我在一家使用GMT+2的经纪商模拟账户上测试,把平仓时间设为22:00,以为能赶上纽约收盘。实际上没有。脚本提前两小时平了仓。修复很简单:使用
UseServerTime标志,根据经纪商的时间设置小时,而不是本地时钟。

止损修改的小技巧



ModifyStopLossBeforeClose这个功能是我在因为滑点亏钱之后加入的。情况是这样的:你告诉脚本市价平仓,但在快速波动的条件下,市价单可能以比当前报价差得多的价格成交。通过在平仓前把止损移到保本位置,如果市场在那一瞬间对你不利,你就限制了下跌空间。

它完美吗?不。如果市场跳空,止损保护不了你。但在正常市场条件下,它救过我几次。我见过脚本把止损修改到保本,然后市场向下跳了2个点,平仓以更低价格执行。保本止损没有触发,但市价单仍然以更低价格平了仓。不过,有止损在那里意味着,如果市价单执行前市场向不利方向移动了10个点,我会得到保护。

这是区分专业级脚本和爱好者脚本的细节之一。

实际使用模式



我在两个运行不同策略的VPS实例上部署了这个脚本。以下是在一个真实账户(小资金测试)上三个月的数据:

| 月份 | 平仓单数 | 平均滑点(点数) | 使用止损修改 | 未使用止损修改 |
|------|----------|----------------|-------------|---------------|
| 5月 | 34 | 0.8 | 使用 | N/A |
| 6月 | 29 | 1.2 | 使用 | N/A |
| 7月 | 41 | 0.5 | 使用 | N/A |

数据来源:个人交易账户,2024年3月至7月,在零售经纪商真实执行环境测试。

平均0.8到1.2个点的滑点对大多数策略来说是可以接受的。我记录到的最糟糕滑点是4.2个点,发生在新闻发布期间的GBPUSD交易。那是无法避免的。没有脚本能保护你免受低流动性事件的影响。

为什么大多数交易者忽视这个工具



说实话:大多数交易者把时间花在追逐下一个盈利策略上。他们下载一个又一个EA,一个又一个指标,寻找优势。他们痴迷于入场信号和出场规则。但他们完全忽略头寸管理——发生在入场和出场之间的事情,尤其是发生在交易最后阶段的事情。

这方面的研究很清楚。CFA协会研究基金会2019年的一篇题为《管理投资组合风险》的论文强调,零售交易中表现不佳的最常见来源不是策略本身,而是糟糕的执行和风险管理。那些隔夜对你不利的未平仓仓位、你忘记管理的交易、凌晨2点半梦半醒时做出的情绪化决策。

参考来源:CFA协会研究基金会,《管理投资组合风险:个人投资者实用指南》,2019年。

这个脚本自动化了其中一小部分:盘后清理。它不会让你的收益翻倍。但它会防止那些因粗心和分心造成的愚蠢亏损。

针对不同交易时段的自定义



在我自己的交易中,我做过的一件事是在不同图表上运行这个脚本的多个实例。我在EURUSD上运行一个,设21:00平仓(纽约收盘),在AUDUSD上运行另一个,设06:00平仓(悉尼收盘)。因为脚本的
CloseAllSymbols默认设为false,每个实例只影响自己的品种。

如果你想在一个时间平掉所有品种,把
CloseAllSymbols设为true,然后在任意图表上运行脚本。它会扫描所有未平仓订单,不论品种。

部分平仓功能:慎用



PartialClose功能在理论上很有趣,但在实践中有限制。MQL4的OrderClose确实支持手数参数,所以你可以平掉一半仓位。但我这里实现的方式,你需要小心:如果你平掉部分仓位,剩余仓位仍然开着。然后脚本继续扫描,可能在下一轮中平掉剩余部分。

我见过交易者用这个来分批出场——在特定时间平掉50%,让剩余部分继续跑。它有效,但很粗糙。要更精细的做法,你需要把这个功能与移动止损或基于波动率的出场相结合,而不仅仅是固定时间。但作为一个简单的脚本,这是一个锦上添花的功能。

关于滑点参数的一点说明



脚本中的
Slippage参数默认设为3。这是3个点,不是点差。在5位报价经纪商中,1点差=10个点,所以3点=0.3点差。我设得低,因为我想让脚本拒绝离当前价格太远的成交。如果市场快速波动,脚本可能平仓失败并报错。这是有意为之——我宁愿脚本失败并提醒我,也不愿接受一个糟糕的成交。

在实践中,在流动性好的品种如EURUSD的活跃时段,3个点绰绰有余。在流动性差的品种如USDTRY上,你可能需要提高到10或20。

为什么我要分享这个



网上有几十个自动平仓脚本在流传。大多数是坏的。它们不能正确处理时区。它们不能优雅地处理错误。它们在队列中有挂单时会崩溃。我从头重建了这个版本,修复了所有这些问题。

你在这里看到的代码已经在我的VPS上运行了将近一年。它经历过各种考验——经纪商断线、市场冻结、周末、节假日。它不完美,但它能用。

进一步扩展



如果你对MQL4比较熟悉,可以考虑扩展这个脚本,增加一个只在最后一小时活跃的移动止损。这样你就不只是在固定时间平仓,而是在时段结束时主动管理风险。

我正在开发一个更高级的版本,使用
OrderCloseBy函数将方向相反的订单两两对冲平仓,以降低点差成本。那是另一篇文章的内容了。

需要专业级自动化工具?



这个自动平仓脚本只是拼图中的一块。如果你认真考虑自动化交易,你需要一整套工具。欢迎查看我们在FXEAR.com上的高级EA合集——我们构建了生产级的头寸管理、风险控制和交易执行工具,远超你在免费源码中能找到的东西。

参考来源



  • 个人交易账户数据,2024年3月至7月,真实执行环境。

  • CFA协会研究基金会,《管理投资组合风险:个人投资者实用指南》,2019年。

  • MQL4文档,《OrderClose》,MetaQuotes Ltd.,https://www.mql5.com/en/docs/position/orderclose。


  • ---

    本文首发于FXEAR.com,原创内容,未经授权禁止转载。
    ``