海龟汤和海龟汤升级版的改进
简介
《华尔街智慧:高胜算短线交易策略》一书的作者,劳伦斯.康纳斯和琳达.瑞斯克是有着34年交易经验的成功交易者,他们的经验包括股票交易,以及在银行、对冲基金、经济公司和咨询公司的职位。他们相信,您只需要一个交易策略就能够做到稳定获利地交易。但是,书中还是包含了20种左右不同的交易策略,分成四组,每组针对不同的市场周期,并且运用于一种稳定的价格行为模式。
在书中描述的策略非常流行,但是有必要知道的是,作者是基于15年到20年的市场行为来开发它们的。所以,本文有两个目标 — 我们将使用 MQL5 实现书中描述的第一个交易策略,然后我们将尝试使用 MetaTrader 5策略测试器来评估它的效能,我们将使用 MetaQuotes 模拟服务器上近些年的价格历史。
当写代码时,我将假定MQL5的用户有基本的语言知识,也就是稍微高级些的初学者。所以,本文不包括对标准函数如何工作的解释,为什么使用这些类型的变量,这些细节应该是用户在编写EA交易之前在学习和练习中做的。另一方面,我也将不会考虑很有经验的EA交易开发人员,因为在实现新的交易策略时,他们已经有了测试好的,他们自己方案的开发库。
本文所面向的大多数编程人员都会对学习面向对象编程感兴趣,所以我将尝试使EA的开发过程对上述的目标有作用。为了使从过程到面向对象方法的迁移更加简单,我们不会使用面向对象编程中的最复杂部分 - 类,我们将会使用它们的简单类比 - 结构来替代。结构可以从逻辑上把不同类型的数据和用于操作它们的函数综合到一起,它们几乎拥有所有类的特性,包括继承。但是您可以在不知道类代码格式规则的基础上使用它们,您可以像您在过程式编程中一样做一些细小改动。
‘海龟汤’交易系统与‘海龟汤升级版’的改进
海龟汤是在称为‘测试(Tests)’的系列交易策略中的第一个。说得更清楚一些,选择这个系列的基础是,它是根据‘使用价格来测试范围的边界或者支撑/阻力水平’。海龟汤是假定价格是不会不经一次反弹就突破20天的范围这一原则的,我们的任务就是从临时的反弹或者假突破中获利。 交易仓位的方向总是朝向通道内部,所以这交易策略可以称为一种“反弹策略”。
另外,海龟汤这个名称与著名的海龟策略类似,这不是偶然的 - 这两种策略都监视着20天范围内的价格行为。该书的作者已经尝试使用多种突破策略,包含了“海龟”,但是这样的交易还是不够有效,因为有许多假信号和深度的回撤。但是他们发现了一些模式,在它们的帮助下可以创建一系列原则来从与突破反方向的价格运动中获利。
在“海龟汤”交易策略中,一套完整的买入交易进场原则可以分析如下:
- 确认距离前一个20天低点至少过去了3天
- 等待资产价格跌破20天低点
- 在向下突破的价格低点上方5到10个点设置买入挂单
- 当挂单触发时,把止损设于当日最低价下方一个点的位置
- 当仓位有利润后使用跟踪止损
- 如果仓位在第一天或者第二天由止损关闭,您可以在初始水平重复进场
卖出交易规则是类似的,它们应用于范围的上方边界,也就是基于20天高点。
在代码库中有一个指标可以在历史柱上根据适当的设置显示通道的边界,在人工交易中您可以使用这个指标用于显示通道。
交易策略的描述没有回答这样的问题:挂单应该保持多久,所以让我们使用一个简单的逻辑。当测试范围边界时,价格将会创建出新的极值点,所以后面一天上面的第一个条件就将不可能达到。因为那一天将不会有信号,我们将必须取消前一天的挂单。
这个交易策略的修改版,称为‘海龟汤升级版’的有两点差别:
- 不是在突破20天范围后立即设置挂单,而是等待一个确认信号 - 当天的柱收盘于范围之外,当日收盘在分析所得的水平通道边界之外也是可以的。
- 为了确定初始止损水平,我们使用两天的极值(最高或者最低价)水平。
定义通道参数
为了检验条件,我们需要知道范围的最高价和最低价,在定义了时间限制后就可以得到,在任意指定时间的通道中都是有四个变量决定了通道,所以它们可以组成一个结构。让我们在其中再加入交易策略中使用的两个变量,就是距离范围中最高价和最低价过去的天数(柱数):
struct CHANNEL { double d_High; // 范围上方边界的价格 double d_Low; // 范围下方边界的价格 datetime t_From; // 通道中第一个(最早的)柱的日期/时间 datetime t_To; // 通道中最后一个柱的日期/时间 int i_Highest_Offset; // 最高价右边的柱数 int i_Lowest_Offset; // 最低价右边的柱数 };
所有这些变量都将由f_Set函数来进行及时更新,该函数需要知道它应该从哪个柱开始绘制虚拟通道(i_Newest_Bar_Shift)以及它所应查看的历史深度 (i_Bars_Limit):
void f_Set(int i_Bars_Limit, int i_Newest_Bar_Shift = 1) { double da_Price_Array[]; // 用于保存通道中所有柱的最高/最低价格的辅助数组 // 确定通道的上方边界: int i_Price_Bars = CopyHigh(_Symbol, PERIOD_CURRENT, i_Newest_Bar_Shift, i_Bars_Limit, da_Price_Array); int i_Bar = ArrayMaximum(da_Price_Array); d_High = da_Price_Array[i_Bar]; // 确定通道的上方边界 i_Highest_Offset = i_Price_Bars - i_Bar; // 距离最高价的时间(柱数) // 确定范围的最低边界: i_Price_Bars = CopyLow(_Symbol, PERIOD_CURRENT, i_Newest_Bar_Shift, i_Bars_Limit, da_Price_Array); i_Bar = ArrayMinimum(da_Price_Array); d_Low = da_Price_Array[i_Bar]; // 确定通道的最低边界 i_Lowest_Offset = i_Price_Bars - i_Bar; // 距离最低点的时间(柱数) datetime ta_Time_Array[]; i_Price_Bars = CopyTime(_Symbol, PERIOD_CURRENT, i_Newest_Bar_Shift, i_Bars_Limit, ta_Time_Array); t_From = ta_Time_Array[0]; t_To = ta_Time_Array[i_Price_Bars - 1]; }
这个函数的代码只有13行,但是如果您已经在语言参考部分读过 MQL 函数对时间序列数据的读取(CopyHigh, CopyLow, CopyTime 以及其他), 您就知道它们其实没那么简单。有的时候,返回数值的数量和您请求的可能不同,因为请求的数据在您第一次访问想要的时间序列时可能还没有准备好。只有您正确处理结果,从时间序列中复制数据才能正常工作。
所以我们要满足确保编程质量的最低标准,加上一些错误处理。为了使错误更加容易理解,让我们把错误数据打印到记录中,记录在调试中也是很有用的,因为它可以含有为什么决定下单的详细信息。让我们引入一个枚举类型的新的变量,它将能设置我们的记录中应该包含多少详细信息。
enum ENUM_LOG_LEVEL { // 记录级别 LOG_LEVEL_NONE, // 禁用记录 LOG_LEVEL_ERR, // 只含有错误信息 LOG_LEVEL_INFO, // 错误 + EA的注释 LOG_LEVEL_DEBUG // 所有内容 };
所需的级别将由用户选择,将在许多函数中加入打印信息的处理。所以枚举列表和自定义变量Log_Level都应该在主程序的开始包含进来,而不是独立的一块。
让我们回到f_Set函数, 它将包含所有另外的检查了 (增加的代码行已经突出显示):
void f_Set(int i_Bars_Limit, int i_Newest_Bar_Shift = 1) { double da_Price_Array[]; // 用于保存通道中所有柱的最高/最低价格的辅助数组 // 确定通道的上方边界: int i_Price_Bars = CopyHigh(_Symbol, PERIOD_CURRENT, i_Newest_Bar_Shift, i_Bars_Limit, da_Price_Array); if(i_Price_Bars == WRONG_VALUE) { // 处理 CopyHigh 函数的错误 if(Log_Level > LOG_LEVEL_NONE) PrintFormat("%s: CopyHigh: 错误 #%u", __FUNCSIG__, _LastError); return; } if(i_Price_Bars < i_Bars_Limit) { // CopyHigh 函数未能接收到请求数量的数据 if(Log_Level > LOG_LEVEL_NONE) PrintFormat("%s: CopyHigh: 已经复制了 %u 个柱,共需 %u", __FUNCSIG__, i_Price_Bars, i_Bars_Limit); return; } int i_Bar = ArrayMaximum(da_Price_Array); if(i_Bar == WRONG_VALUE) { // 处理 ArrayMaximum 函数的错误 if(Log_Level > LOG_LEVEL_NONE) PrintFormat("%s: ArrayMaximum: 错误 #%u", __FUNCSIG__, _LastError); return; } d_High = da_Price_Array[i_Bar]; // 确定通道的上方边界 i_Highest_Offset = i_Price_Bars - i_Bar; // 距离最高价的时间(柱数) // 确定范围的最低边界: i_Price_Bars = CopyLow(_Symbol, PERIOD_CURRENT, i_Newest_Bar_Shift, i_Bars_Limit, da_Price_Array); if(i_Price_Bars == WRONG_VALUE) { // 处理 CopyLow 函数的错误 if(Log_Level > LOG_LEVEL_NONE) PrintFormat("%s: CopyLow: 错误 #%u", __FUNCSIG__, _LastError); return; } if(i_Price_Bars < i_Bars_Limit) { // CopyLow 函数未能收到所请求数量的数据 if(Log_Level > LOG_LEVEL_NONE) PrintFormat("%s: CopyLow: 已复制 %u 个柱,共需 %u", __FUNCSIG__, i_Price_Bars, i_Bars_Limit); return; } i_Bar = ArrayMinimum(da_Price_Array); if(i_Bar == WRONG_VALUE) { // 处理 ArrayMinimum 函数的错误 if(Log_Level > LOG_LEVEL_NONE) PrintFormat("%s: ArrayMinimum: 错误 #%u", __FUNCSIG__, _LastError); return; } d_Low = da_Price_Array[i_Bar]; // 确定通道的最低边界 i_Lowest_Offset = i_Price_Bars - i_Bar; // 距离最低点的时间(柱数) datetime ta_Time_Array[]; i_Price_Bars = CopyTime(_Symbol, PERIOD_CURRENT, i_Newest_Bar_Shift, i_Bars_Limit, ta_Time_Array); if(i_Price_Bars < 1) t_From = t_To = 0; else { t_From = ta_Time_Array[0]; t_To = ta_Time_Array[i_Price_Bars - 1]; } }
当发现错误时,我们做以下事情: 中断执行,这样终端可以下载所需的数据,直到下个订单时刻再来复制所需的数据。为了防止直到过程完全结束前有其他函数使用通道,让我们在结构中加入相应的标志 b_Ready (true = 数据已经准备好, false = 过程还没有完成)。我们还将加入通道更新的标志 (b_Updated) — 为了更好的效率, 它对了解交易策略中四个参数是否有所变化有用。为此我们需要再加入一个变量 - 通道的签名 (s_Signature)。f_Set 函数也要加到结构中,而 CHANNEL 结构看起来就像这样:
// 用于在一个结构中收集和更新通道的信息和函数 struct CHANNEL { // 变量 double d_High; // 范围上方边界的价格 double d_Low; // 范围下方边界的价格 datetime t_From; // 通道的第一个(最前面的)柱的日期/时间 datetime t_To; // 通道中最后一个柱的日期/时间 int i_Highest_Offset; // 最高价右边的柱数 int i_Lowest_Offset; // 最低价右边的柱数 bool b_Ready; // 参数更新过程是否结束了? bool b_Updated; // 通道参数改变了吗? string s_Signature; // 最后数据的签名 // 函数: CHANNEL() { d_High = d_Low = 0; t_From = t_To = 0; b_Ready = b_Updated = false; s_Signature = "-"; i_Highest_Offset = i_Lowest_Offset = WRONG_VALUE; } void f_Set(int i_Bars_Limit, int i_Newest_Bar_Shift = 1) { b_Ready = false; // Pitstop: 设置一个服务标志 double da_Price_Array[]; // 用于保存通道中所有柱的最高/最低价格的辅助数组 // 确定通道的上方边界: int i_Price_Bars = CopyHigh(_Symbol, PERIOD_CURRENT, i_Newest_Bar_Shift, i_Bars_Limit, da_Price_Array); if(i_Price_Bars == WRONG_VALUE) { // 处理 CopyHigh 函数的错误 if(Log_Level > LOG_LEVEL_NONE) PrintFormat("%s: CopyHigh: 错误 #%u", __FUNCSIG__, _LastError); return; } if(i_Price_Bars < i_Bars_Limit) { // CopyHigh 函数未能接收到请求数量的数据 if(Log_Level > LOG_LEVEL_NONE) PrintFormat("%s: CopyHigh: 已经复制了 %u 个柱,共需 %u", __FUNCSIG__, i_Price_Bars, i_Bars_Limit); return; } int i_Bar = ArrayMaximum(da_Price_Array); if(i_Bar == WRONG_VALUE) { // 处理 ArrayMaximum 函数的错误 if(Log_Level > LOG_LEVEL_NONE) PrintFormat("%s: ArrayMaximum: 错误 #%u", __FUNCSIG__, _LastError); return; } d_High = da_Price_Array[i_Bar]; // 确定通道的上方边界 i_Highest_Offset = i_Price_Bars - i_Bar; // 距离最高价的时间(柱数) // 确定范围的最低边界: i_Price_Bars = CopyLow(_Symbol, PERIOD_CURRENT, i_Newest_Bar_Shift, i_Bars_Limit, da_Price_Array); if(i_Price_Bars == WRONG_VALUE) { // 处理 CopyLow 函数的错误 if(Log_Level > LOG_LEVEL_NONE) PrintFormat("%s: CopyLow: 错误 #%u", __FUNCSIG__, _LastError); return; } if(i_Price_Bars < i_Bars_Limit) { // CopyLow 函数未能收到所请求数量的数据 if(Log_Level > LOG_LEVEL_NONE) PrintFormat("%s: CopyLow: 已复制 %u 个柱,共需 %u", __FUNCSIG__, i_Price_Bars, i_Bars_Limit); return; } i_Bar = ArrayMinimum(da_Price_Array); if(i_Bar == WRONG_VALUE) { // 处理 ArrayMinimum 函数的错误 if(Log_Level > LOG_LEVEL_NONE) PrintFormat("%s: ArrayMinimum: 错误 #%u", __FUNCSIG__, _LastError); return; } d_Low = da_Price_Array[i_Bar]; // 确定通道的最低边界 i_Lowest_Offset = i_Price_Bars - i_Bar; // 距离最低点的时间(柱数) datetime ta_Time_Array[]; i_Price_Bars = CopyTime(_Symbol, PERIOD_CURRENT, i_Newest_Bar_Shift, i_Bars_Limit, ta_Time_Array); if(i_Price_Bars < 1) t_From = t_To = 0; else { t_From = ta_Time_Array[0]; t_To = ta_Time_Array[i_Price_Bars - 1]; } string s_New_Signature = StringFormat("%.5f%.5f%u%u", d_Low, d_High, t_From, t_To); if(s_Signature != s_New_Signature) { // 通道数据已经改变 b_Updated = true; if(Log_Level > LOG_LEVEL_ERR) PrintFormat("%s: 通道已更新: %s .. %s / %s .. %s, min: %u max: %u ", __FUNCTION__, DoubleToString(d_Low, _Digits), DoubleToString(d_High, _Digits), TimeToString(t_From, TIME_DATE|TIME_MINUTES), TimeToString(t_To, TIME_DATE|TIME_MINUTES), i_Lowest_Offset, i_Highest_Offset); s_Signature = s_New_Signature; } b_Ready = true; // 数据更新成功完成 } };
CHANNEL go_Channel;
信号生成函数
根据这个系统,在两种所需的条件下会产生一个买入信号:
1. 距离前一个20天低点至少已经过了3个交易日
2a. 交易品种的价格低于20天低点 (海龟汤)
2b. 每日柱的收盘价不高于20天的低点 (海龟汤升级版)
所有以上描述的其他交易策略规则都属于交易订单参数和仓位的管理,所以我们不在信号模块包含它们。
在同一个模块中,我们将根据这两种交易系统 (海龟汤和海龟汤升级版)进行信号的编程,将在EA交易的参数中加入选项,可以选择规则的相应版本。让我们把这对应的自定义变量称为 Strategy_Type。在我们的实例中,策略选项只有两个,所以就使用 true/false ( bool 类型的变量) 将更简单一些。但是我们需要可以把系列文章中的所有策略都加到其中,所以让我们使用数字列表:
enum ENUM_STRATEGY { // 策略列表 TS_TURTLE_SOUP, // 海龟汤 TS_TURTLE_SOUP_PLUS_1 // 海龟汤升级版 }; input ENUM_STRATEGY Strategy_Type = TS_TURTLE_SOUP; // 交易策略:
策略类型将会传到主程序的信号侦测函数中,也就是说它需要知道是否需要等到柱(日柱)关闭 — bool 类型的 b_Wait_For_Bar_Close 变量。第二个需要的变量是在前面的 i_Extremum_Bars 柱数处暂停,该函数返回信号状态: 是否买入/卖出条件已经符合还是继续等待. 在主EA交易文件中要加入对应的数字列表:
enum ENUM_ENTRY_SIGNAL { // 进场信号的列表 ENTRY_BUY, // 买入信号 ENTRY_SELL, // 卖出信号 ENTRY_NONE, // 没有信号 ENTRY_UNKNOWN // 未定义状态 };
另外一个信号模块和主程序函数都要用到的结构是 go_Tick 全局对象,它包含最新订单时刻的信息。这是一个标准的 MqlTick 类型的结构, 应该在主文件中声明。晚些时候我们将会编程使它在主程序体中更新 (OnTick 函数)。
MqlTick go_Tick; // 最后所知订单时刻的信息
现在,最终我们可以继续到模块的主函数了。
ENUM_ENTRY_SIGNAL fe_Get_Entry_Signal( bool b_Wait_For_Bar_Close = false, int i_Extremum_Bars = 3 ) {}
让我们从检查卖出信号条件开始; 距离前一最高价(第一个条件)是否过去了足够的天数(柱数), 以及价格是否突破了范围的上方边界 (第二个条件):
if(go_Channel.i_Highest_Offset > i_Extremum_Bars) // 第一个条件 if(go_Channel.d_High < d_Actual_Price) // 第二个条件 return(ENTRY_SELL); // 两个卖出条件都已达到
检查买入信号条件是类似的:
if(go_Channel.i_Lowest_Offset > i_Extremum_Bars) // 第一个条件 if(go_Channel.d_Low > d_Actual_Price) { // 第二个条件 return(ENTRY_BUY); // 两个买入条件都已满足
在此我们已经使用d_Actual_Price变量,它包含用于此交易策略的当前价格。对于海龟汤,它的意思是最后所知的卖出价格,对于海龟汤升级版,它就是前一天(柱)的收盘价格:
double d_Actual_Price = go_Tick.bid; // 默认价格 - 用于海龟汤版本 if(b_Wait_For_Bar_Close) { // 用于海龟汤升级版版本 double da_Price_Array[1]; // 辅助数组 CopyClose(_Symbol, PERIOD_CURRENT, 1, 1, da_Price_Array)); d_Actual_Price = da_Price_Array[0]; }
包含了最少所需处理的函数看起来是这样的:
ENUM_ENTRY_SIGNAL fe_Get_Entry_Signal(bool b_Wait_For_Bar_Close = false, int i_Extremum_Bars = 3) { double d_Actual_Price = go_Tick.bid; // 默认价格 - 用于海龟汤版本 if(b_Wait_For_Bar_Close) { // 用于海龟汤升级版版本 double da_Price_Array[1]; CopyClose(_Symbol, PERIOD_CURRENT, 1, 1, da_Price_Array)); d_Actual_Price = da_Price_Array[0]; } // 上方边界: if(go_Channel.i_Highest_Offset > i_Extremum_Bars) // 第一个条件 if(go_Channel.d_High < d_Actual_Price) { // 2nd condition // 价格突破了上方边界 return(ENTRY_SELL); } // 下方边界: if(go_Channel.i_Lowest_Offset > i_Extremum_Bars) // 第一个条件 if(go_Channel.d_Low > d_Actual_Price) { // 第二个条件 // 价格突破了下方边界 return(ENTRY_BUY); } return(ENTRY_NONE); }
请记住,通道对象可能在从中读取数据时还没有准备好(标志 go_Channel.b_Ready = false),所以,我们需要增加对这个标志的检查。在这个函数中,我们使用用于从时间序列中复制数据的标准函数之一(CopyClose), 所以让我们加上可能的错误处理。不要忘记记录详细数据,它们可能用于调试:
ENUM_ENTRY_SIGNAL fe_Get_Entry_Signal(bool b_Wait_For_Bar_Close = false, int i_Extremum_Bars = 3) { if(!go_Channel.b_Ready) { // 通道数据还没有准备好 if(Log_Level == LOG_LEVEL_DEBUG) PrintFormat("%s: 通道参数没有准备好", __FUNCTION__); return(ENTRY_UNKNOWN); } double d_Actual_Price = go_Tick.bid; // 默认价格 - 用于海龟汤版本 if(b_Wait_For_Bar_Close) { // 用于海龟汤升级版版本 double da_Price_Array[1]; if(WRONG_VALUE == CopyClose(_Symbol, PERIOD_CURRENT, 1, 1, da_Price_Array)) { // 处理 CopyClose 函数的错误 if(Log_Level > LOG_LEVEL_NONE) PrintFormat("%s: CopyClose: 错误 #%u", __FUNCSIG__, _LastError); return(ENTRY_NONE); } d_Actual_Price = da_Price_Array[0]; } // 上方边界: if(go_Channel.i_Highest_Offset > i_Extremum_Bars) // 第一个条件 if(go_Channel.d_High < d_Actual_Price) { // 2nd condition // 价格突破了上方边界 if(Log_Level == LOG_LEVEL_DEBUG) PrintFormat("%s: 价格 (%s) 已经突破了上方边界 (%s)", __FUNCTION__, DoubleToString(d_Actual_Price, _Digits), DoubleToString(go_Channel.d_High, _Digits)); return(ENTRY_SELL); } // 下方边界: if(go_Channel.i_Lowest_Offset > i_Extremum_Bars) // 第一个条件 if(go_Channel.d_Low > d_Actual_Price) { // 第二个条件 // 价格突破了下方边界 if(Log_Level == LOG_LEVEL_DEBUG) PrintFormat("%s: 价格 (%s) 已经突破了下方边界 (%s)", __FUNCTION__, DoubleToString(d_Actual_Price, _Digits), DoubleToString(go_Channel.d_Low, _Digits)); return(ENTRY_BUY); } // 如果程序达到了这一行,价格就在范围之内,也就是第二个条件不满足 return(ENTRY_NONE); }
此函数将在每个订单时刻中调用,也就是每天成百上千次。但是,如果第一个条件(距离上一个极值点至少三天)不满足,后面的动作就是没有意义的。根据编程的规则,我们应该尽量减少资源的消耗,所以让我们的函数一直沉睡直到下一个柱(天),也就是说,直到通道参数更新:
ENUM_ENTRY_SIGNAL fe_Get_Entry_Signal(bool b_Wait_For_Bar_Close = false, int i_Extremum_Bars = 3) { static datetime st_Pause_End = 0; // 下一次检查的时间 if(st_Pause_End > go_Tick.time) return(ENTRY_NONE); st_Pause_End = 0; if(go_Channel.b_In_Process) { // 通道数据还没有准备好 if(Log_Level == LOG_LEVEL_DEBUG) PrintFormat("%s: 通道参数没有准备好", __FUNCTION__); return(ENTRY_UNKNOWN); } if(go_Channel.i_Lowest_Offset < i_Extremum_Bars && go_Channel.i_Highest_Offset < i_Extremum_Bars) { // 第一个条件不满足 if(Log_Level == LOG_LEVEL_DEBUG) PrintFormat("%s: 第一个条件不满足", __FUNCTION__); // 暂停,直到通道更新 st_Pause_End = go_Tick.time + PeriodSeconds() - go_Tick.time % PeriodSeconds(); return(ENTRY_NONE); } double d_Actual_Price = go_Tick.bid; // 默认价格 - 用于海龟汤版本 if(b_Wait_For_Bar_Close) { // 用于海龟汤升级版版本 double da_Price_Array[1]; if(WRONG_VALUE == CopyClose(_Symbol, PERIOD_CURRENT, 1, 1, da_Price_Array)) { // 处理 CopyClose 函数的错误 if(Log_Level > LOG_LEVEL_NONE) PrintFormat("%s: CopyClose: 错误 #%u", __FUNCSIG__, _LastError); return(ENTRY_NONE); } d_Actual_Price = da_Price_Array[0]; } // 上方边界: if(go_Channel.i_Highest_Offset > i_Extremum_Bars) // 第一个条件 if(go_Channel.d_High < d_Actual_Price) { // 2nd condition // 价格突破了上方边界 if(Log_Level == LOG_LEVEL_DEBUG) PrintFormat("%s: 价格 (%s) 已经突破了上方边界 (%s)", __FUNCTION__, DoubleToString(d_Actual_Price, _Digits), DoubleToString(go_Channel.d_High, _Digits)); return(ENTRY_SELL); } // 下方边界: if(go_Channel.i_Lowest_Offset > i_Extremum_Bars) // 第一个条件 if(go_Channel.d_Low > d_Actual_Price) { // 第二个条件 // 价格突破了下方边界 if(Log_Level == LOG_LEVEL_DEBUG) PrintFormat("%s: 价格 (%s) 已经突破了下方边界 (%s)", __FUNCTION__, DoubleToString(d_Actual_Price, _Digits), DoubleToString(go_Channel.d_Low, _Digits)); return(ENTRY_BUY); } // 如果程序达到了这一行,价格就在范围之内,也就是第二个条件不满足 if(b_Wait_For_Bar_Close) // 对于海龟汤升级版 // 暂停,直到当前柱关闭 st_Pause_End = go_Tick.time + PeriodSeconds() - go_Tick.time % PeriodSeconds(); return(ENTRY_NONE); }
这是这个函数的最终代码,让我们把信号模块文件称为Signal_Turtle_Soup.mqh, 把有关通道和信号的代码加到其中; 在文件的开头,我们将加入用于自定义策略的栏位:
enum ENUM_STRATEGY { // 策略版本 TS_TURTLE_SOUP, // 海龟汤 TS_TURTLE_SOUP_PLIS_1 // 海龟汤升级版 }; // 自定义设置 input ENUM_STRATEGY Turtle_Soup_Type = TS_TURTLE_SOUP; // 海龟汤: 策略版本 input uint Turtle_Soup_Period_Length = 20; // 海龟汤: 极值点搜索深度 (柱数) input uint Turtle_Soup_Extremum_Offset = 3; // 海龟汤: 距离上一个极值点的暂停(柱数) input double Turtle_Soup_Entry_Offset = 10; // 海龟汤: 进场: 与极值水平的偏移 (点数) input double Turtle_Soup_Exit_Offset = 1; // 海龟汤: 推出: 与相反极值点的偏移 (点数)
把文件保存到终端数据文件夹; 信号库应该保存到 MQL5\Include\Expert\Signal 目录下。
用于测试交易策略的基础EA交易
在靠近EA交易开头的代码中,我们加入了自定义栏位,在这些栏位之前我们加入了在设置中使用的枚举列表的列表。让我们把设置分成两组 — "策略设置" 和 "仓位的建立和管理". 第一组设置将在编译的时候从信号库文件包含,这样,我们创建了一个文件,在下面的文章中,我们将编程实现书中的其他策略,并且可以替换(或者增加)信号模块,包括所需的自定义设置。
现在我们在 MQL5 标准库文件开始之前包含代码,用于进行交易操作:
enum ENUM_LOG_LEVEL { // 记录级别列表 LOG_LEVEL_NONE, // 记录被禁用 LOG_LEVEL_ERR, // 只记录错误信息 LOG_LEVEL_INFO, // 错误 + EA的注释 LOG_LEVEL_DEBUG // 所有内容 }; enum ENUM_ENTRY_SIGNAL { // 进场信号的列表 ENTRY_BUY, // 买入信号 ENTRY_SELL, // 卖出信号 ENTRY_NONE, // 没有信号 ENTRY_UNKNOWN // 未定义状态 }; #include <Trade\Trade.mqh> // 用于进行交易操作的类 input string _ = "** 策略设置:"; // . #include <Expert\Signal\Signal_Turtle_Soup.mqh> //信号模块 input string __ = "** 仓位建立和管理:"; // . input double Trade_Volume = 0.1; // 交易量 input uint Trail_Trigger = 100; // 移动止损: 距离跟踪的点数 在input uint Trail_Step = 5; // 跟踪止损: 止损步长 (点数) input uint Trail_Distance = 50; // 跟踪止损,价格距离止损的最大距离 (点数) input ENUM_LOG_LEVEL Log_Level = LOG_LEVEL_INFO; // 记录模式:
对于这个策略,作者并没有提到任何特定的资金管理或者风险管理技术,所以我们对所有交易使用固定手数。
跟踪止损设置应该输入点数。5位小数报价的引入会对单位的使用造成混淆,所以注意这里的一个点是对应着交易品种价格的最小变化。这就是说,对于5位小数报价,一个点等于 0.00001, 而对于4位小数报价,它等于 0.0001。不要和 pips 混淆 — pips 会忽视报价的真正精确度,总需要把它们转换成4位小数。也就是说,如果交易品种价格变化的最小数是 0.00001, 一个 pip 就等于 10 points; 如果 point 等于 0.0001, point 和 pip 的数值就是相同的。
跟踪止损函数在每个订单时刻都会使用这些设置,而且每天会重新计算成千上万的交易品种的真实价格,尽管这不需要消耗过多的 CPU 资源。在EA交易初始化时重新计算用于输入的数值也许更加正确,并且把它们保存到全局变量中以便今后使用。用于规范化手数的变量也是有相同的原则 — 最小值和最大值的限制, 以及 EA 交易在运行时的改动的步长。这里是全局变量的声明和初始化函数:
int gi_Try_To_Trade = 4, // 发出交易订单的重试次数 gi_Connect_Wait = 2000 // 在尝试前的暂停时间(毫秒数) ; double gd_Stop_Level, // 把止损水平根据设置转换为交易品种价格 gd_Lot_Step, gd_Lot_Min, gd_Lot_Max, // 手数限制的设置 gd_Entry_Offset, // 进场: 距离极值点的交易品种价格距离 gd_Exit_Offset, // 退出: 交易品种价格中与极值点的偏移 gd_Trail_Trigger, gd_Trail_Step, gd_Trail_Distance // 转换为交易品种价格的跟踪止损参数 ; MqlTick go_Tick; // 最后所知订单时刻的信息 int OnInit() { // 把设置从点数转换为交易品种价格: double d_One_Point_Rate = pow(10, _Digits); gd_Entry_Offset = Turtle_Soup_Entry_Offset / d_One_Point_Rate; gd_Exit_Offset = Turtle_Soup_Exit_Offset / d_One_Point_Rate; gd_Trail_Trigger = Trail_Trigger / d_One_Point_Rate; gd_Trail_Step = Trail_Step / d_One_Point_Rate; gd_Trail_Distance = Trail_Distance / d_One_Point_Rate; gd_Stop_Level = SymbolInfoInteger(_Symbol, SYMBOL_TRADE_STOPS_LEVEL) / d_One_Point_Rate; // 手数限制的初始化: gd_Lot_Min = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN); gd_Lot_Max = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MAX); gd_Lot_Step = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP); return(INIT_SUCCEEDED); }
请注意, MQL5 标准库中包含了我们需要的这种类型的跟踪止损模块 (TrailingFixedPips.mqh), 并且我们可以像其他进行交易操作的类一样,把它包含在代码中。但是这和 EA 交易的特性并不完全符合,所以我们将写自己的跟踪代码并且把它以独立的自定义函数的方式加到EA交易中:
bool fb_Trailing_Stop( // 当前交易品种仓位的跟踪止损 double d_Trail_Trigger, // 启用跟踪止损的距离 double d_Trail_Step, // SL 跟踪止损步长 (以交易品种价格计算) double d_Trail_Distance // 价格距离止损的最小距离 (在交易品种价格中)</s3> ) { if(!PositionSelect(_Symbol)) return(false); // 没有仓位,不需要跟踪止损 // 计算新的止损水平的基础数值 - 当前价格数值: double d_New_SL = PositionGetDouble(POSITION_PRICE_CURRENT); if(PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY) { // 对于买入仓位 if(d_New_SL - PositionGetDouble(POSITION_PRICE_OPEN) < d_Trail_Trigger) return(false); // 价格没有移动到足够的位置来启用跟踪止损 if(d_New_SL - PositionGetDouble(POSITION_SL) < d_Trail_Distance + d_Trail_Step) return(false); // 价格改动少于设置的跟踪止损步长 d_New_SL -= d_Trail_Distance; // 新的止损水平 } else if(PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_SELL) { // 对于卖出仓位 if(PositionGetDouble(POSITION_PRICE_OPEN) - d_New_SL < d_Trail_Trigger) return(false); // 价格没有移动到足够的位置来启用跟踪止损 if(PositionGetDouble(POSITION_SL) > 0.0) if(PositionGetDouble(POSITION_SL) - d_New_SL < d_Trail_Distance + d_Trail_Step) return(false); // 价格没有移动到足够的位置来启用跟踪止损 d_New_SL += d_Trail_Distance; // New SL level } else return(false); // 服务器设置是否允许在与当前价格这样的距离中设置止损? if(!fb_Is_Acceptable_Distance(d_New_SL, PositionGetDouble(POSITION_PRICE_CURRENT))) return(false); CTrade Trade; Trade.LogLevel(LOG_LEVEL_ERRORS); // 移动止损 Trade.PositionModify(_Symbol, d_New_SL, PositionGetDouble(POSITION_TP)); return(true); } bool fb_Is_Acceptable_Distance(double d_Level_To_Check, double d_Current_Price) { return( fabs(d_Current_Price - d_Level_To_Check) > fmax(gd_Stop_Level, go_Tick.ask - go_Tick.bid) ); }
检查是否允许在新的距离中设置止损是包含在独立函数fb_Is_Acceptable_Distance中的, 它也可用于验证设置挂单的水平和已建仓位的止损水平。
现在我们继续EA交易代码的主要工作区域,它是由处理新订单时刻到来时间的处理函数调用的 - OnTick。根据策略的规则,如果有已经建立的仓位,EA就不应该搜索新的信号,所以我们开始要做对应的检查。如果已经有了仓位,EA有两种选项: 或者计算和设置新仓位的初始止损水平,或者激活跟踪止损函数,即确定是否应该移动止损,从而进行对应的操作。调用跟踪止损函数很简单,而对于止损水平的计算,我们将使用由用户输入的与极值点gd_Exit_Offset偏移点数并把它转换为交易品种的价格。极值点的价格数值可以通过使用标准 MQL5 函数 CopyHigh 或 CopyLow 来寻找。计算所得的水平应该随后使用fb_Is_Acceptable_Distance函数来验证,并且使用来自go_Tick结构中的当前价格数值。我们将止损买入(BuyStop)和止损卖出(SellStop)订单的计算和验证独立分开:
if(PositionSelect(_Symbol)) { // 有建立的仓位 if(PositionGetDouble(POSITION_SL) == 0.) { // 新的仓位 double d_SL = WRONG_VALUE, // 止损水平 da_Price_Array[] // 辅助数组 ; // 计算止损水平: if(PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY) { // 对于买入仓位 if(WRONG_VALUE == CopyLow(_Symbol, PERIOD_CURRENT, 0, 1 + (Turtle_Soup_Type == TS_TURTLE_SOUP_PLIS_1), da_Price_Array)) { // 处理 CopyLow 函数的错误 if(Log_Level > LOG_LEVEL_NONE) PrintFormat("%s: CopyLow: 错误 #%u", __FUNCTION__, _LastError); return; } d_SL = da_Price_Array[ArrayMinimum(da_Price_Array)] - gd_Exit_Offset; // 距离当前价格是否已经足够? if(!fb_Is_Acceptable_Distance(d_SL, go_Tick.bid)) { if(Log_Level > LOG_LEVEL_NONE) PrintFormat("计算所得的止损水平 %s 由允许的最小值 %s 代替", DoubleToString(d_SL, _Digits), DoubleToString(go_Tick.bid + fmax(gd_Stop_Level, go_Tick.ask - go_Tick.bid), _Digits)); d_SL = go_Tick.bid - fmax(gd_Stop_Level, go_Tick.ask - go_Tick.bid); } } else { // For a short position if(WRONG_VALUE == CopyHigh(_Symbol, PERIOD_CURRENT, 0, 1 + (Turtle_Soup_Type == TS_TURTLE_SOUP_PLIS_1), da_Price_Array)) { // 处理 CopyHigh 函数的错误 if(Log_Level > LOG_LEVEL_NONE) PrintFormat("%s: CopyHigh: 错误 #%u", __FUNCTION__, _LastError); return; } d_SL = da_Price_Array[ArrayMaximum(da_Price_Array)] + gd_Exit_Offset; // 距离当前价格是否已经足够? if(!fb_Is_Acceptable_Distance(d_SL, go_Tick.ask)) { if(Log_Level > LOG_LEVEL_NONE) PrintFormat("计算所得的止损水平 %s 由允许的最小值 %s 代替", DoubleToString(d_SL, _Digits), DoubleToString(go_Tick.ask - fmax(gd_Stop_Level, go_Tick.ask - go_Tick.bid), _Digits)); d_SL = go_Tick.ask + fmax(gd_Stop_Level, go_Tick.ask - go_Tick.bid); } } CTrade Trade; Trade.LogLevel(LOG_LEVEL_ERRORS); // 设置止损 Trade.PositionModify(_Symbol, d_SL, PositionGetDouble(POSITION_TP)); return; } // 跟踪止损 fb_Trailing_Stop(gd_Trail_Trigger, gd_Trail_Step, gd_Trail_Distance); return; }
除了计算新订单时刻的参数,我们还需要更新通道的参数,它们用于信号的侦测。调用对应的go_Channel结构的f_Set函数只应该在柱关闭之后进行,而这些参数在其余时间中都保持不变。交易机器人还有一个操作与新的一天(柱)开始有关,就是删除不需要的昨天的挂单。Let's program these two actions:
int i_Order_Ticket = WRONG_VALUE, // 挂单的订单编号 i_Try = gi_Try_To_Trade, // 进行操作的尝试次数 i_Pending_Type = -10 // 已存在挂单的类型 ; static int si_Last_Tick_Bar_Num = 0; // 前一个订单时刻柱的编号 (0 = MQL中的计算起点) // 处理与新的一天(柱)开始的事件: if(si_Last_Tick_Bar_Num < int(floor(go_Tick.time / PeriodSeconds()))) { // 向新的一天打个招呼 :) si_Last_Tick_Bar_Num = int(floor(go_Tick.time / PeriodSeconds())); // 是否有过时的挂单? i_Pending_Type = fi_Get_Pending_Type(i_Order_Ticket); if(i_Pending_Type == ORDER_TYPE_SELL_STOP || i_Pending_Type == ORDER_TYPE_BUY_STOP) { // 删除旧的订单: if(Log_Level > LOG_LEVEL_ERR) Print("删除昨天的挂单"); CTrade o_Trade; o_Trade.LogLevel(LOG_LEVEL_ERRORS); while(i_Try-- > 0) { // 尝试删除 if(o_Trade.OrderDelete(i_Order_Ticket)) { // 尝试成功 i_Try = -10; // 成功操作的标志 break; } // 尝试失败 Sleep(gi_Connect_Wait); // 在下一次尝试前暂停 } if(i_Try == WRONG_VALUE) { // 删除挂单失败 if(Log_Level > LOG_LEVEL_NONE) Print("删除挂单错误"); return; // 等待下一个订单时刻 } } // 更新通道参数: go_Channel.f_Set(Turtle_Soup_Period_Length, 1 + (Turtle_Soup_Type == TS_TURTLE_SOUP_PLIS_1)); }
这里使用的fi_Get_Pending_Type函数返回挂单的类型,它使用收到的i_Order_Ticket变量的引用,在其中加入订单编号。订单类型晚些时候将用于与此订单时刻的真实信号方向做比较,而订单编号用于在有必要时删除订单。如果没有挂单,这两个值都将等于 WRONG_VALUE。函数代码如下:
int fi_Get_Pending_Type( // 侦测当前交易品种是否有挂单 int& i_Order_Ticket // 所选挂单单号的引用 ) { int i_Order = OrdersTotal(), // 订单总数 i_Order_Type = WRONG_VALUE // 订单类型的变量 ; i_Order_Ticket = WRONG_VALUE; // 默认返回的订单编号 if(i_Order < 1) return(i_Order_Ticket); // 没有订单 while(i_Order-- > 0) { // 检查已有的订单 i_Order_Ticket = int(OrderGetTicket(i_Order)); // 读取订单编号 if(i_Order_Ticket > 0) if(StringCompare(OrderGetString(ORDER_SYMBOL), _Symbol, false) == 0) { i_Order_Type = int(OrderGetInteger(ORDER_TYPE)); // 我们只需要挂单: if(i_Order_Type == ORDER_TYPE_BUY_LIMIT || i_Order_Type == ORDER_TYPE_BUY_STOP || i_Order_Type == ORDER_TYPE_SELL_LIMIT || i_Order_Type == ORDER_TYPE_SELL_STOP) break; // 找到了一个挂单 } i_Order_Ticket = WRONG_VALUE; // 还没有找到 } return(i_Order_Type); }
现在用于判断信号状态的准备已经完成了,如果交易策略的条件没有被满足 (信号的状态将是 ENTRY_NONE 或者 ENTRY_UNKNOWN), 主程序在这个订单时刻的操作就可以结束了:
// 取得信号状态: ENUM_ENTRY_SIGNAL e_Signal = fe_Get_Entry_Signal(Turtle_Soup_Type == TS_TURTLE_SOUP_PLIS_1, Turtle_Soup_Extremum_Offset); if(e_Signal > 1) return; // No signal
如果有信号,而且已经有挂单,和它与已有挂单的方向做比较:
// 寻找挂单的类型和它的订单编号: if(i_Pending_Type == -10) i_Pending_Type = fi_Get_Pending_Type(i_Order_Ticket); // 我们是否需要一个新的挂单? if( (e_Signal == ENTRY_SELL && i_Pending_Type == ORDER_TYPE_SELL_STOP) || (e_Signal == ENTRY_BUY && i_Pending_Type == ORDER_TYPE_BUY_STOP) ) return; // 在信号方向上有一个挂单 // 我们是否需要删除挂单? if( (e_Signal == ENTRY_SELL && i_Pending_Type == ORDER_TYPE_BUY_STOP) || (e_Signal == ENTRY_BUY && i_Pending_Type == ORDER_TYPE_SELL_STOP) ) { // 挂单的方向与信号方向不匹配 if(Log_Level > LOG_LEVEL_ERR) Print("挂单的方向和信号的方向不符合"); i_Try = gi_Try_To_Trade; while(i_Try-- > 0) { // 尝试删除 if(o_Trade.OrderDelete(i_Order_Ticket)) { // 尝试成功 i_Try = -10; // 成功操作的标志 break; } // 尝试失败 Sleep(gi_Connect_Wait); // 在下一次尝试前暂停 } if(i_Try == WRONG_VALUE) { // 删除挂单失败 if(Log_Level > LOG_LEVEL_NONE) Print("删除挂单错误"); return; // 等待下一个订单时刻 } }
现在我们确信我们需要设置新的挂单了,让我们计算它的参数。根据策略的规则,订单应该设置在通道边界内部一定偏移的位置, 应该在边界的反向边设置止损,接近当天或者两天之前的价格极值点 (依赖于所选策略的版本)。止损的位置应该只在挂单触发之后计算 — 操作的代码在上面已经有了。
相应的通道边界应该从go_Channel结构中读取,而由用户指定的进场偏移要转换为交易品种的价格,它在 gd_Entry_Offset 变量中指定。计算所得的水平应该使用 fb_Is_Acceptable_Distance 函数验证,使用 go_Tick 结构中的当前价格数值。我们将止损买入(BuyStop)和止损卖出(SellStop)订单的计算和验证独立分开:
double d_Entry_Level = WRONG_VALUE; // 设置挂单的水平 if(e_Signal == ENTRY_BUY) { // 对于买入挂单 // 检查是否可以设置订单: d_Entry_Level = go_Channel.d_Low + gd_Entry_Offset; // 订单设置水平 if(!fb_Is_Acceptable_Distance(d_Entry_Level, go_Tick.ask)) { // 与当前价格的距离不够 if(Log_Level > LOG_LEVEL_ERR) PrintFormat("无法在 %s 水平设置止损买入挂单. Bid: %s Ask: %s StopLevel: %s", DoubleToString(d_Entry_Level, _Digits), DoubleToString(go_Tick.bid, _Digits), DoubleToString(go_Tick.ask, _Digits), DoubleToString(gd_Stop_Level, _Digits) ); return; // 等待当前价格改变 } } else { // 检查是否可以设置订单: d_Entry_Level = go_Channel.d_High - gd_Entry_Offset; // 订单设置水平 if(!fb_Is_Acceptable_Distance(d_Entry_Level, go_Tick.bid)) { // 与当前价格的距离不够 if(Log_Level > LOG_LEVEL_ERR) PrintFormat("无法在 %s 水平设置止损卖出挂单,Bid: %s Ask: %s StopLevel: %s", DoubleToString(d_Entry_Level, _Digits), DoubleToString(go_Tick.bid, _Digits), DoubleToString(go_Tick.ask, _Digits), DoubleToString(gd_Stop_Level, _Digits) ); return; // 等待当前价格改变 } }
如果计算所得的设置挂单水平被验证成功,我们可以使用标准库中的类来吧对应订单发送到服务器:
// 符合服务器要求的手数: double d_Volume = fd_Normalize_Lot(Trade_Volume); // 设置挂单: i_Try = gi_Try_To_Trade; if(e_Signal == ENTRY_BUY) { while(i_Try-- > 0) { // 尝试设置止损买入挂单 if(o_Trade.BuyStop( d_Volume, d_Entry_Level, _Symbol )) { // 尝试成功 Alert("已经设置了买入挂单!"); i_Try = -10; // 成功操作的标志 break; } // 失败 Sleep(gi_Connect_Wait); // 在下一次尝试前暂停 } } else { while(i_Try-- > 0) { // 尝试设置止损卖出挂单 if(o_Trade.SellStop( d_Volume, d_Entry_Level, _Symbol )) { // 尝试成功 Alert("已经设置了卖出挂单!"); i_Try = -10; // 成功操作的标志 break; } // 失败 Sleep(gi_Connect_Wait); // 在下一次尝试前暂停 } } if(i_Try == WRONG_VALUE) // 设置挂单失败 if(Log_Level > LOG_LEVEL_NONE) Print("设置挂单错误");
这是EA编程中的最后一步,我们只需要编译它然后再在策略测试器中分析它的效果。
策略回测
在书中,康纳斯和瑞斯克使用了超过20年的图表来演示策略,所以测试的主要目的是用更近一些年的数据来检验策略的效能。测试中使用由作者指定的参数和每日时段。20年前,很少有5位数报价, 而在 MetaQuotes 模拟服务器上进行的是5位数报价的测试,所以最初的1和10个点将转换为10和100。最初的策略描述中没有包含跟踪止损参数,所以我使用了看起来最适合每日时段的参数。
海龟汤策略在最近5年在 USDJPY 上的测试结果:
相同资产以及相同历史上使用了相同参数的海龟汤升级版的测试结果:
在最近5年黄金报价上的测试结果图: 海龟汤策略:
海龟汤升级版:
在最近4年原油报价上的测试结果图: 海龟汤策略:
测试的完整结果在附件的文件中。
您将自己进行总结,但是我需要给个必要的说明。康纳斯和瑞斯克警告并反对纯粹机械地追随书中描述的任何策略,他们相信有必要分析价格是如何接近通道边界的,并且要看它测试边界的行为。不幸的是,他们没有提供这样做的细节。为了优化,您可以尝试调整参数,用于其它时段,选择更好的交易品种和参数。
结论
我们已经对《华尔街智慧:高胜率短线交易策略》一书中描述的第一对策略进行了规范和编程 — 海龟汤和海龟汤升级版。EA交易和信号库包含了瑞斯克和康纳斯描述的所有规则,但是它们没有包含作者在交易中简要介绍的一些重要细节,至少有必要考虑交易间隙和限制。另外,加上每天只允许进场一次似乎更为符合逻辑,或者只允许一次获利的进场,允许保持挂单的时间超过下一天的开始。您可以自己做这些工作来提高本文所述的EA交易。
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/2717