80-20 交易策略

Alexander Puzanov | 29 十二月, 2016


  1. 概论
  2. '80-20' 交易系统
  3. 信号模型
  4. 手工交易指标
  5. 测试 '80-20' 交易策略的智能交易系统
  6. 策略回测
  7. 结论

概论

'80-20' 是出自 Linda Raschke 与 Laurence Connors 合著的书籍 街头智能: 高概率短线交易策略 中所述交易策略 (TS) 之一 的名字。类似于我在 之前文章 中所述的, 作者将其阶段归类为价格测试范围边界。它也侧重于从边界的虚假突破和回滚中获利。但这一次, 我们仅利用前一天的极短线历史间隔来分析价格走势。所获信号的寿命也相对较短, 因为系统用于日内交易。

本文的第一个目标是讲述使用 MQL5 语言开发 '80-20' 交易策略信号模块。然后, 我们将把这个模块连接到本系列前一篇文章中开发的基本交易机器人的略作改动版本。此外, 我们将使用同样的模块来开发手工交易的指标。

如前所述, 文章系列中提供的代码主要针对稍微高级的新进程序员。因此, 除其主要目标, 代码还设计用来帮助从过程编程转移到面向对象编程。代码将不会体现类特征。代之, 它将完全采用更容易掌握的结构来实现。

本文的另一个目标是开发工具, 以便我们能够检查策略是否至今仍然可行, 因为 Raschke 和 Connors 创建它时, 使用的是上个世纪末的市场行为。几次基于最新历史数据的 EA 测试结果在文章结尾处给出。


'80-20' 交易系统

作者名为 George Taylor 的 泰勒交易技术, 以及 Steve Moore 利用计算机进行期货市场分析的工作, 和 Derek Gipson' 的交易经验作为他们自己工作的理论基础。交易策略的本质可以简要描述如下: 如果前一天的开盘价和收盘价位于日线范围相反区域, 则当日开盘与前一天开盘逆向的概率非常高。前一天的开盘价和收盘价应位于范围边界附近。逆转应该从当日开始 (并非在前一天的蜡烛收盘之前)。买入策略规则如下:

1. 确认昨日开盘价处于全日区间的高位 20% 范围内, 而收盘价在全日区间的低位 20% 范围内。

2. 等待...直到当日最低价的至少 5 次 实时报价突破前日最低价

3. 将买入挂单放在昨日区间的下边界

4. 一旦挂单触发, 将其初始止损设置为当日的最低价

5. 使用尾随停止来保护已获盈利

卖单入场规则是类似的, 但前日的柱线应为看涨, 卖单应挂于柱线的上边界, 而止损应该放在当日的最高价。

另一个重要的细节是已收盘的日线柱线的大小。根据 Linda Raschke 的经验, 它应该足够长 - 超过日线柱线的平均长度。不过, 她没有指定计算平均日线范围时应考虑多少根历史天数。

我们还应记住, 此交易策略是专为日内交易而设计的 — 本书中展示的示例使用 M15 图表。

进行布局的信号块和指标所依据的策略如下。您还可以看到几个利用指标操作的结果截图。它们清晰地描绘了与系统规则相应的形态, 以及交易价位与形态的连接关系。

M5 时间帧:

80-20 交易策略形态

形态分析的结果应放置一笔买入挂单。出示在 M1 时间帧上的相应较佳交易价位:

80-20 交易策略形态: 交易价位

在 M5 时间帧上的相反交易方向的类似形态:

80-20 交易策略形态

其交易价位 (M1 时间帧):

80-20 交易策略形态: 交易价位

 

信号模型

我们以加入止盈价位计算为例来描述如何在自定义交易策略中添加新选项。在原始版本中没有这样的价位, 只用尾随停止来平仓。我们令止盈取决于自定义的最小突破级别 (TS_8020_Extremum_Break) — 我们将之乘以 TS_8020_Take_Profit_Ratio 自定义比率。

我们需要以下 fe_Get_Entry_Signal 信号模型的主函数元素: 当前信号状态, 经过计算的入场/离场价位 (止损和止盈), 以及昨日的范围边界。所有价位都通过与传递给函数的变量相链接来接收, 而信号的返回状态使用前一篇文章中的选项列表:

enum ENUM_ENTRY_SIGNAL {  // 入场信号列表
  ENTRY_BUY,              // 买入信号
  ENTRY_SELL,             // 卖出信号
  ENTRY_NONE,             // 无信号
  ENTRY_UNKNOWN           // 未定义状态
};

ENUM_ENTRY_SIGNAL fe_Get_Entry_Signal( // D1 双蜡烛形态分析
  datetime  t_Time,          // 当前时间
  double&    d_Entry_Level,  // 入场价位 (链接到变量)
  double&    d_SL,           // 止损位 (链接到变量)
  double&    d_TP,           // 止盈位 (链接到变量)
  double&    d_Range_High,   // 形态第一根柱线的最高价 (链接到变量)
  double&    d_Range_Low     // 形态第一根柱线的最低价 (链接到变量)
) {}

为了检测信号, 我们需要分析 D1 时间帧的最后两根柱线。让我们从第一根开始 — 如果它不符合交易策略标准, 没有必要检查第二根柱线。有两条标准:

1. 柱线大小 (最高价和最低价之间的差值) 应超过最后 XX 日的均值 (由 TS_8020_D1_Average_Period 设置)

2. 柱线的开盘价和收盘价应位于柱线区间相反的 20% 范围内

如果满足这些条件, 则应保存最高价和最低价以供进一步使用。由于第一根柱线参数在全日内不可改变, 所以在每次函数调用时检查它们没有意义。我们来把它们存储在静态变量中:

// 自定义设置
input uint  TS_8020_D1_Average_Period = 20;  // 80-20: 计算日线均值的天数
input uint  TS_8020_Extremum_Break = 50;     // 80-20: 昨日极值突破的最小点数


static ENUM_ENTRY_SIGNAL se_Possible_Signal = ENTRY_UNKNOWN; // 形态的首根柱线信号方向
static double
  // 在即时报价之间存储计算价位的变量
  sd_Entry_Level = 0,
  sd_SL = 0, sd_TP = 0,
  sd_Range_High = 0, sd_Range_Low = 0
;


// 检查形态 D1 图表上的首根柱线:
if(se_Possible_Signal == ENTRY_UNKNOWN) { // 未知
  st_Last_D1_Bar = t_Curr_D1_Bar; // 首根柱线当日无变化
  
  // 日线范围均值
  double d_Average_Bar_Range = fd_Average_Bar_Range(TS_8020_D1_Average_Period, PERIOD_D1, t_Time);
  
  if(ma_Rates[0].high — ma_Rates[0].low <= d_Average_Bar_Range) {
    // 首根柱线长度不够
    se_Possible_Signal = ENTRY_NONE; // 意味着当日无信号
    return(se_Possible_Signal);
  }
  
  double d_20_Percents = 0.2 * (ma_Rates[0].high — ma_Rates[0].low); // 昨日范围的 20%
  if((
      // 阴线:
      ma_Rates[0].open > ma_Rates[0].high — d_20_Percents // 柱线开盘价位于上部 20% 之内
      &&
      ma_Rates[0].close < ma_Rates[0].low + d_20_Percents // 收盘价在柱线下部 20% 之内
    ) || (
      // 阳线:
      ma_Rates[0].close > ma_Rates[0].high — d_20_Percents // 柱线收盘价在上部 20% 之内
      &&
      ma_Rates[0].open < ma_Rates[0].low + d_20_Percents // 开盘价在下部 20% 之内
  )) {
    // 首根柱线对应条件
    // 为形态的首根柱线定义当日交易方向:
    se_Possible_Signal = ma_Rates[0].open > ma_Rates[0].close ?ENTRY_BUY : ENTRY_SELL;
    // 入场价位:
    sd_Entry_Level = d_Entry_Level = se_Possible_Signal == ENTRY_BUY ?ma_Rates[0].low : ma_Rates[0].high;
    // 形态首根柱线的范围边界:
    sd_Range_High = d_Range_High = ma_Rates[0].high;
    sd_Range_Low = d_Range_Low = ma_Rates[0].low;
  } else {
    // 首根柱线的开盘价/收盘价与条件不匹配
    se_Possible_Signal = ENTRY_NONE; // 意即当日无信号
    return(se_Possible_Signal);
  }
}

用来定义指定时间帧上, 从指定时间开始至指定柱线数量内的平均柱线范围的函数列表:

double fd_Average_Bar_Range(    // 计算平局柱线大小
  int i_Bars_Limit,             // 要考虑的柱线数量
  ENUM_TIMEFRAMES e_TF = PERIOD_CURRENT,  // 柱线时间帧
  datetime t_Time = WRONG_VALUE  // 当开始计算
) {
  double d_Average_Range = 0; // 数值汇总的变量
  if(i_Bars_Limit < 1) return(d_Average_Range);
  
  MqlRates ma_Rates[]; // 柱线信息数组
  
  // 从指定历史间隔获取柱线信息:
  if(t_Time == WRONG_VALUE) t_Time = TimeCurrent();
  int i_Price_Bars = CopyRates(_Symbol, e_TF, t_Time, i_Bars_Limit, ma_Rates);
  
  if(i_Price_Bars == WRONG_VALUE) { // 处理 CopyRates 函数错误
    if(Log_Level > LOG_LEVEL_NONE) PrintFormat("%s: CopyRates: error #%u", __FUNCTION__, _LastError);
    return(d_Average_Range);
  }
  
  if(i_Price_Bars < i_Bars_Limit) { // CopyRates 函数未能获取请求的数据量
    if(Log_Level > LOG_LEVEL_NONE) PrintFormat("%s: CopyRates: 拷贝 %u 根柱线 %u", __FUNCTION__, i_Price_Bars, i_Bars_Limit);
  }
  
  // 范围之和:
  int i_Bar = i_Price_Bars;
  while(i_Bar-- > 0)
    d_Average_Range += ma_Rates[i_Bar].high — ma_Rates[i_Bar].low;
  
  // 均值:
  return(d_Average_Range / double(i_Price_Bars));
}

形态的第二根 (当前) 柱线仅有一个标准 — 昨日范围边界的突破不应小于在设定中 (TS_8020_Extremum_Break) 指定的值。一旦触及此价位, 将出现放置挂单的信号:

// 检查在 D1 时间帧内形态的第二根 (当前) 柱线:
if(se_Possible_Signal == ENTRY_BUY) {
  sd_SL = d_SL = ma_Rates[1].low; // 止损 — 当日最高价
  if(TS_8020_Take_Profit_Ratio > 0) sd_TP = d_TP = d_Entry_Level + _Point * TS_8020_Extremum_Break * TS_8020_Take_Profit_Ratio; // 止盈
  return(
    // 是否向下突破清晰可见?
    ma_Rates[1].close < ma_Rates[0].low — _Point * TS_8020_Extremum_Break ?
    ENTRY_BUY : ENTRY_NONE
  );
}

