Skip to content

伦敦区间突破

这个策略具有其独特的魅力,很多交易员很信任这类策略,它明确基于特定的时间段(比如伦敦市场的开盘时间),而非依赖价格行为或其他指标。这是它与其他许多交易策略的核心区别,也让交易员能清晰知道何时需要密切关注行情——你一天只需看两次盘面,一周只需看四次盘面,其余时间无需守在电脑前。

一、策略核心逻辑

原始策略基于英国伦敦外汇市场开盘时的行情交易,核心理论是:伦敦开盘第1小时的价格行为通常反映当天主要趋势。原因有二:

  1. 交易量集中:伦敦是全球最大外汇交易市场之一,占全球37%的交易量。其开盘时,亚洲市场未完全闭盘,北美市场即将开盘,形成交易活跃度高峰,大量交易员集中参与,是各国银行外汇交易的密集区。
  2. 重要消息公布:多数重要财经数据和消息在伦敦交易时段发布,对市场影响重大,易引发大幅波动。

基于以上因素,伦敦开盘后1小时的行情常形成全天趋势,交易员通过观察该时段价格行为,以突破区间上下沿作为交易信号,规则简单明了,执行与回测效率高。

二、策略绩效与心路历程

该策略实盘半年多(2024年7月上线),覆盖加密货币、股指、外汇、期货、黄金等品种。初期表现平平,近期才有起色。前几个月曾想放弃,但因未达下架标准而坚持,最终迎来盈利。

这一过程让我意识到:多数交易员难以盈利的关键,在于无法承受策略的“停滞期”。即使是正收益策略,也会因市场无行情而暂时失效,而许多人在等待盈利的过程中选择放弃。正如老话所说:“不经历风雨怎么见彩虹?阳光总在风雨后,欲戴王冠必承其重。”

三、策略操作细节

1. 核心规则

  • 时间区间:设定伦敦开盘后的特定时间段(如第1小时),统计该区间的最高价(上沿)和最低价(下沿),形成“箱体”。
  • 突破信号:区间结束后,若价格突破上沿则做多,突破下沿则做空。
  • 止损与止盈
    • 止损:设为箱体上下沿点数的倍数(如1倍,多单止损设为箱体下沿,空单止损设为箱体上沿)。
    • 止盈:可设(如4倍箱体距离)或不设,我选择较远的止盈位。
  • 平仓时间:设定最终平仓时间,无论盈亏,到达时间必须平仓。

2. 过滤规则(可回测调整)

  • 周期滤网:仅在周一、周三交易,周二、周四、周五忽略信号(我实测有效,建议自行回测)。
  • 信号冲突:若突破后反向再突破(如空单入场后价格反弹至上沿),是否二次开仓需根据品种特性回测决定。

四、策略图示

五、代码实现

cpp
#property strict
#include <Indicators/Trend.mqh>
#include <Util/Util.mqh>
Util util;

enum BREAKOUT_MODE_ENUM {
   ONE_SIGNAL,  // 单边突破
   TWO_SIGNALS, // 双边突破
};

// 不可以做参数优化的参数
input group "固定和输入通用部分";
sinput int MagicNum = 12345; // 魔术编号
sinput int SlType = 1;                                        // 止损方式(1:固定金额{100}美金 2:账户{1}% 3:固定{0.1}手 4:子账户总账户{1}%)
sinput int SlParam = 100;                                     // 止损依据(根据上方【止损方式】的不同,所代表的含义也不同)

input group "参数优化";
input ENUM_TIMEFRAMES TimeFrame = PERIOD_H1; // 时间周期
input int StopLoss =100;                                                 // 止损(区域% 0=无)   传入100 则表示止损 区域高度的 100%
input int TakeProfit = 400;                                               // 止盈(区域% 0=无)   传入100 则表示止盈 区域高度的 100%
input int RangeStart = 540;                                               // 区域开始时间点(分钟) 传入540 表示 9点开始
input int RangeDuration = 1260;                                            // 区域持续时间段(分钟) 传入60 表示持续到10点结束
input int RangeClose = 1020;                                               // 区域平仓时间点(分钟,-1=无) 传入1020 表示17点平仓