if(se_Possible_Signal == ENTRY_SELL) {
  sd_SL = d_SL = ma_Rates[1].high; // 止损 — 当日最低价
  if(TS_8020_Take_Profit_Ratio > 0) sd_TP = d_TP = d_Entry_Level — _Point * TS_8020_Extremum_Break * TS_8020_Take_Profit_Ratio; // 止盈
  return(
    // 是否向上突破清晰可见?
    ma_Rates[1].close > ma_Rates[0].high + _Point * TS_8020_Extremum_Break ?
    ENTRY_SELL : ENTRY_NONE
  );
}

保存上述两个函数 (fe_Get_Entry_Signalfd_Average_Bar_Range) 以及与接收信号相关的自定义设置至 mqh 库文件。完整的列表附加于下。我们将文件命名为 Signal_80-20.mqh, 并将其放在终端数据文件夹 (MQL5\Include\Expert\Signal)。 


手工交易指标

就像这款 EA, 指标使用如上描述的信号模块。指标应通知交易者接收挂单信号并提供计算过的价位 — 订单放置, 止盈和止损价位。用户可选择通知方法 — 标准弹出窗口, 邮件警报或是推送通知。可以一次选择所有, 或任何您喜欢的组合。

指标的另一目标是根据 '80-20' 交易策略提供交易历史布局。指标加亮与系统标准对应的日线, 并绘制计算出的交易价位。价位线显示出的状况随时间变化。为了更明确, 我们按如下来做: 当价格触及信号线时, 交易价位被替换为挂单。当挂单被激活时, 信号线被替换为止盈线和止损线。当价格触及止盈或止损之一 (定单被平仓) 时, 这些线被中断。这种布局令交易系统规则的效率评估, 以及哪些定义可改进变得更容易。

让我们从声明缓存区和它们的显示参数开始。首先, 我们需要生命两个具有垂直填充区域的缓存区 (DRAW_FILLING)。第一个是为了加亮昨日的整根柱线, 另一个是为了加亮交易策略用到的上部 20% 和下部 20% 之间的内部区域。之后, 为多色信号线和挂单价位声明两个缓冲区 (DRAW_COLOR_LINE)。它们的颜色取决于交易方向。还有其它两个价位 (止盈和止损), 它们的颜色保持不变 (DRAW_LINE) — 它们将使用终端分配的标准颜色。所有选定的显示类型, 除了一条简单的线, 每条需要两个缓冲区, 因此代码如下所示:

#property indicator_chart_window
#property indicator_buffers  10
#property indicator_plots    6

#property indicator_label1  "形态的第一根柱线"
#property indicator_type1   DRAW_FILLING
#property indicator_color1  clrDeepPink, clrDodgerBlue
#property indicator_width1  1

#property indicator_label2  "形态的第一根柱线"
#property indicator_type2   DRAW_FILLING
#property indicator_color2  clrDeepPink, clrDodgerBlue
#property indicator_width2  1

#property indicator_label3  "信号价位"
#property indicator_type3   DRAW_COLOR_LINE
#property indicator_style3  STYLE_SOLID
#property indicator_color3  clrDeepPink, clrDodgerBlue
#property indicator_width3  2

#property indicator_label4  "入场价位"
#property indicator_type4   DRAW_COLOR_LINE
#property indicator_style4  STYLE_DASHDOT
#property indicator_color4  clrDeepPink, clrDodgerBlue
#property indicator_width4  2

#property indicator_label5  "止损"
#property indicator_type5   DRAW_LINE
#property indicator_style5  STYLE_DASHDOTDOT
#property indicator_color5  clrCrimson
#property indicator_width5  1

#property indicator_label6  "止盈"
#property indicator_type6   DRAW_LINE
#property indicator_style6  STYLE_DASHDOTDOT
#property indicator_color6  clrLime
#property indicator_width6  1

让我们为交易者提供一些选项, 禁止填充日线形态第一根柱线, 选择信号通知, 和限制历史布局深度。来自信号模块的所有交易系统设置也包括在此。为此, 我们需要初步枚举模块中使用的变量, 即使它们中的一些仅在 EA 中使用, 而在指标中不需要:

#include <Expert\Signal\Signal_80-20.mqh> // '80-20' 交易策略信号模块

input bool    Show_Outer = true;      // 形态的第一根柱线: 显示完整范围?
input bool    Show_Inner = true;      // 形态的第一根柱线: 显示内部区域?
input bool    Alert_Popup = true;     // 警报显示为弹出窗口?
input bool    Alert_Email = false;    // 警报发送邮件?
input string  Alert_Email_Subj = "";  // 警报邮件题头
input bool    Alert_Push = true;      // 警报发送为一条推送通知?

input uint  Bars_Limit = 2000;  // 历史布局深度 (当前时间帧的柱线数)



ENUM_LOG_LEVEL  Log_Level = LOG_LEVEL_NONE;  // 日志模式
double
  buff_1st_Bar_Outer[], buff_1st_Bar_Outer_Zero[], // 绘制形态第一根柱线的缓存区
  buff_1st_Bar_Inner[], buff_1st_Bar_Inner_Zero[], // 绘制形态第一根柱线内部 60% 的缓存区
  buff_Signal[], buff_Signal_Color[], // 信号线缓存区
  buff_Entry[], buff_Entry_Color[], // 挂单线缓存区
  buff_SL[], buff_TP[], // 止损位和止盈位缓存区
  gd_Extremum_Break = 0 // TS_8020_Extremum_Break 为品种价格
;
int
  gi_D1_Average_Period = 1, // TS_8020_D1_Average_Period 的调整值
  gi_Min_Bars = WRONG_VALUE // 重新计算所需的最小柱线数量
;



int OnInit() {
  // 检查 TS_8020_D1_Average_Period 参数:
  gi_D1_Average_Period = int(fmin(1, TS_8020_D1_Average_Period));
  // 转换品种价格的点数:
  gd_Extremum_Break = TS_8020_Extremum_Break * _Point;
  // 重新计算所需的最小柱线数量 = 当前时间帧的一日内柱线数
  gi_Min_Bars = int(86400 / PeriodSeconds());
  
  // 指标缓存区的目标:
  
  // 第一根柱线完整范围的长方形
  SetIndexBuffer(0, buff_1st_Bar_Outer, INDICATOR_DATA);
    PlotIndexSetDouble(0, PLOT_EMPTY_VALUE, 0);
  SetIndexBuffer(1, buff_1st_Bar_Outer_Zero, INDICATOR_DATA);
  
  // 第一根柱线内部区域长方形
  SetIndexBuffer(2, buff_1st_Bar_Inner, INDICATOR_DATA);
    PlotIndexSetDouble(1, PLOT_EMPTY_VALUE, 0);
  SetIndexBuffer(3, buff_1st_Bar_Inner_Zero, INDICATOR_DATA);
  
  // 信号线
  SetIndexBuffer(4, buff_Signal, INDICATOR_DATA);
    PlotIndexSetDouble(2, PLOT_EMPTY_VALUE, 0);
  SetIndexBuffer(5, buff_Signal_Color, INDICATOR_COLOR_INDEX);
  
  // 挂单放置线
  SetIndexBuffer(6, buff_Entry, INDICATOR_DATA);
    PlotIndexSetDouble(3, PLOT_EMPTY_VALUE, 0);
  SetIndexBuffer(7, buff_Entry_Color, INDICATOR_COLOR_INDEX);
  
  // 止损线
  SetIndexBuffer(8, buff_SL, INDICATOR_DATA);
    PlotIndexSetDouble(4, PLOT_EMPTY_VALUE, 0);
  
  // 止盈线
  SetIndexBuffer(9, buff_TP, INDICATOR_DATA);
    PlotIndexSetDouble(5, PLOT_EMPTY_VALUE, 0);
  
  IndicatorSetInteger(INDICATOR_DIGITS, _Digits);
  IndicatorSetString(INDICATOR_SHORTNAME, "80-20 TS");
  
  return(INIT_SUCCEEDED);
}

将主程序的代码内置到 OnCalculate 函数中 — 分配循环遍历当前时间帧从过去到将来的柱线, 使用来自信号模块的函数搜索信号。声明必要变量并使用初始值初始化。对于首次计算的循环柱线数, 我们考虑由用户定义历史深度限制 (Bars_Limit)。对于后续调用, 重新计算当日的所有柱线 (而非最后一根柱线), 因为两根柱线形态实际上属于 D1 图表, 而与当前时间帧无关。

此外, 我们应防止所谓的幻像: 如果我们在重新初始化期间没有执行强制指标缓冲区清除, 则切换时间帧或品种时屏幕上相关的填充区域不再保留。缓冲区清除应在指标初始化之后绑定到第一次 OnCalculate 函数调用。然而, 标准的 prev_calculated 变量不足以定义调用是否是第一次, 因为不仅在第一次函数调用期间它可能为零, 而且 "当校验和变化时" 也为零。让我们花一点时间来正确解决这个问题, 这要通过创建不受 prev_calculated 变量为零影响的结构。结构用来存储和处理指标中的常用数据:

- 首先启动 OnCalculate 函数的标志;

- 当校验和变化时已计算柱线的计数器不会设置为零;

- 校验和变化标志;

- 柱线开始标志;

- 当前柱线开始时间。

组合所有这些数据的结构将在全局层面声明。它能够从任何内置或自定义函数中收集或呈现数据。我们把这个结构命名为 Brownie。它可以放在指标代码的末尾。在此还会声明一个名为 go_Brownie 的单一全局类型结构对象:

struct BROWNIE {                // Brownie: 在全局层面存储和处理数据的结构
  datetime  t_Last_Bar_Time;    // 最后处理过的柱线时间
  int        i_Prew_Calculated; // 已计算柱线的数量
  bool      b_First_Run;        // 首次启动标志
  bool      b_History_Updated;  // 历史更新标志
  bool      b_Is_New_Bar;       // 新柱线开盘标志
  
  BROWNIE() { // 构造器
    // 省缺值:
    t_Last_Bar_Time = 0;
    i_Prew_Calculated = WRONG_VALUE;
    b_First_Run = b_Is_New_Bar = true;
    b_History_Updated = false;
  }
  
  void f_Reset(bool b_Reset_First_Run = true) { // 变量设置为零
    // 省缺值:
    t_Last_Bar_Time = 0;
    i_Prew_Calculated = WRONG_VALUE;
    if(b_Reset_First_Run) b_First_Run = true; // 如果允许则设为零
    b_Is_New_Bar = true;
    b_History_Updated = false;
  }
  
  void f_Update(int i_New_Prew_Calculated = WRONG_VALUE) { // 更新变量
    // OnCalculate 内置函数首次调用标志
    if(b_First_Run && i_Prew_Calculated > 0) b_First_Run = false;
    
    // 新柱线?
    datetime t_This_Bar_Time = TimeCurrent() - TimeCurrent() % PeriodSeconds();
    b_Is_New_Bar = t_Last_Bar_Time == t_This_Bar_Time;
    
    // 更新当前柱线时间?
    if(b_Is_New_Bar) t_Last_Bar_Time = t_This_Bar_Time;
    
    if(i_New_Prew_Calculated > -1) {
      // 历史数据有任何变化?
      b_History_Updated = i_New_Prew_Calculated == 0 && i_Prew_Calculated > WRONG_VALUE;
      
      // 若首次调用 OnCalculate 的情况, 使用 prew_calculated
      if(i_Prew_Calculated == WRONG_VALUE) i_Prew_Calculated = i_New_Prew_Calculated;
      // 或历史数据无更新
      else if(i_New_Prew_Calculated > 0) i_Prew_Calculated = i_New_Prew_Calculated;
    }
  }
};
BROWNIE go_Brownie;