input BREAKOUT_MODE_ENUM BreakoutMode = TWO_SIGNALS; // 突破模式(1:单边突破 2:双边突破)

input group "星期滤网";
input bool Monday = true; // 星期一是否做单
input bool Tuesday = false;                       // 星期二是否做单
input bool Wednesday = true;                     // 星期三是否做单
input bool Thursday = false;                      // 星期四是否做单
input bool Friday = false;                        // 星期五是否做单

int barsTotal;
int initBalance;
/*
结构体也是一种特殊的数据类型它是把几个不同类型变量封装为1个自定义得数据类型。它其实很像我们面向对象编程中的对象的概念。
在编程中,当你想在一个变量中保存不同的相关信息时,这种结构就会派上用场。这可以让存储更多的信息,并且可以将这些信息作为一个整体来操作。
用法:
struct 结构体名称{
数据类型1 名称1;
数据类型2 名称2;
...
数据类型n 名称n;
结构体的定义使用关键字struct开始,并以分号;结束。
在大括号{}里面,你可以按照数据类型 名称的格式声明结构体的成员
结构体的成员可以为任何有效的数据类型
然后你可以清楚明确地通过点运算符,来访问这些成员:

RANGE_STRUCT range;
ange.start time =D'2024.01.17 64:29:24';
ange.high =1.3600;
ange.low1.3500;
if(range.high == 1.5)

构造函数在创建一个对象时被自动调用,它通常用于设置成员变量的初始值
如果我们想让对象在创建时具有某些默认的状态,我们就可以在构造函数中显式地设置它
例如,在RANGE STRUCT中的构造函数就设定了所有成员的默认值
*/
/*
构造函数在创建一个对象时被自动调用,它通常用于设置成员变量的初始值
如果我们想让对象在创建时具有某些默认的状态,我们就可以在构造函数中显式地设置它
例如,在RANGE_STRUCT中的构造函数就设定了所有成员的默认值
这章味着,每当我们创建一个新的RANGE STRUCT对象时
这些成员就会自动被设定为指定的值,我们无需手动设置
保证了数据的一致性,改普了代码的可读性。
*/
struct RANGE_STRUCT {
   datetime start_time;
   datetime end_time;
   datetime close_time;
   double high;
   double low;
   bool f_entry;
   bool f_high_breakout;
   bool f_low_breakout;
   RANGE_STRUCT() : start_time(0), end_time(0), close_time(0), high(0), low(DBL_MAX), f_entry(false), f_high_breakout(false), f_low_breakout(false) {};
};