我们通知指标逆初事件的 Brownie:

void OnDeinit(const int reason) {
  go_Brownie.f_Reset(); // 通知 Brownie
}

若有必要, 如果自定义函数或类需要价格、交易量或是当前柱线的点差值 (开盘价, 最高价, 最低价, 收盘价, 即时报价交易量, 交易量, 点差), 由 Brownie 存储的数据量可以进行扩展。使用来自 OnCalculate 函数的现成数据, 并通过 Brownie 替代时间序列复制函数 (CopyOpen, CopyHigh 或 CopyRates 等等) 来传递它们则更为便利— 这可节省 CPU 资源, 并消除了为这些语言函数的错误分配处理的必要性。

让我们返回主指标函数。使用如下所示 go_Brownie 结构声明变量并准备数组:

go_Brownie.f_Update(prev_calculated); // 为 Brownie 投送数据

int
  i_Period_Bar = 0, // 辅助计数器
  i_Current_TF_Bar = rates_total - int(Bars_Limit) // 当前时间帧循环开始的柱线索引
;
static datetime st_Last_D1_Bar = 0; // 一对 D1 时间帧柱线的最后一根已处理时间 (形态的第二根柱线)
static int si_1st_Bar_of_Day = 0; // 当日首根柱线的索引

if(go_Brownie.b_First_Run) { // 如果这是首次启动
  // 在重新初始化期间清除缓存区:
  ArrayInitialize(buff_1st_Bar_Inner, 0); ArrayInitialize(buff_1st_Bar_Inner_Zero, 0);
  ArrayInitialize(buff_1st_Bar_Outer, 0); ArrayInitialize(buff_1st_Bar_Outer_Zero, 0);
  ArrayInitialize(buff_Entry, 0); ArrayInitialize(buff_Entry_Color, 0);
  ArrayInitialize(buff_Signal, 0); ArrayInitialize(buff_Signal_Color, 0);
  ArrayInitialize(buff_TP, 0);
  ArrayInitialize(buff_SL, 0);
  st_Last_D1_Bar = 0;
  si_1st_Bar_of_Day = 0;
} else { // 这不是首次启动
  datetime t_Time = TimeCurrent();
  // 重计算最小深度 - 从当日开始:
  i_Current_TF_Bar = rates_total - Bars(_Symbol, PERIOD_CURRENT, t_Time - t_Time % 86400, t_Time) - 1;
}
ENUM_ENTRY_SIGNAL e_Signal = ENTRY_UNKNOWN; // 信号
double
  d_SL = WRONG_VALUE, // 止损位
  d_TP = WRONG_VALUE, // 止盈位
  d_Entry_Level = WRONG_VALUE, // 入场价位
  d_Range_High = WRONG_VALUE, d_Range_Low = WRONG_VALUE // borders 形态第一根柱线范围的边界
;
datetime
  t_Curr_D1_Bar = 0, // 当前 D1 柱线的时间 (形态的第二根柱线)
  t_D1_Bar_To_Fill = 0 // 要填充的 D1 柱线时间 (形态的第一根柱线)
;

// 确保重新计算的初始柱线索引在可接受的范围内:
i_Current_TF_Bar = int(fmax(0, fmin(i_Current_TF_Bar, rates_total - gi_Min_Bars)));

while(++i_Current_TF_Bar < rates_total && !IsStopped()) { // 遍历当前时间帧柱线
  // 主程序循环位于此处
}

遍历当前时间帧柱线时检查信号的存在:

e_Signal = fe_Get_Entry_Signal(Time[i_Current_TF_Bar], d_Entry_Level, d_SL, d_TP, d_Range_High, d_Range_Low);
if(e_Signal > 1) continue; // 柱线所属交易日内无信号

如果在新交易日的第一根柱线上存在信号, 则应填充上一个交易日所有柱线的范围。变量 t_D1_Bar_To_Fill 的日期类型值作为标志。如果它等于 WRONG_VALUE, 则不需要在此柱线上填充。信号线应同样开始于第一根柱线, 但让我们将它延伸到前一日的最后一根柱线, 以便获得更好的布局感观。由于信号线的计算, 以及多空柱线和填充颜色均有所不同, 我们要打造两个相似的块:

t_Curr_D1_Bar = Time[i_Current_TF_Bar] — Time[i_Current_TF_Bar] % 86400; // 柱线所属日的开始
if(st_Last_D1_Bar < t_Curr_D1_Bar) { // 这是新一日的柱线
  t_D1_Bar_To_Fill = Time[i_Current_TF_Bar — 1] — Time[i_Current_TF_Bar — 1] % 86400;
  si_1st_Bar_of_Day = i_Current_TF_Bar;
}
else t_D1_Bar_To_Fill = WRONG_VALUE; // 昨日柱线, 没有新的填充需求
st_Last_D1_Bar = t_Curr_D1_Bar; // 记忆