RANGE_STRUCT range;
MqlTick currentTick;
int objKey;
double point;
//+------------------------------------------------------------------+
//|                                                                  |
//+------------------------------------------------------------------+
int OnInit() {
// 当用户没有打开参数对应的图表周期,自动帮他打开对应周期
   if (MQLInfoInteger(MQL_TESTER)) {
      ChartSetInteger(0, CHART_SHOW_GRID, false);
   } else {
      ChartSetSymbolPeriod(0, _Symbol, TimeFrame);
   }
   int cntBuy, cntSell;
   util.countOpenPositions(MagicNum, cntBuy, cntSell);
// 如果图表参数发生了变更,并且没有任何买单和卖单,那么就重新计算时间区域
   if (_UninitReason == REASON_PARAMETERS && cntBuy == 0 && cntSell == 0)
      CalculateRange();
   objKey = rand();
   initBalance = SlParam;
   Comment("MagicNum:", MagicNum, " SlType: ", SlType, " SlParam: ", SlParam);
   return INIT_SUCCEEDED;
}
//+------------------------------------------------------------------+
//|                                                                  |
//+------------------------------------------------------------------+
bool CheckInputs() {
// 没有止损又没有设置平仓时间
   if (RangeClose < 0 && StopLoss == 0) {
      Alert("RangeClose<0 && StopLoss ==0 ");
      return false;
   }
// 统计一天的最高最低价
   if (RangeStart < 0 || RangeStart >= 1440) {
      Alert("RangeStart<0 || RangeStart>=1440");
      return false;
   }
   if (RangeDuration <= 0 || RangeDuration >= 1440) {
      Alert("RangeDuration<=0 || RangeDuration>=1440");
      return false;
   }
   if (RangeClose >= 1440) {
      Alert("RangeClose>=1440 or end time == close time");
      return false;
   }
   if (Monday + Tuesday + Wednesday + Thursday + Friday == 0) {
      Alert("Monday+Tuesday+Wednesday+Thursday+Friday ==0");
      return false;
   }
   return true;
}
// 计算时间区域
void CalculateRange() {
// 重置数据
   range.start_time = 0;
   range.end_time = 0;
   range.close_time = 0;
   range.high = 0.0;
   range.low = DBL_MAX;
   range.f_entry = false;
   range.f_high_breakout = false;
   range.f_low_breakout = false;

// 计算区域开始时间, 星期六和星期天区域后移
   /*
       一天的秒数(24小时*60分钟/小时**60秒/分钟=86400秒)
       (currentTick.time%time_cyle)
       求的是当前时间自凌晨以来过去的秒数,用当前时间减去这个结果可以得到当天(80:00:80)的时间
       tmp.day_of_week获取到这个时间是一周的第几天(1-5表示周一至周五,6表示周六,@表示周日)
       当前时间己经过了时间,区域的开始时间|周六周天市场闭市的情况|| 使用的星期过滤器过滤掉了一些不工作的星期
       如果以上任意一种情况为真,那么它就将range.start_time向后推迟一天(也就是加上一天的秒数,即time_cycle)
   */
   int time_cycle = 86400; // 一天的秒数
   range.start_time = currentTick.time - (currentTick.time % time_cycle) + RangeStart * 60;
   for (int i = 0; i < 8; i++) {
      MqlDateTime tmp;
      TimeToStruct(range.start_time, tmp);
      int dow = tmp.day_of_week;
      if (currentTick.time > range.start_time || dow == 6 || dow == 0 || (dow == 1 && !Monday) || (dow == 2 && !Tuesday) || (dow == 3 && !Wednesday) || (dow == 4 && !Thursday) || (dow == 5 && !Friday)) {
         range.start_time += time_cycle;
      }
   }
// 计算区域结束时间 星期六 和星期天区域后移
   range.end_time = range.start_time + RangeDuration * 60;
   for (int i = 0; i < 2; i++) {
      MqlDateTime tmp;
      TimeToStruct(range.end_time, tmp);
      int dow = tmp.day_of_week;
      if (dow == 6 || dow == 0) {
         range.end_time += time_cycle;
      }
   }
// 计算平仓时间, 星期六和星期天区域后移
   if (RangeClose >= 0) {
      range.close_time = range.start_time + RangeClose * 60;
      for (int i = 0; i < 3; i++) {
         MqlDateTime tmp;
         TimeToStruct(range.close_time, tmp);
         int dow = tmp.day_of_week;
         if (range.close_time <= range.end_time || dow == 6 || dow == 0) {
            range.close_time += time_cycle;
         }
      }
   }
   DrawObjeckts();
}
//+------------------------------------------------------------------+
//|                                                                  |
//+------------------------------------------------------------------+
void DrawObjeckts() {
// 开始 结束时间末
   /*
   图表ID。这里的0表示当前激活的图表,也就是正在查看的图表。
   objname:创建的图形对象名称,这个是唯一的,如果有其他对象使用了相同的名称,将会出错。
   OBJRECTANGLE:这是图形对象的类型,这里OBJRECTANGLE表示的是矩形。
   0:此处的0表示主窗口。在MT5中,图表窗口可以有多个,比如主图窗口,MACD窗口,相对强度指标窗口等等,这里的0表示主图窗口。
   range.start_time:矩形对象的开始时间,也就是矩形对象在X轴的开始位置,通常这个是一个时间值。
   range.high:表示矩形对象在价格轴(Y轴)上的顶部位置。
   range.end_time:矩形对象的结束时间,也就是矩形对象在X轴的结束位置,和开始时间配合确定了矩形的宽度。
   range.low:表示矩形对象在价格轴(Y轴)上的底部位置。配合range.high确定了矩形的高度。
   */
   int digits = (int)SymbolInfoInteger(_Symbol, SYMBOL_DIGITS);
   string objname;
   objname = "range time zone " + (string)objKey;
   if (range.start_time > 0 && range.end_time > 0) {
      // 如果已存在同名的绘图对象,先将其删除
      if (ObjectFind(0, objname) != INVALID_HANDLE) {
         ObjectDelete(0, objname);
      }
      // 创建矩形对象
      ObjectCreate(0, objname, OBJ_RECTANGLE, 0,
                   range.start_time, range.high,
                   range.end_time, range.low);

      // 设置相关属性:矩形在背景层,颜色为深海绿,填充模式,无边框
      ObjectSetInteger(0, objname, OBJPROP_BACK, true);
      ObjectSetInteger(0, objname, OBJPROP_COLOR, clrDarkSeaGreen);
      ObjectSetInteger(0, objname, OBJPROP_FILL, true);
      ObjectSetInteger(0, objname, OBJPROP_BORDER_TYPE, DRAW_NONE);
   }

// 高点
   /* NULL:这是图表ID。'NULL'表示要在当前活动图表上创建对象。如果你想在特定图表上创建对象,可以在这里填写特定图表的ID。
       objname:这是对象名称。在一个图表上,每个对象都要有唯一的名称,以便于区分和管理。
       OBJTREND:对象类型。这里的OBJ_TREND表示要创建的是一个趋势线对象。
       0:窗口索引。'0'表示在主图窗口上创建这个趋势线对象。

       range.end_time 和 range.high 是趋势线的一端(通常是起点)的时间和价格。range可能是某个数据结构,记录了趋势线的信息。
       RangeClose>=0?range.close_time:INT_MAX和range.high 是趋势线的另一端(通常是终点〉的时间和价格。
       特别的,如果RangeClese不小于0,那么终点的时间就是range.close_time。
       否则,终点的时间将会是INT_MAX,这是一个非常大的值,可能意味着趋势线一直绘制到未来的某个时间。
   */

   objname = "range high" + (string)objKey;
   if (range.high > 0) {
      // 在具有相同高度的范围结束时间到平仓时间之间绘制一条延伸线
      objname = objname + "extend";
      // 创建趋势线对象(假设需要两个点:开始时间+高点,结束时间+高点)
      ObjectCreate(0, objname, OBJ_TREND, 0, range.end_time, range.high, RangeClose >= 0 ? range.close_time : INT_MAX, range.high);

      // 设置趋势线属性(示例:红色,实线,线宽2)
      ObjectSetString(0, objname, OBJPROP_TOOLTIP, "high of the range \n" + DoubleToString(range.high, digits));
      ObjectSetInteger(0, objname, OBJPROP_COLOR, clrDarkSeaGreen);
      ObjectSetInteger(0, objname, OBJPROP_STYLE, STYLE_SOLID);
      ObjectSetInteger(0, objname, OBJPROP_BACK, true);
      // 设置线的样式为点状
      ObjectSetInteger(0, objname, OBJPROP_STYLE, STYLE_DOT);
   }
   objname = "range low" + (string)objKey;
// 如果交易区间的地点小于DBL_MAX(一个非常大的数,表示没有设置低点)
   if (range.low < DBL_MAX) {
      objname = objname + "extend";
      ObjectCreate(0, objname, OBJ_TREND, 0, range.end_time, range.low, RangeClose >= 0 ? range.close_time : INT_MAX, range.low);

      // 设置趋势线属性(示例:蓝色,实线,线宽2)
      ObjectSetString(0, objname, OBJPROP_TOOLTIP, "low of the range \n" + DoubleToString(range.low, digits));
      ObjectSetInteger(0, objname, OBJPROP_COLOR, clrDarkSeaGreen);
      ObjectSetInteger(0, objname, OBJPROP_STYLE, STYLE_SOLID);
      ObjectSetInteger(0, objname, OBJPROP_BACK, true);
      // 设置线的样式为点状
      ObjectSetInteger(0, objname, OBJPROP_STYLE, STYLE_DOT);
   }

// 平仓时间
   /*
   NULL:这是图表的ID。为NULL意味着在当前收到报价的图表上创建对象。
   如果在特定的图表上创建对象,可以接受图表的特定ID
   obiname:这是创建的对象的名称,每个对象必须有一个独一无二的名称,以便于管理和操作。
   0BJ_VLINE:这是对象的类型,在这里 0BJVLINE 表示创建的是一个重直线对象。
   0:这是窗口的索引,0表示主窗口。
   range.close_time:垂直线的横坐标位置,也就是在时间轴上的位置,这里是在平仓的时间位置画这个垂直线。
   0:这个参数在 0BVLINE 类型的对象中无意义,因为乖直线在乖直轴(价格轴)上并无起始和结束
   */
   objname = "range close" + (string)objKey;
   if (range.close_time > 0) {
      // 创建垂直线对象
      ObjectCreate(0, objname, OBJ_VLINE, 0, range.close_time, 0);
      // 设置垂直线属性(示例:红色,实线,线宽2)
      ObjectSetString(0, objname, OBJPROP_TOOLTIP, "close of the range \n" + TimeToString(range.close_time, TIME_DATE | TIME_MINUTES));
      ObjectSetInteger(0, objname, OBJPROP_COLOR, clrLightPink);
      ObjectSetInteger(0, objname, OBJPROP_WIDTH, 2);
      ObjectSetInteger(0, objname, OBJPROP_BACK, true);
   }
// 刷新图表 , 使新增的绘图对象立即显示出来
   ChartRedraw();
}
// 节省内存后 买VPS就可以买小一点
void OnDeinit(const int reason) {
}
void OnTick() {

   if (!util.isNewBar(_Symbol, PERIOD_CURRENT, barsTotal))
      return;
   point = SymbolInfoDouble(_Symbol, SYMBOL_POINT);
   SymbolInfoTick(_Symbol, currentTick);
   if (currentTick.time >= range.start_time && currentTick.time < range.end_time) {
      range.f_entry = true;
      double high = iHigh(_Symbol, PERIOD_CURRENT, 1);
      double low = iLow(_Symbol, PERIOD_CURRENT, 1);
      if (high > range.high) {
         range.high = high;
         DrawObjeckts();
      }
      if (low < range.low) {
         range.low = low;
         DrawObjeckts();
      }
   }
// 出场
// 检查是否达到指定平仓时间
   if (RangeClose >= 0 && currentTick.time >= range.close_time)
   closeAllPositions(MagicNum);

   int cntBuy, cntSell;
   util.countOpenPositions(MagicNum, cntBuy, cntSell);

   if (((RangeClose >= 0 && currentTick.time >= range.close_time) // 过了平仓时间

         || (range.f_high_breakout && range.f_low_breakout)                                // 上下沿都被突破
         || (range.end_time == 0)                                                          // 区域没有计算
         || (range.end_time != 0 && currentTick.time >= range.end_time && !range.f_entry)) // 区域已经计算但是里面没有价格行为
         && cntBuy == 0 && cntSell == 0) {
      objKey = rand();
      CalculateRange();
      Comment("开始时间: " + (string)range.start_time + "\r\n" +
              "结束时间: " + (string)range.end_time + "\r\n" +
              "平仓时间: " + (string)range.close_time + "\r\n");
   }

   CheckBreakouts();
}
// 突破下单
void CheckBreakouts() {
// 联取当前货币对的小数位数
   int digits = (int)SymbolInfoInteger(Symbol(), SYMBOL_DIGITS);

// 判断当前时间是否超过区间结束时间,且f_entry为true时存在价格行为
   if(currentTick.time >= range.end_time && range.end_time > 0 && range.f_entry) {
      // 市价第一次突破区间上沿
      if(!range.f_high_breakout && currentTick.ask >= range.high) {
         // 确保订单唯一,不同时存在多个买单
         int cntBuy, cntSell;
         util.countOpenPositions(MagicNum, cntBuy, cntSell);
         if(cntBuy != 0) {
            range.f_high_breakout = true;
            return;
         }

         // 记录已经突破上沿标记
         range.f_high_breakout = true;

         // ONE_SIGNAL模式下 如果突破上沿 就认为已经突破下沿
         if(BreakoutMode == ONE_SIGNAL) range.f_low_breakout = true;

         // 计算止损止盈
         double sl = StopLoss == 0 ? 0 : NormalizeDouble(currentTick.bid - ((range.high - range.low) * StopLoss * 0.01), digits);
         double tp = TakeProfit == 0 ? 0 : NormalizeDouble(currentTick.bid + ((range.high - range.low) * TakeProfit * 0.01), digits);

         // 资金管理计算手数
         double slp = SlParam;
         if(SlType == 4) slp = util.calculateEachEaBalance(MagicNum, initBalance);
         double lotSize = util.calcLots(_Symbol, currentTick.ask, sl, SlType, slp);

         // 开买单
         trade.SetExpertMagicNumber(MagicNum);
         trade.PositionOpen(_Symbol, ORDER_TYPE_BUY, lotSize, currentTick.ask, sl, tp, "LB-" + (string)range.close_time);
      }

      // 市价突破区间下沿
      if(!range.f_low_breakout && currentTick.bid <= range.low) {
         int cntBuy, cntSell;
         util.countOpenPositions(MagicNum, cntBuy, cntSell);
         if(cntSell != 0) {
            range.f_low_breakout = true;
            return;
         }
         // 记录已经突破下沿标记
         range.f_low_breakout = true;
         // 计算止损止盈
         double sl = StopLoss == 0 ? 0 : NormalizeDouble(currentTick.ask + ((range.high - range.low) * StopLoss * 0.01), digits);
         double tp = TakeProfit == 0 ? 0 : NormalizeDouble(currentTick.ask - ((range.high - range.low) * TakeProfit * 0.01), digits);
         // 资金管理计算手数
         double slp = SlParam;
         if(SlType == 4) slp = util.calculateEachEaBalance(MagicNum, initBalance);
         double lotSize = util.calcLots(_Symbol, currentTick.bid, sl, SlType, slp);
         // 开卖单
         trade.SetExpertMagicNumber(MagicNum);
         trade.PositionOpen(_Symbol, ORDER_TYPE_SELL, lotSize, currentTick.bid, sl, tp, "LB-" + (string)range.close_time);

      }
   }
}