if(t_D1_Bar_To_Fill != WRONG_VALUE) { // 新的 D1 柱线
  // 填充昨日的 D1 柱线:
  i_Period_Bar = i_Current_TF_Bar;
  if(d_Entry_Level < d_Range_High) { // D1 阴线
    if(Show_Outer) while(--i_Period_Bar > 0) { // 全部范围
      if(Time[i_Period_Bar] < t_D1_Bar_To_Fill) break;
      buff_1st_Bar_Outer_Zero[i_Period_Bar] = d_Range_Low;
      buff_1st_Bar_Outer[i_Period_Bar] = d_Range_High;
    }
    if(Show_Inner) { // 内部区域
      i_Period_Bar = i_Current_TF_Bar;
      while(--i_Period_Bar > 0) {
        if(Time[i_Period_Bar] < t_D1_Bar_To_Fill) break;
        buff_1st_Bar_Inner_Zero[i_Period_Bar] = d_Range_Low + 0.2 * (d_Range_High — d_Range_Low);
        buff_1st_Bar_Inner[i_Period_Bar] = d_Range_High — 0.2 * (d_Range_High — d_Range_Low);
      }
    }
    // 信号线的开始 — 从昨日的最后一根柱线
    buff_Signal[i_Current_TF_Bar] = buff_Signal[i_Current_TF_Bar — 1] = d_Range_Low — gd_Extremum_Break;
    buff_Signal_Color[i_Current_TF_Bar] = buff_Signal_Color[i_Current_TF_Bar — 1] = 0;
  } else { // D1 阳线
    if(Show_Outer) while(--i_Period_Bar > 0) { // 全部范围
      if(Time[i_Period_Bar] < t_D1_Bar_To_Fill) break;
      buff_1st_Bar_Outer_Zero[i_Period_Bar] = d_Range_High;
      buff_1st_Bar_Outer[i_Period_Bar] = d_Range_Low;
    }
    if(Show_Inner) { // 内部区域
      i_Period_Bar = i_Current_TF_Bar;
      while(--i_Period_Bar > 0) {
        if(Time[i_Period_Bar] < t_D1_Bar_To_Fill) break;
        buff_1st_Bar_Inner_Zero[i_Period_Bar] = d_Range_High — 0.2 * (d_Range_High — d_Range_Low);
        buff_1st_Bar_Inner[i_Period_Bar] = d_Range_Low + 0.2 * (d_Range_High — d_Range_Low);
      }
    }
    // 信号线的开始 — 从昨日的最后一根柱线
    buff_Signal[i_Current_TF_Bar] = buff_Signal[i_Current_TF_Bar — 1] = d_Range_High + gd_Extremum_Break;
    buff_Signal_Color[i_Current_TF_Bar] = buff_Signal_Color[i_Current_TF_Bar — 1] = 1;
  }
} else continue;

所有剩余的布局线将在当前时间帧的柱线迭代循环内绘制。如上提及, 信号线应在价格触及它时于柱线上结束。挂单线应在同一根柱线上开始, 并于柱线收盘时结束, 它应与价格产生联系。止盈和止损线也应在同一根柱线。形态的布局在柱线上完成, 价格会触及其一:

// 信号线依旧与柱线交叉:
i_Period_Bar = i_Current_TF_Bar;
if(d_Entry_Level < d_Range_High) { // D1 阴线
  while(++i_Period_Bar < rates_total) {
    if(Time[i_Period_Bar] > t_Curr_D1_Bar + 86399) break;
    buff_Signal[i_Period_Bar] = d_Range_Low — gd_Extremum_Break;
    buff_Signal_Color[i_Period_Bar] = 0;
    if(d_Range_Low — gd_Extremum_Break >= Low[i_Period_Bar]) break;
  }
} else { // D1 阳线
  while(++i_Period_Bar < rates_total) {
    if(Time[i_Period_Bar] > t_Curr_D1_Bar + 86399) break;
    buff_Signal[i_Period_Bar] = d_Range_High + gd_Extremum_Break;
    buff_Signal_Color[i_Period_Bar] = 1;
    if(d_Range_High + gd_Extremum_Break <= High[i_Period_Bar]) break;
  }
}

// 入场线依旧与柱线交叉:
if(d_Entry_Level < d_Range_High) { // D1 阴线
  while(++i_Period_Bar < rates_total) {
    if(Time[i_Period_Bar] > t_Curr_D1_Bar + 86399) break;
    buff_Entry[i_Period_Bar] = d_Range_Low;
    buff_Entry_Color[i_Period_Bar] = 0;
    if(d_Range_Low <= High[i_Period_Bar]) {
      if(buff_Entry[i_Period_Bar — 1] == 0.) {
        // 在单根柱线上开始并结束, 延伸一根柱线到过去
        buff_Entry[i_Period_Bar — 1] = d_Range_Low;
        buff_Entry_Color[i_Period_Bar — 1] = 0;
      }
      break;
    }
  }
} else { // D1 阳线
  while(++i_Period_Bar < rates_total) {
    if(Time[i_Period_Bar] > t_Curr_D1_Bar + 86399) break;
    buff_Entry[i_Period_Bar] = d_Range_High;
    buff_Entry_Color[i_Period_Bar] = 1;
    if(d_Range_High >= Low[i_Period_Bar]) {
      if(buff_Entry[i_Period_Bar — 1] == 0.) {
        // 在单根柱线上开始并结束, 延伸一根柱线到过去
        buff_Entry[i_Period_Bar — 1] = d_Range_High;
        buff_Entry_Color[i_Period_Bar — 1] = 1;
      }
      break;
    }
  }
}