//+------------------------------------------------------------------+
//| 关闭所有仓位                                                     |
//+------------------------------------------------------------------+
void closeAllPositions(int magicNum)
{
    int total = PositionsTotal();
    for (int i = total - 1; i >= 0; i--)
    {
        ulong ticket = PositionGetTicket(i);
        if (ticket < 0)
        {
            Print("获取ticket失败");
            return;
        }
        if (!PositionSelectByTicket(ticket))
        {
            Print("选中ticket失败");
            return;
        }
        long magic;
        if (!!PositionGetInteger(POSITION_MAGIC, magic))
        {
            Print("获取魔术号失败");
            return;
        }
        if (magic == magicNum)
        {
            // 获取订单备注信息
            string comment;
            PositionGetString(POSITION_COMMENT, comment);
            // 解析备注中的平仓时间 LB-2024.01.01 21:00:00
            datetime oldCloseTime = StringToTime(getStr(comment, "-"));
            // 如果当前时间未达到平仓时间, 则返回
            if (!(currentTick.time >= oldCloseTime))
                return;
            // 如果达到平仓时间, 打印信息并关闭持仓
            Print("平仓时间到达, 开始平仓:", MagicNum, " - ", currentTick.time, " >= ", oldCloseTime);
            trade.PositionClose(ticket);
            if (trade.ResultRetcode() != TRADE_RETCODE_DONE)
            {
                Print("平仓失败", ticket, " 异常:", (string)trade.ResultRetcode(), " :", trade.ResultRetcodeDescription());
            }
        }
    }
}

// 通过分割符来分割字符串,并返回分割后的第二个元素
string getStr(string str, string step)
{
    // 定义了一个字符串数组result 来保存分割后的结果
    string result[];
    // 调用StringSplit函数进行字符串的分割
    // StringGetCharacter(step,0) 获取的是分割符的第一个字符(通常分割符只有一个字符)
    // 然后用这个分割符对字符串str进行分割, 分割后的结果存入result数组,同时返回分割除了几个字符串
    int k = StringSplit(str,StringGetCharacter(step,0),result);
    // 判断是否至少分割出了一个字符串
    if(k>0){
        // 返回step"-"后面的内容
        return result[1];
    }
    // 如果没有进入上面的if分支, 那么表示输入的字符串str中没有找到分割符,也就是没有进行分割操作,此时就返回NULL
    return NULL;
}