// 止盈和止损线之一依旧与柱线交叉:
if(d_Entry_Level < d_Range_High) { // D1 阴线
  // 止损线等于日内最低价:
  d_SL = Low[ArrayMinimum(Low, si_1st_Bar_of_Day, i_Period_Bar — si_1st_Bar_of_Day)];
  
  while(++i_Period_Bar < rates_total) {
    if(Time[i_Period_Bar] > t_Curr_D1_Bar + 86399) break;
    buff_SL[i_Period_Bar] = d_SL;
    buff_TP[i_Period_Bar] = d_TP;
    if(d_TP <= High[i_Period_Bar] || d_SL >= Low[i_Period_Bar]) {
      if(buff_SL[i_Period_Bar — 1] == 0.) {
        // 在单根柱线上开始并结束, 延伸一根柱线到过去
        buff_SL[i_Period_Bar — 1] = d_SL;
        buff_TP[i_Period_Bar — 1] = d_TP;
      }
      break;
    }
  }
} else { // D1 阳线
  // 止损线等于日内最高价:
  d_SL = High[ArrayMaximum(High, si_1st_Bar_of_Day, i_Period_Bar — si_1st_Bar_of_Day)];
  
  while(++i_Period_Bar < rates_total) {
    if(Time[i_Period_Bar] > t_Curr_D1_Bar + 86399) break;
    buff_SL[i_Period_Bar] = d_SL;
    buff_TP[i_Period_Bar] = d_TP;
    if(d_SL <= High[i_Period_Bar] || d_TP >= Low[i_Period_Bar]) {
      if(buff_SL[i_Period_Bar — 1] == 0.) {
        // 在单根柱线上开始并结束, 延伸一根柱线到过去
        buff_SL[i_Period_Bar — 1] = d_SL;
        buff_TP[i_Period_Bar — 1] = d_TP;
      }
      break;
    }
  }
}

我们在循环之外放置调用 f_Do_Alert 信号通知函数的代码。事实上, 与指标内涉及的相比, 它的机会略微更广 — 函数能够处理音频文件, 意味着此选项可以添加到自定义设置。可为买入和卖出信号选择单独文件的能力。函数列表:

void f_Do_Alert(                  // 搜索信号和通知的函数
  string  s_Message,              // 警报消息
  bool    b_Alert = true,         // 显示弹出窗口?
  bool    b_Sound = false,        // 播放音频文件?
  bool    b_Email = false,        // 发送邮件?
  bool    b_Notification = false, // 发送推送通知?
  string  s_Email_Subject = "",   // 邮件标题
  string  s_Sound = "alert.wav"   // 音频文件
) {
  static string ss_Prev_Message = "三是静音"; // 之前的警报消息
  static datetime st_Prev_Time; // 之前的警报柱线时间
  datetime t_This_Bar_Time = TimeCurrent() — PeriodSeconds() % PeriodSeconds(); // 当前柱线时间
  
  if(ss_Prev_Message != s_Message || st_Prev_Time != t_This_Bar_Time) {
    // 另一个 和/或 第一次
    
    // 记忆:
    ss_Prev_Message = s_Message;
    st_Prev_Time = t_This_Bar_Time;
    
    // 发自消息串:
    s_Message = StringFormat("%s | %s | %s | %s",
      TimeToString(TimeLocal(), TIME_SECONDS), // 本地时间
      _Symbol, // 品种
      StringSubstr(EnumToString(ENUM_TIMEFRAMES(_Period)), 7), // 时间帧
      s_Message // 消息
    );
    
    // 激活通知信号:
    if(b_Alert) Alert(s_Message);
    if(b_Email) SendMail(s_Email_Subject + " " + _Symbol, s_Message);
    if(b_Notification) SendNotification(s_Message);
    if(b_Sound) PlaySound(s_Sound);
  }
}

在 OnCalculate 事件处理程序完成之前检查是否需要调用函数, 并在程序主体中形成文本的代码:

// 警报
i_Period_Bar = rates_total — 1; // 当前柱线

if(Alert_Popup + Alert_Email + Alert_Push == 0) return(rates_total); // 禁用所有
if(buff_Signal[i_Period_Bar] == 0) return(rates_total); // 尚未捕获 (或已有)
if(
  buff_Signal[i_Period_Bar] > High[i_Period_Bar]
  ||
  buff_Signal[i_Period_Bar] < Low[i_Period_Bar]
) return(rates_total); // 未触及信号线

// 消息文本:
string s_Message = StringFormat("交易策略 80-20: 需要 %s @ %s, 止盈: %s, 止损: %s",
  buff_Signal_Color[i_Period_Bar] > 0 ?"BuyStop" : "SellStop",
  DoubleToString(d_Entry_Level, _Digits),
  DoubleToString(d_TP, _Digits),
  DoubleToString(d_SL, _Digits)
);
// 通知:
f_Do_Alert(s_Message, Alert_Popup, false, Alert_Email, Alert_Push, Alert_Email_Subj);

return(rates_total); // 完成 OnCalculate 操作

指标的完整源代码可在附件 (TS_80-20.mq5) 中找到。依据系统的交易布局在分钟图上观看最佳。

请注意, 指标使用柱线数据, 而非柱线内的即时报价序列。这意味着如果价格在单根柱线上与多条布局线交叉 (例如, 止盈线和止损线), 则您无法定义哪些布局线会先被交叉。另一个不确定性源于起始和结束线不能重合的事实。否则, 由 DRAW_LINE 和 DRAW_COLOR_LINE 类型缓冲区形成的线用户不可见。这些特点降低了布局精度, 但仍然相当清楚。


测试 '80-20' 交易策略的智能交易系统

用来测试策略的基础 EA 出自书籍 — 街头智能: 高概率短线交易策略首篇文章 的描述。我们在其中插入了两个明显改变。首先, 信号模块将在指标中使用, 也意味着它在其中进行交易价位计算是合理的。我们已经如此做了。除了信号状态, fe_Get_Entry_Signal 函数返回订单放置, 止损和止盈价位。因此, 我们从前面的 EA 版本中删除代码的适当部分, 添加用来从函数接收价位的变量, 并编辑函数调用自己。新、旧代码块的列表可在附加文件 (字符串 128-141) 中找到。

另一些显著的添加进基础 EA 的代码是出于这样的事实, 即不像前两, 这个交易策略处理短线趋势。它假定回滚每天发生一次, 并且不太可能重复。这意味着机器人只得入场一次, 并在所有剩余时间里放弃存在的信号, 直到第二天。最简单的实现方法是使用特殊标志 — 在程序内存中定义布尔型的静态或全局变量。但若是 EA 操作因一些原因而中断 (终端关闭, EA 从图表上移除, 等等), 标志也会丢失。因此, 我们应有能力检查今天的信号是否由昨日激活了。为此, 我们可以分析今天的交易历史, 或者将最后一次入场的日期存储在终端全局变量中, 而不是在程序中。我们使用第二个选项, 因为它更容易实现。

为用户提供管理 "每天入场一次" 选项的能力, 并为机器人的每个启动版本设置 ID — 这需要使用终端层面的全局变量:

input bool  One_Trade = false;    // 每日一单
input uint  Magic_Number = 2016;  // EA 魔幻数字

我们将必要的变量添加到程序的全局变量定义块中, 以实现 "每天入场一次" 选项。在 OnInit 函数中初始化它们:

string
  gs_Prefix // 全局变量的标识符 (超级)
;
bool
  gb_Position_Today = false,
  gb_Pending_Today = false
;

int OnInit() {

...

  // 创建全局变量名的前缀 (超级):
  gs_Prefix = StringFormat("SSB %s %u %s", _Symbol, Magic_Number, MQLInfoInteger(MQL_TESTER) ?"t " : "");
  
  // 今日机器人是否已开单?
  gb_Position_Today = int(GlobalVariableGet(gs_Prefix + "Last_Position_Date")) == TimeCurrent() — TimeCurrent() % 86400;
  gb_Pending_Today = int(GlobalVariableGet(gs_Prefix + "Last_Pending_Date")) == TimeCurrent() — TimeCurrent() % 86400;

...
}

在此机器人读取全局变量值, 并将写入的时间与当日开始时间进行比较, 从而定义今天的信号是否已经过处理。时间会写在两个位置的变量里 — 让我们将相应的块添加到挂单安装代码 (添加加亮):

if(i_Try != -10) { // 放置挂单失败
  if(Log_Level > LOG_LEVEL_NONE) Print("放置挂单错误");
  // 距当前价位距离不足 :(
  if(Log_Level > LOG_LEVEL_ERR)
    PrintFormat("挂单不可放置在 %s 价位。供给价: %s 采购价: %s 停止位: %s",
      DoubleToString(d_Entry_Level, _Digits),
      DoubleToString(go_Tick.bid, _Digits),
      DoubleToString(go_Tick.ask, _Digits),
      DoubleToString(gd_Stop_Level, _Digits)
    );
} else { // 管理
  // 更新标志:
  GlobalVariableSet( // 终端的全局变量
    gs_Prefix + "Last_Pending_Date",
    TimeCurrent() — TimeCurrent() % 86400
  );
  gb_Pending_Today = true; // 程序的全局变量
}

  第二块放在定义新开仓代码之后:

if(PositionSelect(_Symbol)) { // 有开仓
        if(PositionGetDouble(POSITION_SL) == 0.) { // 新开仓
                
                if(!gb_Position_Today) { // 这是当日第一笔开仓
                        // 更新标志:
                        GlobalVariableSet( // 终端的全局变量
                                gs_Prefix + "Last_Position_Date",
                                TimeCurrent() — TimeCurrent() % 86400
                        );
                        gb_Position_Today = true; // 程序的全局变量
                }
...

这些是以前 EA 版本代码中唯一的重大变化。新版本的最终源代码在下边附加。

 

策略回测

为了说明交易系统的可行性, 其作者使用自上个世纪末的图表来检测形态。因此, 我们需要检查其在当今市场条件下的相关性。为了测试, 我采用了最流行的外汇对 EURUSD, 最易变的一对 USDJPY 和一种贵金属 — XAUUSD。我将 Raschke 和 Connors 指定的缩进增加了 10 倍, 因为书籍出版时使用四位小数的报价, 而我是在五位小数报价上测试 EA。由于没有任何关于尾随参数的指示, 我选择了一种似乎最适合日线时间帧和金融工具波动。这同样适用于添加到原始规则的止盈计算算法 — 其计算的比率被随意选择, 且没有深度优化。

在五年 EURUSD 历史数据上测试原始规则 (无止盈) 时的余额图表:

EURUSD D1 5 年

相同设置并带止盈

EURUSD D1 5 年

在五年 USDJPY 历史数据上测试原始规则时的余额图表:

USDJPY D1 5 年

相同设置并带止盈

USDJPY D1 5 年

最近 4 年黄金日线报价上测试原始规则时的余额图表:

XAUUSD D1 4 年

每次测试中所用机器人设置的全部数据可以在包含完整报告的附件存档中找到。 


结论

信号模块中的编程规则与 Linda Raschke 和 Laurence Connors 在他们的书籍 "街头智能: 高概率短线交易策略" 中提供的 80-20 交易系统描述相匹配。不过, 我们将原来的规则扩展了一些。工具 (机器人和指标) 有助于交易者总结出他们自己的关于交易策略与当今市场的相关性的结论。我个人认为, 交易策略需要认真升级。在本文中, 我已尝试对开发信号模块的代码以及相应的机器人和指标提出一些详细的意见。我希望, 这将有助于部分人做出升级决定。除了修改规则之外, 还可能找到更适合于系统的交易工具, 以及信号检测和跟踪参数。