Appearance
专家顾问的测试与优化
开发专家顾问(EA)不仅意味着在 MQL5 中实现交易策略,更重要的是测试其财务表现、寻找最优设置,以及在各种情况下进行调试(查找并修正错误)。所有这些工作都可以在 MetaTrader 5 集成测试器中完成。
测试器适用于多种货币对,并支持多种报价生成模式:基于所选时间框架的开盘价、M1 时间框架的 OHLC(开盘价、最高价、最低价、收盘价)价格、人工生成的报价,以及真实的报价历史数据。通过这些模式,你可以选择交易模拟速度和准确性之间的最佳平衡。
测试器设置允许你设定过去的测试时间区间、账户初始资金规模和杠杆比例;还可用于模拟滑点和特定账户特性(包括手续费、保证金、交易时段安排、手数限制)。从用户角度来看,关于使用测试器的所有详细信息都可以在终端文档中找到。
此前,我们在“测试指标”部分已经简要讨论过测试器的使用。需要注意的是,与专家顾问不同,指标无法使用测试器的控制功能和优化功能。不过,我个人希望指标能有自适应自动调整的选项:只需为它们支持 OnTester
处理函数即可,我们将在单独的章节中介绍这一点。
如你所知,优化有多种模式可供选择,例如直接枚举专家顾问输入参数的组合、使用加速遗传算法、进行数学计算,或者按市场报价窗口中的交易品种依次运行测试。作为优化标准,你既可以使用常见的指标,如盈利能力、夏普比率、恢复因子和预期回报,也可以使用专家顾问开发者嵌入源代码中的“自定义”变量。在本书的范围内,我们假设读者已经熟悉设置、运行和解读优化结果的原则,因为在本章中我们将开始学习测试器控制 API。有兴趣的读者可以参考文档的相关部分来复习这些知识。
测试器一个特别重要的功能是多线程优化,它可以使用本地和分布式(网络)代理程序来执行,包括 MQL5 云网络中的代理程序。用户手动启动的单次测试运行(使用特定的输入参数),或者在优化过程中调用的多次运行(当我们在给定范围内枚举参数值时),都是在一个单独的程序——代理程序中执行的。从技术上讲,这是 metatester64.exe
文件,在测试和优化过程中,你可以在 Windows 任务管理器中看到它的多个进程副本。正是由于这个原因,测试器才具备多线程能力。
终端就像一个调度器,负责将任务分配给本地和远程代理程序。必要时,它会启动本地代理程序。在优化时,默认会启动多个代理程序,其数量与处理器核心数相对应。代理程序在完成对指定参数的专家顾问测试任务后,会将结果返回给终端。
每个代理程序都会创建自己的交易和软件环境。所有代理程序之间以及与客户端终端都是相互隔离的。
具体来说,代理程序有自己的全局变量和独立的文件沙箱,其中包括用于写入详细代理日志的文件夹:Tester/Agent - IPaddress - Port/Logs
。这里的 Tester
是测试器的安装目录(在与 MetaTrader 5 一起进行标准安装时,这是终端安装目录下的子文件夹)。Agent - IPaddress - Port
目录名中的 IPaddress
和 Port
会替换为与终端通信时使用的具体网络地址和端口值。对于本地代理程序,地址是 127.0.0.1
,默认端口范围从 3000 开始(例如,在一个有 4 核处理器的计算机上,我们会看到端口为 3000、3001、3002、3003 的代理程序)。
在测试专家顾问时,所有文件操作都在 Tester/Agent - IPaddress - Port/MQL5/Files
文件夹中进行。不过,也可以通过共享文件夹实现本地代理程序与客户端终端(以及同一计算机上不同终端副本之间)的交互。为此,在使用 FileOpen
函数打开文件时,必须指定 FILE_COMMON
标志。另一种将数据从代理程序传输到终端的方法是通过帧机制。
出于安全原因(防止不同的专家顾问读取彼此的数据),每个测试开始前,代理程序的本地沙箱会自动清空。
每个代理程序的文件沙箱旁边会创建一个包含报价历史数据的文件夹:Tester/Agent - IPaddress - Port/bases/ServerName/Symbol/
。在下一节中,我们将简要介绍这个文件夹是如何形成的。
终端会将单次测试运行和优化的结果存储在一个特殊的缓存中,该缓存位于安装目录下的 Tester/cache/
子文件夹中。测试结果存储在扩展名为 .tst
的文件中,优化结果存储在 .opt
文件中。这两种文件格式均由 MetaQuotes 开发者开源,因此你可以实现自己的批量分析数据处理,或者使用 mql5.com 网站代码库中的现成源代码。
在本章中,我们首先将探讨 MQL 程序在测试器中的基本工作原理,然后学习如何在实践中与测试器进行交互。
在测试器中生成报价数据点(Ticks)
即使在智能交易系统(Expert Advisor)中没有OnTick
处理程序,也并非必须要有它才能在测试器中对其进行测试。智能交易系统可以使用其他熟悉的函数中的一个或多个:
OnTick
—— 新报价数据点到达的事件处理程序OnTrade
—— 交易事件处理程序OnTradeTransaction
—— 交易事务处理程序OnTimer
—— 定时器信号处理程序OnChartEvent
—— 图表上的事件处理程序,包括自定义图表
同时,在测试器内部,时间进程的主要等效物是一系列报价数据点(Ticks),这些数据点不仅包含价格变化,还包含精确到毫秒的时间。因此,为了测试智能交易系统,有必要生成报价数据点序列。MetaTrader 5测试器有4种报价数据点生成模式:
- 真实报价数据点(如果经纪商提供了其历史数据)
- 每个报价数据点(基于可用的M1时间框架报价进行模拟)
- 来自分钟K线的OHLC价格(1 Minute OHLC)
- 仅开盘价(每根K线1个报价数据点)
另一种操作模式 —— 数学计算 —— 我们稍后再分析,因为它与报价和报价数据点无关。
无论用户选择这4种模式中的哪一种,终端都会加载可用的历史数据进行测试。如果选择了真实报价数据点模式,而经纪商没有该交易品种的真实报价数据点,那么将使用“所有报价数据点”模式。测试器会在其报告中以图形和百分比的形式(其中100%表示所有报价数据点都是真实的)表明报价数据点生成的性质。
在开始测试过程之前,终端会从交易服务器同步并下载在测试器设置中选择的交易品种的历史数据。同时,终端首次会从交易服务器下载所需深度的历史数据(有一定的余量,具体取决于时间框架,至少在测试开始前1年),以便日后无需再次申请。在未来,只会下载新数据。所有这些操作都会在测试器的日志中显示相应的消息。
测试代理在测试开始后会立即从客户端终端获取被测试交易品种的历史数据。如果测试过程中使用了其他交易品种的数据(例如,这是一个多货币智能交易系统),那么在这种情况下,测试代理会在首次调用时从客户端终端请求所需的历史数据。如果终端上有可用的历史数据,它们会立即被传输到测试代理。如果数据缺失,终端会从服务器请求并下载数据,然后将其传输到测试代理。
在交易操作中计算交叉汇率价格时也会使用其他交易品种。例如,当以美元作为存款货币测试欧元兑瑞郎(EURCHF)的策略时,在处理第一笔交易操作之前,测试代理会从客户端终端请求欧元兑美元(EURUSD)和美元兑瑞郎(USDCHF)的历史数据,尽管该策略并不直接涉及这些交易品种。
因此,在测试多货币策略之前,建议您首先将所有必要的历史数据下载到客户端终端。这将有助于避免因恢复数据而导致的测试/优化延迟。例如,您可以通过打开相应的图表并将其滚动到历史数据的开头来下载历史数据。
现在让我们更详细地了解报价数据点生成模式。
来自历史的真实报价数据点
在真实报价数据点上进行测试和优化尽可能接近真实情况。这些报价数据点来自交易所和流动性提供商。
如果在交易品种的历史数据中有分钟K线,但该分钟没有报价数据点数据,测试器将以“每个报价数据点”模式生成报价数据点(详见下文)。这使得在经纪商提供的报价数据点数据不完整的情况下,能够在测试器中构建正确的图表。此外,由于各种原因,报价数据点数据可能与分钟K线不匹配。例如,由于从数据源到客户端终端的数据传输中断或其他故障。在测试时,分钟数据被认为更可靠。
报价数据点存储在策略测试器的交易品种缓存中。缓存大小不超过128,000个报价数据点。当新的报价数据点到达时,最旧的数据会被挤出缓存。但是,使用CopyTicks
函数,您可以获取缓存之外的报价数据点(仅在使用真实报价数据点进行测试时)。在这种情况下,数据将从测试器的报价数据点数据库中请求,该数据库与客户端终端的类似数据库完全对应。不会对该数据库进行基于分钟K线的调整。因此,其中的报价数据点可能与缓存中的报价数据点不同。
每个报价数据点(模拟)
如果没有可用的真实报价数据点历史,或者如果您需要最小化网络流量(因为真实报价数据点的存档可能会消耗大量资源),您可以选择根据可用的M1时间框架报价人工生成报价数据点。
金融工具的报价历史以紧密打包的分钟K线块的形式从交易服务器传输到MetaTrader 5客户端终端。在“时间序列的组织和存储的技术特点”部分中详细介绍了历史数据查询过程和所需时间框架的构建。
价格历史的最小元素是分钟K线,从中您可以获取关于四个OHLC价格值的信息:开盘价(Open)、最高价(High)、最低价(Low)和收盘价(Close)。
新的分钟K线不是在新的一分钟开始(秒数变为0)时开盘,而是在出现至少一个点的价格变化的报价数据点时开盘。同样,我们无法从K线中以秒为精度确定对应于该分钟K线收盘价的报价数据点何时到达:我们只知道一分钟K线的最后一个价格,该价格被记录为收盘价。
因此,对于每个分钟K线,我们知道4个控制点,可以肯定价格曾经在这些点上。如果K线只有4个报价数据点,那么这些信息对于测试来说就足够了,但通常报价数据点数量会超过4个。这意味着有必要为在开盘价、最高价、最低价和收盘价之间到达的报价数据点生成额外的检查点。“每个报价数据点”模式下生成报价数据点的基本原理在文档中有描述。
在“每个报价数据点”模式下进行测试时,智能交易系统的OnTick
函数将在每个生成的报价数据点上被调用。智能交易系统将以与在线工作时相同的方式接收时间和买价/卖价/最新价。
“每个报价数据点”测试模式是最准确的(仅次于真实报价数据点模式),但也是最耗时的。对于大多数交易策略的初步评估,通常使用两种简化测试模式之一就足够了:基于M1时间框架的OHLC价格或基于所选时间框架K线的开盘价。
1分钟OHLC
在“1分钟OHLC”模式下,报价数据点序列仅根据分钟K线的OHLC价格构建,OnTick
函数的调用次数会显著减少;因此,测试时间也会减少。这是一种非常高效、有用的模式,在测试准确性和速度之间提供了一个折衷方案。然而,当涉及到其他人的智能交易系统时,需要谨慎使用此模式。
拒绝在开盘价、最高价、最低价和收盘价之间生成额外的中间报价数据点,会导致从确定开盘价的那一刻起,价格走势出现严格的确定性。这使得有可能创建一个“测试圣杯”,在测试时显示出漂亮的上升趋势余额图表。
对于一个分钟K线,已知4个价格,其中第一个是开盘价,最后一个是收盘价。在它们之间记录的价格是最高价和最低价,并且关于它们出现顺序的信息丢失了,但我们知道最高价大于或等于开盘价,最低价小于或等于开盘价。
在收到开盘价后,我们只需要分析下一个报价数据点,以确定它是最高价还是最低价。如果价格低于开盘价,这就是最低价 —— 在这个报价数据点上买入,因为下一个报价数据点将对应于最高价,我们在这个最高价上平仓买入并开仓卖出。下一个报价数据点是K线上的最后一个,即收盘价,我们在这个收盘价上平仓卖出。
如果在我们的价格之后出现一个价格高于开盘价的报价数据点,那么交易顺序将相反。看似在这种模式下可以在每根K线上进行交易。当在历史数据上测试这样的智能交易系统时,一切都很完美,但在在线交易时它会失败。
由于计算算法(例如,统计计算)和报价数据点生成的特征组合,可能会无意中出现类似的效果。
因此,在粗略测试模式(“1分钟OHLC”和“仅开盘价”)下找到智能交易系统的最佳设置后,始终在“每个报价数据点”模式下进行测试,或者更好的是,基于真实报价数据点进行测试,这一点非常重要。
仅开盘价
在这种模式下,使用为测试选择的时间框架的OHLC价格生成报价数据点。在这种情况下,OnTick
函数仅在每根K线的开头运行一次。由于这个特点,止损水平和挂单可能会在与请求价格不同的价格上触发(尤其是在更高的时间框架上进行测试时)。作为交换,我们获得了快速对智能交易系统进行评估测试的机会。
例如,在“仅开盘价”模式下对欧元兑美元(EURUSD)的H1时间框架进行智能交易系统测试。在这种情况下,报价数据点(控制点)的总数将是测试区间内小时K线数量的4倍。但在这种情况下,OnTick
处理程序只会在小时K线开盘时被调用。对于其余的报价数据点(对智能交易系统“隐藏”),将执行正确测试所需的以下检查:
- 保证金要求的计算
- 止损(Stop Loss)和止盈(Take Profit)的触发
- 挂单的触发
- 挂单到期时的删除
如果没有未平仓头寸或挂单,那么对于隐藏的报价数据点就无需进行这些检查,速度提升可能会非常显著。
在“仅开盘价”模式下生成报价数据点时,周线(W1)和月线(MN1)周期是例外情况:对于这些时间框架,分别为每天的OHLC价格生成报价数据点,而不是每周或每月。
这种“仅开盘价”模式非常适合测试仅在K线开盘时进行交易、不使用挂单并且不使用止损和止盈水平的策略。对于这类策略,所有必要的测试准确性都得以保留。
MQL5 API不允许程序在测试器中查明它正在以哪种模式运行。同时,这对于智能交易系统或它们使用的指标可能很重要,例如,这些智能交易系统或指标并非设计为在开盘价或OHLC价格下正确工作。因此,我们实现了一个简单的模式检测机制。源代码附在TickModel.mqh
文件中。
让我们声明具有现有模式的枚举。
c
enum TICK_MODEL
{
TICK_MODEL_UNKNOWN = -1, /*未知 (任意)*/ // 未知/尚未定义
TICK_MODEL_REAL = 0, /*真实报价数据点*/ // 最佳质量
TICK_MODEL_GENERATED = 1, /*生成的报价数据点*/ // 良好质量
TICK_MODEL_OHLC_M1 = 2, /*OHLC M1*/ // 可接受质量且快速
TICK_MODEL_OPEN_PRICES = 3, /*开盘价*/ // 质量较差,但非常快
TICK_MODEL_MATH_CALC = 4, /*数学计算*/// 无报价数据点 (未定义)
};
除了第一个元素(保留用于模式尚未确定或由于某种原因无法确定的情况)之外,所有其他元素按模拟质量从高到低排列,从真实报价数据点开始,到开盘价结束(对于开盘价,开发人员必须检查策略与仅在新K线开盘时进行交易这一事实的兼容性)。最后一个模式TICK_MODEL_MATH_CALC
完全不使用报价数据点运行;我们将单独考虑它。
模式检测原理基于在测试开始时检查前两个报价数据点的可用性及其时间。检查本身包含在getTickModel
函数中,智能交易系统应从OnTick
处理程序中调用该函数。由于检查只进行一次,因此在函数内部最初将静态变量model
描述为TICK_MODEL_UNKNOWN
。它将存储并切换检查的当前状态,这对于区分OHLC模式和开盘价模式是必需的。
c
TICK_MODEL getTickModel()
{
static TICK_MODEL model = TICK_MODEL_UNKNOWN;
...
在分析的第一个报价数据点上,model
等于TICK_MODEL_UNKNOWN
,并尝试通过调用CopyTicks
获取真实报价数据点。
c
if(model == TICK_MODEL_UNKNOWN)
{
MqlTick ticks[];
const int n = CopyTicks(_Symbol, ticks, COPY_TICKS_ALL, 0, 10);
if(n == -1)
{
switch(_LastError)
{
case ERR_NOT_ENOUGH_MEMORY: // 模拟报价数据点
model = TICK_MODEL_GENERATED;
break;
case ERR_FUNCTION_NOT_ALLOWED: // 开盘价和OHLC价格
if(TimeCurrent() != iTime(_Symbol, _Period, 0))
{
model = TICK_MODEL_OHLC_M1;
}
else if(model == TICK_MODEL_UNKNOWN)
{
model = TICK_MODEL_OPEN_PRICES;
}
break;
}
Print(E2S(_LastError));
}
else
{
model = TICK_MODEL_REAL;
}
}
...
如果成功,检测立即结束,并将model
设置为TICK_MODEL_REAL
。如果无法获取真实报价数据点,系统将返回某个错误代码,根据该错误代码我们可以得出以下结论。错误代码ERR_NOT_ENOUGH_MEMORY
对应于报价数据点模拟模式。为什么是这个代码并不完全清楚,但这是一个特征,我们在这里使用它。在另外两种报价数据点生成模式中,我们将得到ERR_FUNCTION_NOT_ALLOWED
错误。
您可以通过报价数据点时间来区分一种模式与另一种模式。如果发现某个报价数据点的时间不是时间框架的倍数,那么我们讨论的就是OHLC模式。然而,这里的问题是,在两种模式中,第一个报价数据点都可能与K线开盘时间对齐。因此,我们将得到TICK_MODEL_OPEN_PRICES
值,但需要进一步确定它。因此,为了得出最终结论,应该再分析一个报价数据点(如果之前收到了TICK_MODEL_OPEN_PRICES
,则再次在该报价数据点上调用该函数)。对于这种情况,函数内部提供了以下if
分支。
c
else if(model == TICK_MODEL_OPEN_PRICES)
{
if(TimeCurrent() != iTime(_Symbol, _Period, 0))
{
model = TICK_MODEL_OHLC_M1;
}
}
return model;
}
让我们在一个简单的智能交易系统TickModel.mq5
中检查检测器的操作。在TickCount
输入参数中,我们指定要分析的报价数据点的最大数量,即getTickModel
函数将被调用的次数。我们知道两次就足够了,但为了确保之后模式不会改变,默认建议分析5个报价数据点。我们还提供了RequireTickModel
参数,该参数指示智能交易系统如果模拟级别低于请求的级别,则终止操作。默认情况下,其值为TICK_MODEL_UNKNOWN
,这意味着没有模式限制。
c
input int TickCount = 5;
input TICK_MODEL RequireTickModel = TICK_MODEL_UNKNOWN;
在OnTick
处理程序中,我们仅在它在测试器中运行时运行我们的代码。
c
void OnTick()
{
if(MQLInfoInteger(MQL_TESTER))
{
static int count = 0;
if(count++ < TickCount)
{
// 输出报价数据点信息以供参考
static MqlTick tick[1];
SymbolInfoTick(_Symbol, tick[0]);
ArrayPrint(tick);
// 定义并显示模式 (初步)
const TICK_MODEL model = getTickModel();
PrintFormat("%d %s", count, EnumToString(model));
// 如果报价数据点计数器为2或更大,结论是最终的,我们根据它采取行动
if(count >= 2)
{
if(RequireTickModel != TICK_MODEL_UNKNOWN
&& RequireTickModel < model) // 质量低于请求的质量
{
PrintFormat("Tick model is incorrect (%s %sis required), terminating",
EnumToString(RequireTickModel),
(RequireTickModel != TICK_MODEL_REAL ? "or better " : ""));
ExpertRemove(); // 结束操作
}
}
}
}
}
让我们尝试在测试器中以不同的报价数据点生成模式运行智能交易系统,选择常见的欧元兑美元(EURUSD)H1组合。
智能交易系统中的RequireTickModel
参数设置为OHLC M1。如果测试器模式是“每个报价数据点”,我们将在日志中收到相应的消息,并且智能交易系统将继续运行。
[时间] [买价] [卖价] [最新价] [交易量] [时间毫秒] [标志] [真实交易量]
[0] 2022.04.01 00:00:30 1.10656 1.10679 1.10656 0 1648771230000 14 0.00000
NOT_ENOUGH_MEMORY
1 TICK_MODEL_GENERATED
[时间] [买价] [卖价] [最新价] [交易量] [时间毫秒] [标志] [真实交易量]
[0] 2022.04.01 00:01:00 1.10656 1.10680 1.10656 0 1648771260000 12 0.00000
2 TICK_MODEL_GENERATED
[时间] [买价] [卖价] [最新价] [交易量] [时间毫秒] [标志] [真实交易量]
[0] 2022.04.01 00:01:30 1.10608 1.10632 1.10608 0 1648771290000 14 0.00000
3 TICK_MODEL_GENERATED
OHLC M1模式和真实报价数据点模式也同样适用,并且在使用真实报价数据点模式时,不会出现错误代码。
[时间] [买价] [卖价] [最新价] [交易量] [时间毫秒] [标志] [真实交易量]
[0] 2022.04.01 00:00:00 1.10656 1.10687 0.0000 0 1648771200122 134 0.00000
1 TICK_MODEL_REAL
[时间] [买价] [卖价] [最新价] [交易量] [时间毫秒] [标志] [真实交易量]
[0] 2022.04.01 00:00:00 1.10656 1.10694 0.0000 0 1648771200417 4 0.00000
2 TICK_MODEL_REAL
[时间] [买价] [卖价] [最新价] [交易量] [时间毫秒] [标志] [真实交易量]
[0] 2022.04.01 00:00:00 1.10656 1.10691 0.0000 0 1648771200816 4 0.00000
3 TICK_MODEL_REAL
然而,如果将测试器中的模式更改为“仅开盘价”,智能交易系统将在处理完第二个报价数据点后停止运行。
[时间] [买价] [卖价] [最新价] [交易量] [时间毫秒] [标志] [真实交易量]
[0] 2022.04.01 00:00:00 1.10656 1.10679 1.10656 0 1648771200000 14 0.00000
FUNCTION_NOT_ALLOWED
1 TICK_MODEL_OPEN_PRICES
[时间] [买价] [卖价] [最新价] [交易量] [时间毫秒] [标志] [真实交易量]
[0] 2022.04.01 01:00:00 1.10660 1.10679 1.10660 0 1648774800000 14 0.00000
2 TICK_MODEL_OPEN_PRICES
Tick model is incorrect (TICK_MODEL_OHLC_M1 or better is required), terminating
ExpertRemove()函数被调用
这种方法需要运行测试并等待几个报价数据点,以便确定模式。换句话说,我们无法通过从OnInit
函数返回错误来提前停止测试。更有甚者,当使用错误类型的报价数据点生成方式启动优化时,我们无法停止优化,而只能从OnTesterInit
函数中停止优化。因此,测试器将尝试在优化过程中完成所有的遍历,尽管它们会在一开始就被停止。这是当前平台的限制。
测试可视化:图表、对象、指标
测试器允许以两种不同的方式进行测试:带可视化和不带可视化。通过在测试器的主设置选项卡上选择相应的选项来选择测试方式。
当启用可视化时,测试器会打开一个单独的窗口,在其中重现交易操作,并显示指标和对象。尽管可视化很直观,但并非每种情况都需要查看,只有那些带有用户界面的程序(例如,交易面板或由图形对象创建的可控标记)才需要。对于其他智能交易系统,重要的是算法按照既定策略执行。这可以在无可视化的情况下进行检查,这样可以显著加快测试过程。顺便说一下,在进行优化时就是以这种模式运行测试的。
在这种 “后台” 测试和优化过程中,不会构建图形对象。因此,当智能交易系统访问对象的属性时,将收到零值。所以,只有在可视化模式下进行测试时,才能检查与对象和图表相关的操作。
之前,在 “测试指标” 部分,我们已经了解了指标在测试器中的特定行为。为了提高智能交易系统(使用指标)的非可视化测试和优化效率,指标并非在每个报价点都进行计算,而是仅在我们向它们请求数据时才计算。只有当指标中存在 EventChartCustom
、OnChartEvent
、OnTimer
函数或测试器的 tester_everytick_calculate
预处理指令(请参阅测试器的预处理指令)时,才会在每个报价点进行重新计算。在可视化测试器窗口中,在线指标总是在每个报价点都会接收到 OnCalculate
事件。
如果在非可视化模式下进行测试,测试完成后,品种图表会自动在终端中打开,该图表会显示已完成的交易以及智能交易系统中使用的指标。这有助于将市场的入场和出场时刻与指标值关联起来。然而,这里指的仅仅是在测试的品种和时间框架上工作的指标。如果智能交易系统在其他品种或时间框架上创建了指标,这些指标将不会显示。
需要注意的是,测试完成后自动打开的图表上显示的指标会在测试结束后重新计算。即使这些指标在被测试的智能交易系统中使用过,并且之前是在柱线形成时 “实时” 计算的,这种重新计算也会发生。
在某些情况下,程序员可能需要隐藏交易算法中使用了哪些指标的信息,因此不希望这些指标在图表上可视化显示。这时可以使用 IndicatorRelease
函数来实现。
IndicatorRelease
函数最初的目的是在不再需要指标的计算部分时释放它。这可以节省内存和处理器资源。它的第二个目的是禁止在单次运行测试完成后,指标在测试图表上显示。
要在测试结束时禁止指标在图表上显示,只需在 OnDeinit
处理程序中使用指标句柄调用 IndicatorRelease
函数即可。OnDeinit
函数总是在智能交易系统测试完成后且在显示测试图表之前被调用。在测试器中,指标本身不会调用 OnDeinit
函数,全局和静态对象的析构函数也不会被调用 —— 这是 MetaTrader 5 的开发者所达成的共识。
此外,MQL5 API 包含一个具有类似目的的特殊函数 TesterHideIndicators
,我们稍后会介绍它。
同时,应该考虑到 tpl 模板(如果已创建)可能会对测试图表的外部显示产生额外影响。
所以,如果在 MQL5/Profiles/Templates
目录中有 tester.tpl
模板,它将应用于打开的图表。如果智能交易系统在其工作中使用了其他指标,并且没有禁止这些指标的显示,那么模板中的指标和智能交易系统中的指标将在图表上组合显示。
当不存在 tester.tpl
时,将应用默认模板(default.tpl
)。
如果 MQL5/Profiles/Templates
文件夹中包含一个与智能交易系统同名的 tpl 模板(例如,ExpertMACD.tpl
),那么在可视化测试期间或测试后打开的图表上,将只显示来自该模板的指标。在这种情况下,被测试的智能交易系统中使用的指标将不会显示。
多货币测试
如你所知,MetaTrader 5 测试器允许对交易多种金融工具的策略进行测试。从纯粹技术角度来看,只要计算机硬件资源允许,就可以模拟所有可用工具的同时交易。
对这类策略进行测试,对测试器提出了一些额外的技术要求:
- 为所有工具生成报价序列。
- 计算所有工具的指标。
- 计算所有工具的保证金要求,并模拟其他交易条件。
测试器在首次访问历史数据时,会自动从终端下载所需工具的历史数据。如果终端中没有所需的历史数据,它会转而从交易服务器请求数据。因此,在测试多货币智能交易系统(Expert Advisor)之前,建议在终端的 “市场报价” 中选择所需的工具,并下载所需数量的数据。
测试代理会多下载一些缺失的历史数据,以提供计算指标或在测试时供智能交易系统复制所需的数据。从交易服务器下载的最小历史数据量取决于时间框架。例如,对于日线图(D1)及更低时间框架,是一年的数据。也就是说,相对于测试器的开始日期,会从前一年年初开始下载初步历史数据。如果从 1 月 1 日开始测试,这至少会提供 1 年的历史数据;如果从 12 月开始测试,则最多可获得近 2 年的历史数据。对于周线图时间框架,会请求 100 根柱线的历史数据,即大约 2 年(一年有 52 周)。对于月线图时间框架的测试,测试代理会请求 100 个月的数据(约等于 8 年的历史数据:12 个月×8 年 = 96 个月)。在任何情况下,在低于工作时间框架的时间框架上,都可以获得按比例更多的柱线数据。如果现有数据不足以满足预定义的初步历史数据深度,这一情况将记录在测试日志中。
你无法配置(更改)这种行为。因此,如果你需要从一开始就提供当前时间框架的指定数量的历史柱线,你应该为测试设置一个更早的开始日期,然后在智能交易系统代码中 “等待” 所需的交易开始日期或足够数量的柱线。在此之前,你应该跳过所有事件。
测试器还会模拟其自身的 “市场报价”,程序可以从中获取工具信息。默认情况下,在测试开始时,测试器的 “市场报价” 中只包含一个交易品种:即开始测试的那个交易品种。当通过 API 函数访问其他交易品种时,所有额外的交易品种会自动添加到测试器的 “市场报价” 中。当 MQL 程序首次访问 “第三方” 交易品种时,测试代理会将该交易品种的数据与终端进行同步。
在以下情况下,可以访问额外交易品种的数据:
- 使用针对交易品种/时间框架对的技术指标、
iCustom
或IndicatorCreate
。 - 查询另一个交易品种的 “市场报价”:
SeriesInfoInteger
Bars
SymbolSelect
SymbolIsSynchronized
SymbolInfoDouble
SymbolInfoInteger
SymbolInfoString
SymbolInfoTick
SymbolInfoSessionQuote
SymbolInfoSessionTrade
MarketBookAdd
MarketBookGet
3.使用以下函数查询交易品种/时间框架对的时间序列:CopyBuffer
CopyRates
CopyTime
CopyOpen
CopyHigh
CopyLow
CopyClose
CopyTickVolume
CopyRealVolume
CopySpread
此外,你可以在 OnInit
处理函数中调用 SymbolSelect
函数,显式请求所需交易品种的历史数据。历史数据将在智能交易系统测试开始前预先加载。
在首次访问另一个交易品种的那一刻,测试过程会停止,该交易品种/周期对的历史数据会从终端下载到测试代理中。此时,报价序列生成也会启动。
每个工具都会根据设置的报价生成模式生成自己的报价序列。
在实现多货币智能交易系统时,不同交易品种的柱线同步尤为重要,因为计算的正确性取决于此。当所有使用的交易品种的最后一根柱线具有相同的开盘时间时,就认为处于同步状态。
测试器会为每个工具生成并播放其报价序列。同时,每个工具上的新柱线的开盘与其他工具上柱线的开盘情况无关。这意味着,在测试多货币智能交易系统时,可能会出现(而且大多数情况下确实会出现)一种情况:一个工具上已经开盘了新的柱线,但另一个工具上还没有。
例如,如果我们正在使用欧元兑美元(EURUSD)交易品种的数据测试一个智能交易系统,并且该交易品种的新一小时蜡烛图已经开盘,我们将收到 OnTick
事件。但与此同时,不能保证我们可能也在使用的英镑兑美元(GBPUSD)交易品种上也开盘了新的蜡烛图。
因此,同步算法意味着你需要检查所有工具的报价,并等待最后一根柱线的开盘时间相等。
只要使用真实报价、模拟所有报价或基于 1 分钟(M1)开盘价、最高价、最低价、收盘价(OHLC)的测试模式,这就不会产生任何问题。在这些模式下,在一根蜡烛图内会生成足够数量的报价,以便等待不同交易品种的柱线同步时刻。只需完成 OnTick
函数,并在下一个报价时检查英镑兑美元(GBPUSD)上是否出现了新柱线即可。但是,当在 “仅开盘价” 模式下进行测试时,将不会有其他报价,因为智能交易系统每根柱线仅被调用一次,而且看起来这种模式似乎不适合测试多货币智能交易系统。实际上,测试器允许你使用 Sleep
函数(在循环中)或定时器来检测另一个交易品种上新柱线开盘的时刻。
首先,让我们来看一个智能交易系统 SyncBarsBySleep.mq5
的示例,它展示了如何通过 Sleep
函数实现柱线同步。
有一对输入参数,可让你设置等待其他交易品种柱线的暂停时间(以秒为单位,Pause
),以及另一个交易品种的名称(OtherSymbol
),该名称必须与图表交易品种不同。
cpp
input uint Pause = 1; // Pause (seconds)
input string OtherSymbol = "USDJPY";
为了识别柱线开盘时间延迟的模式,我们定义了一个简单的类 BarTimeStatistics
。它包含一个用于计算柱线总数的字段(total
),以及最初没有同步的柱线数量(late
),即另一个交易品种延迟的柱线数量。
cpp
class BarTimeStatistics
{
public:
int total;
int late;
BarTimeStatistics(): total(0), late(0) { }
~BarTimeStatistics()
{
PrintFormat("%d bars on %s was late among %d total bars on %s (%2.1f%%)",
late, OtherSymbol, total, _Symbol, late * 100.0 / total);
}
};
这个类的对象会在其析构函数中打印接收到的统计信息。由于我们要将这个对象设置为静态的,所以报告将在测试的最后打印出来。
如果测试器中选择的报价生成模式与开盘价模式不同,我们将使用之前介绍的 getTickModel
函数检测到这一点,并返回一个警告。
cpp
void OnTick()
{
const TICK_MODEL model = getTickModel();
if(model != TICK_MODEL_OPEN_PRICES)
{
static bool shownOnce = false;
if(!shownOnce)
{
Print("This Expert Advisor is intended to run in \"Open Prices\" mode");
shownOnce = true;
}
}
接下来,OnTick
函数提供了工作同步算法。
cpp
// time of the last known bar for _Symbol
static datetime lastBarTime = 0;
// attribute of synchronization
static bool synchronized = false;
// bar counters
static BarTimeStatistics stats;
const datetime currentTime = iTime(_Symbol, _Period, 0);
// if it is executed for the first time or the bar has changed, save the bar
if(lastBarTime != currentTime)
{
stats.total++;
lastBarTime = currentTime;
PrintFormat("Last bar on %s is %s", _Symbol, TimeToString(lastBarTime));
synchronized = false;
}
// time of the last known bar for another symbol
datetime otherTime;
bool late = false;
// wait until the times of two bars become the same
while(currentTime != (otherTime = iTime(OtherSymbol, _Period, 0)))
{
late = true;
PrintFormat("Wait %d seconds...", Pause);
Sleep(Pause * 1000);
}
if(late) stats.late++;
// here we are after synchronization, save the new status
if(!synchronized)
{
// use TimeTradeServer() because TimeCurrent() does not change in the absence of ticks
Print("Bars are in sync at ", TimeToString(TimeTradeServer(),
TIME_DATE | TIME_SECONDS));
// no longer print a message until the next out of sync
synchronized = true;
}
// here is your synchronous algorithm
// ...
}
让我们设置测试器,在欧元兑美元(EURUSD)、1 小时(H1)时间框架上运行该智能交易系统,这是流动性最强的交易品种。我们使用智能交易系统的默认参数,即美元兑日元(USDJPY)将作为 “另一个” 交易品种。
测试结果中,日志将包含以下记录(我们特意展示了与下载美元兑日元(USDJPY)历史数据相关的日志,这发生在首次调用 iTime
函数时)。
2022.04.15 00:00:00 Last bar on EURUSD is 2022.04.15 00:00
USDJPY: load 27 bytes of history data to synchronize in 0:00:00.001
USDJPY: history synchronized from 2020.01.02 to 2022.04.20
USDJPY,H1: history cache allocated for 8109 bars and contains 8006 bars from 2021.01.04 00:00 to 2022.04.14 23:00
USDJPY,H1: 1 bar from 2022.04.15 00:00 added
USDJPY,H1: history begins from 2021.01.04 00:00
2022.04.15 00:00:00 Bars are in sync at 2022.04.15 00:00:00
2022.04.15 01:00:00 Last bar on EURUSD is 2022.04.15 01:00
2022.04.15 01:00:00 Wait 1 seconds...
2022.04.15 01:00:01 Bars are in sync at 2022.04.15 01:00:01
2022.04.15 02:00:00 Last bar on EURUSD is 2022.04.15 02:00
2022.04.15 02:00:00 Wait 1 seconds...
2022.04.15 02:00:01 Bars are in sync at 2022.04.15 02:00:01
...
2022.04.20 23:59:59 95 bars on USDJPY was late among 96 total bars on EURUSD (99.0%)
你可以看到,美元兑日元(USDJPY)的柱线经常出现延迟。如果你在测试器设置中选择美元兑日元(USDJPY)、1 小时(H1),并在智能交易系统参数中选择欧元兑美元(EURUSD),你会得到相反的结果。
2022.04.15 00:00:00 Last bar on USDJPY is 2022.04.15 00:00
EURUSD: load 27 bytes of history data to synchronize in 0:00:00.002
EURUSD: history synchronized from 2018.01.02 to 2022.04.20
EURUSD,H1: history cache allocated for 8109 bars and contains 8006 bars from 2021.01.04 00:00 to 2022.04.14 23:00
EURUSD,H1: 1 bar from 2022.04.15 00:00 added
EURUSD,H1: history begins from 2021.01.04 00:00
2022.04.15 00:00:00 Bars are in sync at 2022.04.15 00:00:00
2022.04.15 01:00:00 Last bar on USDJPY is 2022.04.15 01:00
2022.04.15 01:00:00 Wait 1 seconds...
2022.04.15 01:00:01 Bars are in sync at 2022.04.15 01:00:01
2022.04.15 02:00:00 Last bar on USDJPY is 2022.04.15 02:00
2022.04.15 02:00:00 Wait 1 seconds...
2022.04.15 02:00:01 Bars are in sync at 2022.04.15 02:00:01
...
2022.04.20 23:59:59 23 bars on EURUSD was late among 96 total bars on USDJPY (24.0%)
在这里,大多数情况下无需等待:在美元兑日元(USDJPY)柱线形成时,欧元兑美元(EURUSD)的柱线已经存在了。
还有另一种同步柱线的方法:使用定时器。本书中包含了这样一个智能交易系统 SyncBarsByTimer.mq5
的示例。请注意,定时器事件通常发生在柱线内部(因为恰好命中柱线开始时刻的概率非常低)。因此,柱线几乎总是同步的。
我们也可以提醒你,使用间谍指标 EventTickSpy.mq5
也可以实现柱线同步,但它基于自定义事件,仅在可视化测试时起作用。此外,对于需要对每个报价做出响应的此类指标,使用 #property tester_everytick_calculate
指令非常重要。我们在 “测试指标” 部分已经讨论过这个指令,并且在关于特定测试器指令的部分还会再次提醒你。
优化标准
优化标准是一种特定的度量指标,用于定义所测试的输入参数集的质量。优化标准的值越高,对于给定参数集的测试结果的评估就越好。该参数在“优化”字段右侧的“设置”选项卡中进行选择。
这一标准不仅对用户比较测试结果很重要。如果没有优化标准,就无法使用遗传算法,因为遗传算法是基于该标准“决定”如何为新一代选择候选参数的。在对所有可能的参数变体进行完整迭代的全面优化过程中,并不使用这一标准。
测试器中提供了以下内置的优化标准:
- 最大账户余额
- 最高盈利能力
- 最大预期盈利(每笔交易的平均盈亏)
- 以权益百分比计算的最小回撤
- 最大恢复因子
- 最高夏普比率
- 自定义优化标准
当选择最后一个选项时,在专家顾问中实现的 OnTester
函数的值将被用作优化标准——我们稍后会对此进行讨论。这个参数允许程序员使用任何自定义指标进行优化。
MetaTrader 5 中还提供了一个特殊的“综合标准”。这是一个衡量测试过程质量的综合指标,它同时考虑了多个参数:
- 交易次数
- 回撤
- 恢复因子
- 盈利的数学期望
- 夏普比率
开发者并未公开其计算公式,但已知其可能的取值范围是 0 到 100。重要的是,无论选择何种标准,综合参数的值都会影响优化表中“结果”列单元格的颜色。也就是说,即使在“结果”列中选择了另一个标准进行显示,按照这一方案的突出显示效果仍然有效。值低于 20 的较弱参数组合会以红色突出显示,值高于 80 的较强参数组合会以深绿色突出显示。
对于大多数交易者而言,寻找一种通用的交易系统质量因子标准是一项紧迫且困难的任务,因为仅基于某一个标准(例如利润)的最大值来选择设置,通常远非在可预见的未来中保证专家顾问稳定且可预测运行的最佳选择。
综合指标的存在能够平衡每个单独度量指标的弱点(这些弱点必然存在且广为人知),并且在开发自己的用于在 OnTester
中计算的自定义变量时提供指导。我们很快就会涉及到这方面的内容。
获取测试财务统计数据:TesterStatistics
我们通常根据交易报告来评估智能交易系统(Expert Advisor)的质量,在使用测试器时,该交易报告类似于测试报告。它包含大量的变量,这些变量描述了交易风格、稳定性,当然还有盈利能力。除了一些例外情况,所有这些指标都可以通过MQL程序中的一个特殊函数TesterStatistics
来获取。因此,智能交易系统的开发者能够在代码中分析各个变量,并从这些变量中构建自己的综合优化质量标准。
c
double TesterStatistics(ENUM_STATISTICS statistic)
TesterStatistics
函数返回指定统计变量的值,该值是根据智能交易系统在测试器中单独运行的结果计算得出的。可以在OnDeinit
或OnTester
处理程序中调用该函数,关于OnTester
处理程序我们稍后会讨论。
所有可用的统计变量都汇总在ENUM_STATISTICS
枚举中。其中一些作为定性特征,即实数(通常是总利润、回撤、比率等等),另一部分是定量特征,即整数(例如,交易数量)。然而,这两组变量都由返回double
类型结果的同一个函数来控制。
以下表格显示了实际指标(金额和系数)。所有金额均以存款货币表示。
标识符 | 描述 |
---|---|
STAT_INITIAL_DEPOSIT | 初始存款 |
STAT_WITHDRAWAL | 从账户中提取的资金金额 |
STAT_PROFIT | 测试结束时的净利润或净亏损,即STAT_GROSS_PROFIT 与STAT_GROSS_LOSS 之和 |
STAT_GROSS_PROFIT | 总利润,所有盈利交易的总和(大于或等于零) |
STAT_GROSS_LOSS | 总亏损,所有亏损交易的总和(小于或等于零) |
STAT_MAX_PROFITTRADE | 最大盈利:所有盈利交易中的最大值(大于或等于零) |
STAT_MAX_LOSSTRADE | 最大亏损:所有亏损交易中的最小值(小于或等于零) |
STAT_CONPROFITMAX | 一系列盈利交易中的总最大利润(大于或等于零) |
STAT_MAX_CONWINS | 最长盈利交易序列的总利润 |
STAT_CONLOSSMAX | 一系列亏损交易中的总最大亏损(小于或等于零) |
STAT_MAX_CONLOSSES | 最长亏损交易序列的总亏损 |
STAT_BALANCEMIN | 最小余额值 |
STAT_BALANCE_DD | 以货币金额表示的最大余额回撤 |
STAT_BALANCEDD_PERCENT | 以百分比表示的余额回撤,记录于以货币金额表示的最大余额回撤(STAT_BALANCE_DD )发生时 |
STAT_BALANCE_DDREL_PERCENT | 以百分比表示的最大余额回撤 |
STAT_BALANCE_DD_RELATIVE | 以货币金额等效值表示的余额回撤,记录于以百分比表示的最大余额回撤(STAT_BALANCE_DDREL_PERCENT )发生时 |
STAT_EQUITYMIN | 最小权益值 |
STAT_EQUITY_DD | 以货币金额表示的最大回撤 |
STAT_EQUITYDD_PERCENT | 以百分比表示的回撤,记录于以货币金额表示的资金最大回撤(STAT_EQUITY_DD )发生时 |
STAT_EQUITY_DDREL_PERCENT | 以百分比表示的最大回撤 |
STAT_EQUITY_DD_RELATIVE | 以货币金额表示的回撤,记录于以百分比表示的最大回撤(STAT_EQUITY_DDREL_PERCENT )发生时 |
STAT_EXPECTED_PAYOFF | 盈利的数学期望(总利润与交易数量的算术平均值) |
STAT_PROFIT_FACTOR | 盈利能力,即STAT_GROSS_PROFIT /STAT_GROSS_LOSS 的比率(如果STAT_GROSS_LOSS = 0;盈利能力取值为DBL_MAX ) |
STAT_RECOVERY_FACTOR | 恢复因子:STAT_PROFIT /STAT_BALANCE_DD 的比率 |
STAT_SHARPE_RATIO | 夏普比率 |
STAT_MIN_MARGINLEVEL | 达到的最小保证金水平 |
STAT_CUSTOM_ONTESTER | OnTester 函数返回的自定义优化标准的值 |
以下表格显示了整数指标(数量)。
标识符 | 描述 |
---|---|
STAT_DEALS | 已完成交易的总数 |
STAT_TRADES | 交易数量(退出市场的交易) |
STAT_PROFIT_TRADES | 盈利交易 |
STAT_LOSS_TRADES | 亏损交易 |
STAT_SHORT_TRADES | 空头交易 |
STAT_LONG_TRADES | 多头交易 |
STAT_PROFIT_SHORTTRADES | 空头盈利交易 |
STAT_PROFIT_LONGTRADES | 多头盈利交易 |
STAT_PROFITTRADES_AVGCON | 盈利交易序列的平均长度 |
STAT_LOSSTRADES_AVGCON | 亏损交易序列的平均长度 |
STAT_CONPROFITMAX_TRADES | 构成STAT_CONPROFITMAX (盈利交易序列中的最大利润)的交易数量 |
STAT_MAX_CONPROFIT_TRADES | 最长盈利交易序列STAT_MAX_CONWINS 中的交易数量 |
STAT_CONLOSSMAX_TRADES | 构成STAT_CONLOSSMAX (亏损交易序列中的最大亏损)的交易数量 |
STAT_MAX_CONLOSS_TRADES | 最长亏损交易序列STAT_MAX_CONLOSSES 中的交易数量 |
让我们尝试使用上述指标来创建我们自己的智能交易系统质量的综合标准。为此,我们需要一个MQL程序的“实验性”示例。我们以智能交易系统MultiMartingale.mq5
为起点,但我们会对其进行简化:我们将去除多货币功能、内置错误处理和时间安排功能。此外,我们将为其选择一种信号交易策略,在每根K线开盘价时进行一次计算。这将加快优化速度,并为实验提供更广阔的空间。
该策略将基于由OsMA指标确定的超买和超卖条件。叠加在OsMA指标上的布林带(Bollinger Bands)指标将帮助您动态地找到过度波动的边界,这也就意味着交易信号。
当OsMA指标回到通道内,从下往上穿过下边界时,我们将开仓买入。当OsMA指标以同样的方式从上往下穿过上边界时,我们将开仓卖出。为了平仓,我们使用同样应用于OsMA指标的移动平均线(MA)。如果OsMA指标显示反向运动(对于多头头寸是向下,对于空头头寸是向上)并且触及移动平均线,头寸将被平仓。以下截图展示了该策略。
基于OsMA、BBands和MA指标的交易策略
蓝色垂直线对应于开仓买入的K线,因为在前两根K线上,OsMA指标的柱状图从下往上穿过了布林带下轨(在子窗口中用空心蓝色箭头标记了这个位置)。红色垂直线是反向信号的位置,因此买入头寸被平仓并开仓卖出。在子窗口中,在这个位置(确切地说,在前两根K线上,空心红色箭头所在的位置),OsMA指标的柱状图从上往下穿过了布林带上轨。最后,绿色线表示卖出头寸的平仓,因为柱状图开始上升并超过了红色的移动平均线。
我们将这个智能交易系统命名为BandOsMA.mq5
。常规设置将包括一个魔术数字、固定的手数以及止损点数距离。对于止损,我们将使用上一个示例中的追踪止损(TrailingStop)。这里不使用止盈。
c
input group "C O M M O N S E T T I N G S"
sinput ulong Magic = 1234567890;
input double Lots = 0.01;
input int StopLoss = 1000;
有三组设置用于指标。
c
input group "O S M A S E T T I N G S"
input int FastOsMA = 12;
input int SlowOsMA = 26;
input int SignalOsMA = 9;
input ENUM_APPLIED_PRICE PriceOsMA = PRICE_TYPICAL;
input group "B B A N D S S E T T I N G S"
input int BandsMA = 26;
input int BandsShift = 0;
input double BandsDeviation = 2.0;
input group "M A S E T T I N G S"
input int PeriodMA = 10;
input int ShiftMA = 0;
input ENUM_MA_METHOD MethodMA = MODE_SMA;
在MultiMartingale.mq5
智能交易系统中,我们没有交易信号,而开仓方向是由用户设置的。在这里,我们有交易信号,并且将它们安排为一个单独的类是有意义的。首先,让我们描述抽象接口TradingSignal
。
c
interface TradingSignal
{
virtual int signal(void);
};
它和我们的其他接口TradingStrategy
一样简单。这是很好的。接口和对象越简单,它们就越有可能只做一件事,这是一种好的编程风格,因为它能将错误最小化,并使大型软件项目更容易理解。由于在任何使用TradingSignal
的程序中都有抽象性,所以将一个信号替换为另一个信号是可行的。我们也可以替换策略。我们的策略现在负责准备和发送订单,而信号则基于市场分析来启动这些订单。
在我们的例子中,让我们将TradingSignal
的具体实现封装到BandOsMaSignal
类中。当然,我们需要变量来存储这三个指标的描述符。指标实例分别在构造函数和析构函数中创建和删除。所有参数都将从输入变量中传递。请注意,iBands
和iMA
是基于hOsMA
句柄构建的。
c
class BandOsMaSignal: public TradingSignal
{
int hOsMA, hBands, hMA;
int direction;
public:
BandOsMaSignal(const int fast, const int slow, const int signal,
const ENUM_APPLIED_PRICE price,
const int bands, const int shift, const double deviation,
const int period, const int x, ENUM_MA_METHOD method)
{
hOsMA = iOsMA(_Symbol, _Period, fast, slow, signal, price);
hBands = iBands(_Symbol, _Period, bands, shift, deviation, hOsMA);
hMA = iMA(_Symbol, _Period, period, x, method, hOsMA);
direction = 0;
}
~BandOsMaSignal()
{
IndicatorRelease(hMA);
IndicatorRelease(hBands);
IndicatorRelease(hOsMA);
}
...
当前交易信号的方向存储在变量direction
中:0 —— 无信号(未定义情况),+1 —— 买入,-1 —— 卖出。我们将在signal
方法中填充这个变量。它的代码在MQL5中重复了上述关于信号的文字描述。
c
virtual int signal(void) override
{
double osma[2], upper[2], lower[2], ma[2];
// 获取每根指标在第1根和第2根K线上的两个值
if(CopyBuffer(hOsMA, 0, 1, 2, osma) != 2) return 0;
if(CopyBuffer(hBands, UPPER_BAND, 1, 2, upper) != 2) return 0;
if(CopyBuffer(hBands, LOWER_BAND, 1, 2, lower) != 2) return 0;
if(CopyBuffer(hMA, 0, 1, 2, ma) != 2) return 0;
// 如果已经有信号,检查信号是否已经结束
if(direction != 0)
{
if(direction > 0)
{
if(osma[0] >= ma[0] && osma[1] < ma[1])
{
direction = 0;
}
}
else
{
if(osma[0] <= ma[0] && osma[1] > ma[1])
{
direction = 0;
}
}
}
// 无论如何,检查是否有新信号
if(osma[0] <= lower[0] && osma[1] > lower[1])
{
direction = +1;
}
else if(osma[0] >= upper[0] && osma[1] < upper[1])
{
direction = -1;
}
return direction;
}
};
如您所见,指标值是为第1根和第2根K线读取的,因为我们将在K线开盘时进行操作,而在我们调用signal
方法时,第0根K线刚刚开盘。
实现TradingStrategy
接口的新类将被称为SimpleStrategy
。
这个类提供了一些新功能,同时也使用了一些先前存在的部分。特别是,它保留了PositionState
和TrailingStop
的自动指针,并新增了一个指向TradingSignal
信号的自动指针。此外,由于我们只打算在K线开盘时进行交易,所以我们需要lastBar
变量,它将存储最后处理的K线的时间。
c
class SimpleStrategy: public TradingStrategy
{
protected:
AutoPtr<PositionState> position;
AutoPtr<TrailingStop> trailing;
AutoPtr<TradingSignal> command;
const int stopLoss;
const ulong magic;
const double lots;
datetime lastBar;
...
全局参数被传递到SimpleStrategy
的构造函数中。我们还传递一个指向TradingSignal
对象的指针:在这种情况下,它将是BandOsMaSignal
,必须由调用代码创建。接下来,构造函数尝试在现有头寸中找到具有所需魔术数字和交易品种的头寸,如果成功,则添加追踪止损。如果智能交易系统由于某种原因中断,并且头寸已经开仓,这将非常有用。
c
public:
SimpleStrategy(TradingSignal *signal, const ulong m, const int sl, const double v):
command(signal), magic(m), stopLoss(sl), lots(v), lastBar(0)
{
// 在现有头寸中选择“我们的”头寸(如果有合适的)
PositionFilter positions;
ulong tickets[];
positions.let(POSITION_MAGIC, magic).let(POSITION_SYMBOL, _Symbol).select(tickets);
const int n = ArraySize(tickets);
if(n > 1)
{
Alert(StringFormat("Too many positions: %d", n));
// TODO: 关闭多余的头寸 - 这是策略不允许的
}
else if(n > 0)
{
position = new PositionState(tickets[0]);
if(stopLoss)
{
trailing = new TrailingStop(tickets[0], stopLoss, stopLoss / 50);
}
}
}
trade
方法的实现类似于鞅策略的示例。然而,我们删除了手数的乘法运算,并添加了signal
方法的调用。
c
virtual bool trade() override
{
// 我们只在新K线出现时操作一次
if(lastBar == iTime(_Symbol, _Period, 0)) return false;
int s = command[].signal(); // 获取信号
ulong ticket = 0;
if(position[] != NULL)
{
if(position[].refresh()) // 头寸存在
{
// 信号变为相反的信号或消失
if((position[].get(POSITION_TYPE) == POSITION_TYPE_BUY && s != +1)
|| (position[].get(POSITION_TYPE) == POSITION_TYPE_SELL && s != -1))
{
PrintFormat("Signal lost: %d for position %d %lld",
s, position[].get(POSITION_TYPE), position[].get(POSITION_TICKET));
if(close(position[].get(POSITION_TICKET)))
{
position = NULL;
}
else
{
// 根据是否平仓更新内部标志'ready'
position[].refresh();
}
}
else
{
position[].update();
if(trailing[]) trailing[].trail();
}
}
else // 头寸已平仓
{
position = NULL;
}
}
if(position[] == NULL && s != 0)
{
ticket = (s == +1) ? openBuy() : openSell();
}
if(ticket > 0) // 刚刚开仓了新头寸
{
position = new PositionState(ticket);
if(stopLoss)
{
trailing = new TrailingStop(ticket, stopLoss, stopLoss / 50);
}
}
// 存储当前K线
lastBar = iTime(_Symbol, _Period, 0);
return true;
}
辅助方法openBuy
、openSell
以及其他方法仅做了最小程度的修改,所以我们就不一一列举了(完整的源代码已附上)。
与多货币鞅策略(在多货币鞅策略中每个交易品种都需要各自的设置)不同,在这个智能交易系统中我们始终只有一个策略,所以我们排除了策略池,直接管理策略对象。
c
AutoPtr<TradingStrategy> strategy;
int OnInit()
{
if(FastOsMA >= SlowOsMA) return INIT_PARAMETERS_INCORRECT;
strategy = new SimpleStrategy(
new BandOsMaSignal(FastOsMA, SlowOsMA, SignalOsMA, PriceOsMA,
BandsMA, BandsShift, BandsDeviation,
PeriodMA, ShiftMA, MethodMA),
Magic, StopLoss, Lots);
return INIT_SUCCEEDED;
}
void OnTick()
{
if(strategy[] != NULL)
{
strategy[].trade();
}
}
现在我们有了一个现成的智能交易系统,我们可以用它作为研究测试器的工具。首先,让我们创建一个辅助结构体TesterRecord
,用于查询和存储所有统计数据。
c
struct TesterRecord
{
string feature;
double value;
static void fill(TesterRecord &stats[])
{
ResetLastError();
for(int i = 0; ; ++i)
{
const double v = TesterStatistics((ENUM_STATISTICS)i);
if(_LastError) return;
TesterRecord t = {EnumToString((ENUM_STATISTICS)i), v};
PUSH(stats, t);
}
}
};
在这种情况下,feature
字符串字段仅用于在日志中输出信息。要保存所有指标(例如,以便稍后能够生成自己的报告表格),一个适当长度的简单double
类型数组就足够了。
在OnDeinit
处理程序中使用这个结构体,我们可以确保MQL5 API返回的值与测试器报告中的值相同。
c
void OnDeinit(const int)
{
TesterRecord stats[];
TesterRecord::fill(stats);
ArrayPrint(stats, 2);
}
例如,当在欧元兑美元(EURUSD)、H1时间框架下,以10000的存款运行该智能交易系统,并且不进行任何优化(使用默认设置)时,对于2021年我们大约会得到以下值(片段):
[feature] | [value] | |
---|---|---|
[ 0] | "STAT_INITIAL_DEPOSIT" | 10000.00 |
[ 1] | "STAT_WITHDRAWAL" | 0.00 |
[ 2] | "STAT_PROFIT" | 6.01 |
[ 3] | "STAT_GROSS_PROFIT" | 303.63 |
[ 4] | "STAT_GROSS_LOSS" | -297.62 |
[ 5] | "STAT_MAX_PROFITTRADE" | 15.15 |
[ 6] | "STAT_MAX_LOSSTRADE" | -10.00 |
... | ... | ... |
[27] | "STAT_DEALS" | 476.00 |
[28] | "STAT_TRADES" | 238.00 |
... | ... | ... |
[37] | "STAT_CONLOSSMAX_TRADES" | 8.00 |
[38] | "STAT_MAX_CONLOSS_TRADES" | 8.00 |
[39] | "STAT_PROFITTRADES_AVGCON" | 2.00 |
[40] | "STAT_LOSSTRADES_AVGCON" | 2.00 |
了解了所有这些值后,我们可以发明自己的智能交易系统质量综合指标公式,同时这也是目标优化函数。但无论如何,这个指标的值都需要报告给测试器。而这正是OnTester
函数的作用。
OnTester事件
当智能交易系统在历史数据上完成测试时,会触发OnTester
事件(包括用户手动启动的单独测试运行,以及测试器在优化过程中自动发起的多次运行中的某一次)。若要处理OnTester
事件,MQL程序的源代码中必须有对应的函数,但这并非必要条件。即便没有OnTester
函数,智能交易系统也能依据标准标准成功进行优化。
此函数仅能在智能交易系统中使用。
c
double OnTester()
该函数旨在计算一个double
类型的值,此值会作为自定义优化标准(自定义最大值)使用。标准的选择对于成功进行遗传优化尤为重要,同时也能让用户评估并对比不同设置的效果。
在遗传优化中,每一代的结果会按照标准值降序排列。也就是说,标准值最高的结果在优化标准方面被视为最佳。在此次排序中,最差的值随后会被舍弃,不会参与下一代的生成。
需要注意的是,只有在测试器设置中选择了自定义标准时,OnTester
函数返回的值才会被考虑。存在OnTester
函数并不意味着遗传算法会自动使用它。
MQL5 API并未提供以编程方式查明用户在测试器设置中选择了哪种优化标准的方法。有时,为了实现自定义的分析算法来对优化结果进行后处理,了解这一点非常重要。
该函数仅会在测试器中被内核调用,且会在调用OnDeinit
函数之前调用。
为了计算返回值,我们既可以使用通过TesterStatistics
函数获取的标准统计数据,也可以进行任意计算。
在BandOsMA.mq5
智能交易系统中,我们创建了OnTester
处理程序,它会考虑多个指标:利润、盈利能力、交易数量和夏普比率。接着,我们会对每个指标取平方根后再将它们相乘。当然,每位开发者在构建此类综合质量标准时可能会有自己的偏好和想法。
c
double sign(const double x)
{
return x > 0 ? +1 : (x < 0 ? -1 : 0);
}
double OnTester()
{
const double profit = TesterStatistics(STAT_PROFIT);
return sign(profit) * sqrt(fabs(profit))
* sqrt(TesterStatistics(STAT_PROFIT_FACTOR))
* sqrt(TesterStatistics(STAT_TRADES))
* sqrt(fabs(TesterStatistics(STAT_SHARPE_RATIO)));
}
单元测试日志会显示一行包含OnTester
函数值的内容。
让我们对EURUSD
货币对在H1时间框架上、2021年的数据进行智能交易系统的遗传优化,同时选择指标参数和止损大小(书中提供了MQL5/Presets/MQL5Book/BandOsMA.set
文件)。为了检验优化的质量,我们还会纳入从2022年初开始(5个月)的前向测试。
首先,按照我们自定义的标准进行优化。
众所周知,MetaTrader 5除了会保存优化过程中使用的当前标准外,还会将所有标准标准保存到优化结果中。这使得在优化完成后,我们可以通过在带有表格的面板右上角的下拉列表中选择特定标准,从不同角度分析结果。因此,尽管我们是按照自定义标准进行优化的,但我们也能使用最有趣的内置综合标准。
我们可以将优化表格导出为XML文件,先选择自定义标准导出,然后选择综合标准导出并给文件取个新名字(遗憾的是,导出文件中只会写入一个标准;在两次导出之间不要更改排序很重要)。这使得我们可以在外部程序中合并两个表格,并绘制一个图表,在图表的坐标轴上绘制两个标准;图中的每个点都代表一次运行中的标准组合。
自定义标准与综合优化标准的比较
在综合标准中,我们可以看到一个多层次的结构,因为它是根据带有条件的公式计算得出的:在某些地方使用一个分支,在其他地方使用另一个分支。而我们的自定义标准始终使用相同的公式进行计算。我们还注意到自定义标准中存在负值(这是预期之中的),并且综合标准的声明范围是0 - 100。
让我们通过分析前向测试期间自定义标准的值,来检验该标准的优劣。
优化期间和前向测试期间自定义标准的值
正如预期的那样,在前向测试中,只有一部分优化阶段的良好指标得以保留。但我们更关注的不是标准,而是利润。让我们看看在优化 - 前向测试环节中利润的分布情况。
优化期间和前向测试期间的利润
情况类似。在优化期间有6850次盈利的测试中,只有3123次在前向测试中也盈利(占比45%)。而在前1000次最佳测试中,只有323次盈利,这并不理想。因此,这个智能交易系统需要做大量工作来找出稳定盈利的设置。但也许这是优化标准的问题?
让我们重新进行优化,这次使用内置的综合标准。
注意! MetaTrader 5在优化过程中会生成优化缓存:在Tester/cache
目录下的opt
文件。当启动下一次优化时,它会查找合适的缓存以继续优化。如果存在包含先前设置的缓存文件,优化过程不会从头开始,而是会考虑先前的结果。这使得你可以进行链式遗传优化,前提是你能找到最佳结果(毕竟,每次遗传优化都是一个随机过程)。
MetaTrader 5不会将优化标准作为设置中的区分因素。根据上述内容,这在某些情况下可能有用,但会对我们当前的任务造成干扰。为了进行纯粹的实验,我们需要从头开始进行优化。因此,在第一次使用自定义标准进行优化后,我们不能立即使用综合标准进行第二次优化。
无法从终端界面禁用当前的缓存行为。因此,你应该在任何文件管理器中手动删除或重命名(更改扩展名)先前的opt
文件。稍后我们会了解测试器的预处理指令tester_no_cache
,可以在特定智能交易系统的源代码中指定该指令,从而禁用缓存读取。
优化期间和前向测试期间综合标准值的比较如下所示。
优化期间和前向测试期间的综合标准
前向测试中的利润稳定性
在历史数据中有5952个正向结果,在前向测试中只有2655个(约45%)保持盈利。但在前1000个结果中,有581个在前向测试中成功盈利。
所以,从技术角度来看,使用OnTester
非常简单,但我们的自定义标准在相同条件下的表现不如内置标准,尽管内置标准也远非完美。因此,从寻找标准公式本身以及后续在不预知未来的情况下合理选择参数的角度来看,OnTester
的内容存在更多问题,而非答案。
在这里,编程逐渐演变为研究和科学活动,这超出了本书的范围。但我们将给出一个基于自定义指标而非现成指标(TesterStatistics
)计算标准的示例。我们将讨论R²标准,也称为决定系数(RSquared.mqh
)。
让我们创建一个从余额曲线计算R²的函数。众所周知,在使用固定手数进行交易时,理想的交易系统应呈现出直线形式的余额曲线。我们现在使用的是固定手数,因此这对我们适用。至于在使用可变手数情况下的R²,我们稍后再处理。
最终,R²是数据相对于基于它们构建的线性回归的方差的反向度量。R²值的范围是从负无穷到 +1(尽管在我们的情况下,大的负值非常罕见)。显然,找到的直线同时具有斜率,因此,为了使代码通用化,我们将R²和斜率的正切值都保存到R2A
结构中作为中间结果。
c
struct R2A
{
double r2; // 相关系数的平方
double angle; // 斜率的正切值
R2A(): r2(0), angle(0) { }
};
指标的计算在RSquared
函数中进行,该函数接受一个数据数组作为输入,并返回一个R2A
结构。
c
R2A RSquared(const double &data[])
{
int size = ArraySize(data);
if(size <= 2) return R2A();
double x, y, div;
int k = 0;
double Sx = 0, Sy = 0, Sxy = 0, Sx2 = 0, Sy2 = 0;
for(int i = 0; i < size; ++i)
{
if(data[i] == EMPTY_VALUE
|| !MathIsValidNumber(data[i])) continue;
x = i + 1;
y = data[i];
Sx += x;
Sy += y;
Sxy += x * y;
Sx2 += x * x;
Sy2 += y * y;
++k;
}
size = k;
const double Sx22 = Sx * Sx / size;
const double Sy22 = Sy * Sy / size;
const double SxSy = Sx * Sy / size;
div = (Sx2 - Sx22) * (Sy2 - Sy22);
if(fabs(div) < DBL_EPSILON) return R2A();
R2A result;
result.r2 = (Sxy - SxSy) * (Sxy - SxSy) / div;
result.angle = (Sxy - SxSy) / (Sx2 - Sx22);
return result;
}
对于优化,我们需要一个标准值,在这里斜率很重要,因为具有负斜率的平滑下降余额曲线也可能获得较好的R²估计值。因此,我们将编写另一个函数,该函数会给任何斜率为负的R²估计值加上负号。我们取R²的绝对值,因为在数据非常糟糕(分散)且不符合我们的线性模型的情况下,R²本身可能为负。这样,我们必须避免负负得正的情况。
c
double RSquaredTest(const double &data[])
{
const R2A result = RSquared(data);
const double weight = 1.0 - 1.0 / sqrt(ArraySize(data) + 1);
if(result.angle < 0) return -fabs(result.r2) * weight;
return result.r2 * weight;
}
此外,我们的标准考虑了序列的大小,这对应于交易次数。因此,交易次数的增加会提高该指标。
有了这个工具后,我们将在智能交易系统中实现计算余额曲线的函数,并为其计算R²。最后,我们将该值乘以100,从而将尺度转换为内置综合标准的范围。
c
#define STAT_PROPS 4
double GetR2onBalanceCurve()
{
HistorySelect(0, LONG_MAX);
const ENUM_DEAL_PROPERTY_DOUBLE props[STAT_PROPS] =
{
DEAL_PROFIT, DEAL_SWAP, DEAL_COMMISSION, DEAL_FEE
};
double expenses[][STAT_PROPS];
ulong tickets[]; // 仅因 'select' 原型需要,但对调试有用
DealFilter filter;
filter.let(DEAL_TYPE, (1 << DEAL_TYPE_BUY) | (1 << DEAL_TYPE_SELL), IS::OR_BITWISE)
.let(DEAL_ENTRY,
(1 << DEAL_ENTRY_OUT) | (1 << DEAL_ENTRY_INOUT) | (1 << DEAL_ENTRY_OUT_BY),
IS::OR_BITWISE)
.select(props, tickets, expenses);
const int n = ArraySize(tickets);
double balance[];
ArrayResize(balance, n + 1);
balance[0] = TesterStatistics(STAT_INITIAL_DEPOSIT);
for(int i = 0; i < n; ++i)
{
double result = 0;
for(int j = 0; j < STAT_PROPS; ++j)
{
result += expenses[i][j];
}
balance[i + 1] = result + balance[i];
}
const double r2 = RSquaredTest(balance);
return r2 * 100;
}
在OnTester
处理程序中,我们将在条件编译指令下使用新的标准,因此需要在源代码开头取消注释#define USE_R2_CRITERION
指令。
c
double OnTester()
{
#ifdef USE_R2_CRITERION
return GetR2onBalanceCurve();
#else
const double profit = TesterStatistics(STAT_PROFIT);
return sign(profit) * sqrt(fabs(profit))
* sqrt(TesterStatistics(STAT_PROFIT_FACTOR))
* sqrt(TesterStatistics(STAT_TRADES))
* sqrt(fabs(TesterStatistics(STAT_SHARPE_RATIO)));
#endif
}
让我们删除先前的优化结果(带有缓存的opt
文件),并基于R²标准对智能交易系统进行新的优化。
将R²标准的值与综合标准进行比较时,我们可以说它们之间的“收敛性”有所提高。
自定义R²标准与内置综合标准的比较
优化窗口和前向测试期间对应参数集的R²标准值如下所示。
优化期间和前向测试期间的R²标准
过去和未来的利润情况如下。
优化期间和前向测试期间的利润
R²标准下优化期间和前向测试期间的利润
统计数据如下:在最后5582次盈利测试中,有2638次(47%)保持盈利,在前1000次最盈利的测试中,有566次保持盈利,这与内置综合标准相当。
如上所述,这些统计数据为后续更智能的优化阶段提供了原始素材,这不仅仅是一个编程任务。我们将专注于优化的其他纯编程方面。
自动调优:ParameterGetRange 和 ParameterSetRange 函数
在上一节中,我们学习了如何将优化标准传递给测试器。然而,我们忽略了一个重要的点。如果你查看我们的优化日志,会在其中看到很多错误消息,如下所示:
...
Best result 90.61004580175876 produced at generation 25. Next generation 26
genetic pass (26, 388) tested with error "incorrect input parameters" in 0:00:00.021
genetic pass (26, 436) tested with error "incorrect input parameters" in 0:00:00.007
genetic pass (26, 439) tested with error "incorrect input parameters" in 0:00:00.007
genetic pass (26, 363) tested with error "incorrect input parameters" in 0:00:00.008
genetic pass (26, 365) tested with error "incorrect input parameters" in 0:00:00.008
...
换句话说,每隔几次测试运行,输入参数就会出现问题,这样的运行就无法执行。OnInit
处理函数中包含以下检查:
cpp
if(FastOsMA >= SlowOsMA) return INIT_PARAMETERS_INCORRECT;
从我们的角度来看,施加这样的限制是很合理的,即慢速移动平均线(MA)的周期应该大于快速移动平均线的周期。然而,测试器并不了解我们算法的这些情况,因此它会尝试遍历各种周期组合,包括不正确的组合。这在优化过程中可能是一种常见情况,但会产生负面后果。
由于我们应用了遗传优化,每一代中都有几个被拒绝的样本不会参与进一步的变异。MetaTrader 5 优化器不会弥补这些损失,也就是说,它不会生成这些样本的替代值。那么,较小的种群规模可能会对优化质量产生负面影响。因此,有必要想出一种方法,确保输入设置仅以正确的组合进行枚举。这时,两个 MQL5 API 函数可以帮助我们:ParameterGetRange
和 ParameterSetRange
。
这两个函数都有两个重载的原型,它们的参数类型不同:long
和 double
。ParameterGetRange
函数的两个变体描述如下:
cpp
bool ParameterGetRange(const string name, bool &enable, long &value, long &start, long &step, long &stop)
bool ParameterGetRange(const string name, bool &enable, double &value, double &start, double &step, double &stop)
对于由 name
指定的输入变量,该函数接收关于其当前值(value
)、取值范围(start
,stop
)以及在优化过程中的变化步长(step
)的信息。此外,还会将一个属性写入名为 enable
的变量中,该属性表示对于名为 name
的输入变量,优化是否已启用。
该函数返回一个表示成功(true
)或错误(false
)的指示。
该函数只能从三个与优化相关的特殊处理函数中调用:OnTesterInit
、OnTesterPass
和 OnTesterDeinit
。我们将在下一节中讨论这些函数。从名称上你可以猜到,OnTesterInit
在优化开始前被调用,OnTesterDeinit
在优化完成后被调用,而 OnTesterPass
在优化过程的每次运行后被调用。目前,我们只对 OnTesterInit
感兴趣。和其他两个函数一样,它没有参数,可以声明为 void
类型,即它不返回任何值。
ParameterSetRange
函数的两个版本具有类似的原型,并执行相反的操作:它们设置智能交易系统输入参数的优化属性。
cpp
bool ParameterSetRange(const string name, bool enable, long value, long start, long step, long stop)
bool ParameterSetRange(const string name, bool enable, double value, double start, double step, double stop)
该函数在优化时设置名为 name
的输入变量的修改规则:value
(当前值)、变化步长、起始值和结束值。
这个函数只能在策略测试器中开始优化时,从 OnTesterInit
处理函数中调用。
因此,使用 ParameterGetRange
和 ParameterSetRange
函数,你可以分析和设置新的范围和步长值,并且尽管策略测试器中有相关设置,你也可以完全排除某些参数的优化,或者相反,将某些参数纳入优化。这使你能够创建自己的脚本来在优化过程中管理输入参数的空间。
该函数甚至允许你在优化中使用那些用 sinput
修饰符声明的变量(用户无法将它们包含在优化中)。
注意:在调用 ParameterSetRange
并更改了特定输入变量的设置后,后续对 ParameterGetRange
的调用将无法 “看到” 这些更改,并且仍会返回原始设置。这使得在复杂的软件产品中无法一起使用这些函数,因为在这些产品中,设置可能由不同的类和来自独立开发者的库来处理。
让我们使用这些新函数来改进 BandOsMA
智能交易系统。更新后的版本名为 BandOsMApro.mq5
(“pro” 可以有条件地理解为 “参数范围优化”)。
因此,我们有 OnTesterInit
处理函数,在其中我们读取 FastOsMA
和 SlowOsMA
参数的设置,并检查它们是否包含在优化中。如果是,就需要将它们关闭,并提供一些替代方案。
cpp
void OnTesterInit()
{
bool enabled1, enabled2;
long value1, start1, step1, stop1;
long value2, start2, step2, stop2;
if(ParameterGetRange("FastOsMA", enabled1, value1, start1, step1, stop1)
&& ParameterGetRange("SlowOsMA", enabled2, value2, start2, step2, stop2))
{
if(enabled1 && enabled2)
{
if(!ParameterSetRange("FastOsMA", false, value1, start1, step1, stop1)
|| !ParameterSetRange("SlowOsMA", false, value2, start2, step2, stop2))
{
Print("Can't disable optimization by FastOsMA and SlowOsMA: ",
E2S(_LastError));
return;
}
...
}
}
else
{
Print("Can't adjust optimization by FastOsMA and SlowOsMA: ", E2S(_LastError));
}
}
不幸的是,由于添加了 OnTesterInit
,编译器还要求添加 OnTesterDeinit
,尽管我们并不需要这个函数。但我们不得不照做并添加一个空的处理函数。
cpp
void OnTesterDeinit()
{
}
代码中存在 OnTesterInit
和 OnTesterDeinit
函数会导致这样一个事实:当开始优化时,终端中会打开一个额外的图表,上面运行着我们智能交易系统的一个副本。它以一种特殊模式工作,允许从测试代理上的测试副本接收额外的数据(即所谓的帧),但我们将在后面探讨这种可能性。目前,对我们来说重要的是要注意,在这个智能交易系统的辅助副本中,所有与文件、日志、图表和对象相关的操作都像往常一样直接在终端中进行(而不是在代理上)。特别是,所有错误消息和 Print
调用都将显示在终端的 “智能交易系统” 选项卡的日志中。
我们拥有这些参数的变化范围和步长的信息,实际上可以重新计算所有正确的组合。这个任务被分配给一个单独的 Iterate
函数,因为在代理上的智能交易系统副本的 OnInit
处理函数中,将不得不重现类似的操作。
在 Iterate
函数中,我们有两个嵌套的循环,分别针对快速和慢速移动平均线的周期,在循环中我们计算有效的组合数量,即当 i
周期小于 j
周期时的组合数量。当从 OnInit
调用 Iterate
时,我们需要可选的 find
参数,以便根据组合 i
和 j
的序号返回这一对周期值。由于需要返回两个数字,我们为它们声明了 PairOfPeriods
结构体。
cpp
struct PairOfPeriods
{
int fast;
int slow;
};
PairOfPeriods Iterate(const long start1, const long stop1, const long step1,
const long start2, const long stop2, const long step2,
const long find = -1)
{
int count = 0;
for(int i = (int)start1; i <= (int)stop1; i += (int)step1)
{
for(int j = (int)start2; j <= (int)stop2; j += (int)step2)
{
if(i < j)
{
if(count == find)
{
PairOfPeriods p = {i, j};
return p;
}
++count;
}
}
}
PairOfPeriods p = {count, 0};
return p;
}
当从 OnTesterInit
调用 Iterate
时,我们不使用 find
参数,一直计数到最后,并在结构体的第一个字段中返回结果数量。这将是某个新的影子参数的取值范围,我们必须为该参数启用优化。我们将其称为 FastSlowCombo4Optimization
,并将其添加到新的辅助输入参数组中。很快还会在这里添加更多参数。
cpp
input group "A U X I L I A R Y"
sinput int FastSlowCombo4Optimization = 0; // (reserved for optimization)
...
让我们回到 OnTesterInit
,并使用 ParameterSetRange
函数在所需范围内对 FastSlowCombo4Optimization
参数进行 MQL5 优化。
cpp
void OnTesterInit()
{
...
PairOfPeriods p = Iterate(start1, stop1, step1, start2, stop2, step2);
const int count = p.fast;
ParameterSetRange("FastSlowCombo4Optimization", true, 0, 0, 1, count);
PrintFormat("Parameter FastSlowCombo4Optimization is enabled with maximum: %d",
count);
...
}
请注意,新参数的最终迭代次数应该显示在终端日志中。
在代理上进行测试时,使用 FastSlowCombo4Optimization
中的数字,通过再次调用 Iterate
来获取一对周期值,这次要填充 find
参数。但问题是,对于这个操作,需要知道 FastOsMA
和 SlowOsMA
参数的初始范围和变化步长。这些信息仅存在于终端中。所以,我们需要以某种方式将其传输到代理上。
现在我们将采用目前所知的唯一解决方案:我们将再添加 3 个影子优化参数,并为它们设置一些值。将来,我们将了解向代理传输文件的技术(请参阅测试器的预处理器指令)。然后,我们将能够将 Iterate
函数计算出的整个索引数组写入文件,并将其发送到代理上。这将避免使用三个额外的影子优化参数。
所以,让我们添加三个输入参数:
cpp
sinput ulong FastShadow4Optimization = 0; // (reserved for optimization)
sinput ulong SlowShadow4Optimization = 0; // (reserved for optimization)
sinput ulong StepsShadow4Optimization = 0; // (reserved for optimization)
我们使用 ulong
类型是为了更节省空间:将两个 int
类型的数字打包到每个值中。在 OnTesterInit
中是这样填充它们的:
cpp
void OnTesterInit()
{
...
const ulong fast = start1 | (stop1 << 16);
const ulong slow = start2 | (stop2 << 16);
const ulong step = step1 | (step2 << 16);
ParameterSetRange("FastShadow4Optimization", false, fast, fast, 1, fast);
ParameterSetRange("SlowShadow4Optimization", false, slow, slow, 1, slow);
ParameterSetRange("StepsShadow4Optimization", false, step, step, 1, step);
...
}
所有这 3 个参数都是不可优化的(第二个参数为 false
)。
我们对 OnTesterInit
函数的操作到此结束。让我们转到接收端:OnInit
处理函数。
cpp
int OnInit()
{
// keep the check for single tests
if(FastOsMA >= SlowOsMA) return INIT_PARAMETERS_INCORRECT;
// when optimizing, we require the presence of shadow parameters
if(MQLInfoInteger(MQL_OPTIMIZATION) && StepsShadow4Optimization == 0)
{
return INIT_PARAMETERS_INCORRECT;
}
PairOfPeriods p = {FastOsMA, SlowOsMA}; // by default we work with normal parameters
if(FastShadow4Optimization && SlowShadow4Optimization && StepsShadow4Optimization)
{
// if the shadow parameters are full, decode them into periods
int FastStart = (int)(FastShadow4Optimization & 0xFFFF);
int FastStop = (int)((FastShadow4Optimization >> 16) & 0xFFFF);
int SlowStart = (int)(SlowShadow4Optimization & 0xFFFF);
int SlowStop = (int)((SlowShadow4Optimization >> 16) & 0xFFFF);
int FastStep = (int)(StepsShadow4Optimization & 0xFFFF);
int SlowStep = (int)((StepsShadow4Optimization >> 16) & 0xFFFF);
p = Iterate(FastStart, FastStop, FastStep,
SlowStart, SlowStop, SlowStep, FastSlowCombo4Optimization);
PrintFormat("MA periods are restored from shadow: FastOsMA=%d SlowOsMA=%d",
p.fast, p.slow);
}
strategy = new SimpleStrategy(
new BandOsMaSignal(p.fast, p.slow, SignalOsMA, PriceOsMA,
BandsMA, BandsShift, BandsDeviation,
PeriodMA, ShiftMA, MethodMA),
Magic, StopLoss, Lots);
return INIT_SUCCEEDED;
}
使用 MQLInfoInteger
函数,我们可以确定智能交易系统的所有模式,包括与测试器和优化相关的模式。将 ENUM_MQL_INFO_INTEGER
枚举的其中一个元素指定为参数,我们将得到一个逻辑标志作为结果(true
/false
):
MQL_TESTER
— 程序在测试器中运行。MQL_VISUAL_MODE
— 测试器在可视化模式下运行。MQL_OPTIMIZATION
— 在优化过程中执行测试运行(不是单独执行)。MQL_FORWARD
— 在优化后的向前周期上执行测试运行(如果优化设置中指定)。MQL_FRAME_MODE
— 智能交易系统在终端图表上以特殊服务模式运行(而不是在代理上)以控制优化(下一节将详细介绍)。
MQL 程序的测试器模式
一切准备就绪,可以开始优化了。一旦开始优化,使用上述设置 Presets/MQL5Book/BandOsMA.set
,我们将在终端的 “智能交易系统” 日志中看到一条消息:
Parameter FastSlowCombo4Optimization is enabled with maximum: 698
这次优化日志中应该不会有错误,并且所有代的生成都不会崩溃。
...
Best result 91.02452934181422 produced at generation 39. Next generation 42
Best result 91.56338892567393 produced at generation 42. Next generation 43
Best result 91.71026391877101 produced at generation 43. Next generation 44
Best result 91.71026391877101 produced at generation 43. Next generation 45
Best result 92.48460871443507 produced at generation 45. Next generation 46
...
甚至可以通过增加的总体优化时间来判断这一点:之前,一些运行在早期阶段就被拒绝了,而现在它们都被完整地处理了。
但我们的解决方案有一个缺点。现在,智能交易系统的工作设置不仅包括 FastOsMA
和 SlowOsMA
参数中的几个周期,还包括它们在所有可能组合中的序号(FastSlowCombo4Optimization
)。我们唯一能做的就是输出在 OnInit
函数中解码的周期,如上面所示。
因此,在通过优化找到良好的设置后,用户通常会进行一次单独的运行,以细化交易系统的行为。在测试日志的开头,应该会出现以下形式的记录:
MA periods are restored from shadow: FastOsMA=27 SlowOsMA=175
然后,你可以在同名参数中输入指定的周期
用于优化控制的 OnTester 事件组
在 MQL5 中,有三个特殊事件用于管理优化过程,并将任意应用结果(除交易指标外)从代理程序传输到终端:OnTesterInit
、OnTesterDeinit
和 OnTesterPass
。程序员在代码中为这些事件编写处理函数后,就能够在优化开始前、优化完成后以及每次单独的优化运行结束时(如果从代理程序接收到应用数据,下面会详细介绍)执行所需的操作。
所有处理函数都是可选的。正如我们所见,即使没有这些处理函数,优化过程也能正常进行。同时要明白,这三个事件仅在优化过程中起作用,在单次测试中并不生效。
带有这些处理函数的专家顾问会自动加载到终端的一个单独图表上,图表的交易品种和周期由测试器指定。这个专家顾问实例不会进行交易,仅执行服务性操作。其他所有事件处理函数,如 OnInit
、OnDeinit
和 OnTick
,在这个实例中都不会起作用。
要确定专家顾问是在代理程序上以常规交易模式执行,还是在终端中以服务模式执行,可以在其代码中调用 MQLInfoInteger(MQL_FRAME_MODE)
函数,该函数会返回 true
或 false
。这种服务模式也被称为“帧”模式,适用于可以从代理程序上的专家顾问实例发送到终端的数据包。我们稍后会看到具体的实现方式。
在优化过程中,终端中只有一个专家顾问实例在工作,并且在必要时接收传入的帧。别忘了,只有当专家顾问代码中包含上述三个事件处理函数之一时,才会启动这样的实例。
OnTesterInit 事件
OnTesterInit
事件在策略测试器启动优化时,在第一次运行之前触发。该处理函数有两种版本:返回类型为 int
和 void
。
c
int OnTesterInit(void);
void OnTesterInit(void);
在返回类型为 int
的版本中,返回值为 0(INIT_SUCCEEDED
)表示在终端图表上启动的专家顾问初始化成功,可以开始优化。任何其他返回值都表示错误代码,优化将不会启动。
返回类型为 void
的版本总是意味着专家顾问已成功为优化做好准备。
OnTesterInit
的执行有时间限制,超过这个时间,专家顾问将被强制终止,优化过程也会被取消。在这种情况下,测试器日志中会显示相应的消息。
在上一节中,我们看到了一个示例,展示了如何使用 OnTesterInit
处理函数,通过 ParameterGetRange
/ParameterSetRange
函数修改优化参数。
OnTesterDeinit 函数
c
void OnTesterDeinit(void);
OnTesterDeinit
函数在专家顾问优化完成时被调用。
该函数用于对应用优化结果进行最终处理。例如,如果在 OnTesterInit
中打开了一个文件来写入帧的内容,那么就需要在 OnTesterDeinit
中关闭这个文件。
OnTesterPass 函数
c
void OnTesterPass(void);
OnTesterPass
事件在优化过程中接收到数据帧时自动触发。该函数允许处理在优化过程中从代理程序上运行的专家顾问实例接收到的应用数据。测试代理程序发送的帧必须通过 OnTester
处理函数使用 FrameAdd
函数发送。
专家顾问优化的事件顺序图
该图展示了专家顾问优化时的事件顺序。
代理程序会自动向终端发送每次测试运行的一组标准财务统计数据。如果专家顾问不需要使用 FrameAdd
发送任何数据,也可以不发送。如果不使用帧,OnTesterPass
处理函数将不会被调用。
通过使用 OnTesterPass
,你可以“即时”动态处理优化结果,例如将其显示在终端的图表上,或者将其添加到文件中以便后续进行批量处理。
为了展示 OnTester
事件处理函数的功能,我们首先需要学习与帧相关的函数。这些内容将在接下来的章节中介绍。
从测试代理向终端发送数据帧
除了标准的财务指标和统计数据之外,MQL5 还提供了一组函数,用于组织对自定义(应用的)优化结果的传输和处理。其中一个函数FrameAdd
旨在从测试代理发送数据,其他函数则用于在终端接收数据。
数据交换格式基于数据帧。这是一种特殊的内部结构,智能交易系统(Expert Advisor)可以在测试器中基于简单类型的数组(不包含字符串、类对象或动态数组)来填充它,或者使用具有指定名称的文件来填充(必须首先在代理的沙盒中创建该文件)。通过多次调用FrameAdd
函数,智能交易系统可以向终端发送一系列数据帧,数据帧的数量没有限制。
FrameAdd
函数有两个版本:
c
bool FrameAdd(const string name, ulong id, double value, const string filename)
bool FrameAdd(const string name, ulong id, double value, const void &data[])
该函数将一个数据帧添加到要发送到终端的缓冲区中。name
和id
参数是公共标签,可用于在FrameFilter
函数中筛选数据帧。value
参数允许传递任意数值,当只需要一个值时可以使用该参数。更大量的数据要么在data
数组中指定(可以是简单结构的数组),要么在名为filename
的文件中指定。
如果没有大量数据需要传输(例如,只需要传输进程的状态),则使用该函数的第一种形式,并指定NULL
来代替文件名的字符串,或者使用第二种形式并传入大小为零的虚拟数组。
如果成功,该函数返回true
。
该函数只能在OnTester
处理程序中调用。
在简单测试期间(即在优化之外)调用该函数没有效果。
只能从测试代理向终端发送数据。在 MQL5 中,没有在优化期间向相反方向发送数据的机制。智能交易系统想要发送给测试代理的所有数据,都必须在开始优化之前准备好并可用(以输入参数或通过指令连接的文件的形式)。
在下一节熟悉了主机的函数之后,我们将查看一个使用FrameAdd
函数的示例。
在终端中获取数据帧
由测试代理通过FrameAdd
函数发送的数据帧会被传送到终端,并按照接收顺序写入到terminal_directory/MQL5/Files/Tester
文件夹下、以智能交易系统名称命名的mqd文件中。一次接收一个或多个数据帧会触发OnTesterPass
事件。
MQL5 API 提供了4个用于分析和读取数据帧的函数:FrameFirst
、FrameFilter
、FrameNext
和FrameInputs
。所有这些函数都返回一个布尔值,指示操作成功(true
)或出错(false
)。
为了访问已有的数据帧,内核维护了一个指向当前数据帧的内部指针。当使用FrameNext
函数读取下一个数据帧时,指针会自动向前移动,但也可以使用FrameFirst
或FrameFilter
函数将其移回到所有数据帧的起始位置。因此,MQL程序可以在循环中组织对数据帧的迭代,直到遍历完所有数据帧。如果有必要,这个过程可以重复进行,例如在OnTesterDeinit
函数中应用不同的过滤器。
c
bool FrameFirst()
FrameFirst
函数将内部数据帧读取指针设置到起始位置,并重置过滤器(如果之前使用FrameFilter
函数设置过过滤器)。
理论上,对于一次性接收和处理所有数据帧的情况,不需要调用FrameFirst
函数,因为在优化开始时指针已经位于起始位置。
c
bool FrameFilter(const string name, ulong id)
该函数设置数据帧读取过滤器,并将内部数据帧指针设置到起始位置。过滤器将影响后续FrameNext
函数调用时会包含哪些数据帧。
如果将空字符串作为第一个参数传递,过滤器将仅根据数字参数起作用,即所有具有指定id
的数据帧。如果第二个参数的值等于ULONG_MAX
,则仅文本过滤器起作用。
调用FrameFilter("", ULONG_MAX)
等效于调用FrameFirst()
,也等同于不使用过滤器。
如果在OnTesterPass
函数中调用FrameFirst
或FrameFilter
函数,请确保这确实是你需要的操作:代码中可能存在逻辑错误,因为这样做可能会导致循环、重复读取同一数据帧,或者使计算负载呈指数级增加。
c
bool FrameNext(ulong &pass, string &name, ulong &id, double &value)
bool FrameNext(ulong &pass, string &name, ulong &id, double &value, void &data[])
FrameNext
函数读取一个数据帧,并将指针移动到下一个数据帧。pass
参数将记录优化运行的次数。name
、id
和value
参数将接收在FrameAdd
函数的相应参数中传递的值。
需要注意的是,当没有更多数据帧可读时,该函数在正常运行的情况下也可能返回false
。在这种情况下,内置变量_LastError
的值为4000(它没有内置的符号表示)。
无论使用FrameAdd
函数的哪种形式发送数据,文件或数组的内容都将被放置在接收数据数组中。接收数组的类型必须与发送数组的类型匹配,并且在发送文件的情况下存在一些细微差别。
二进制文件(FILE_BIN
)最好使用uchar
字节数组来接收,以确保与任何文件大小兼容(因为其他更大的类型可能不是文件大小的整数倍)。如果文件大小(实际上是接收到的数据帧中的数据块大小)不是接收数组类型大小的整数倍,FrameNext
函数将不会读取数据,并返回INVALID_ARRAY
(4006)错误。
Unicode文本文件(FILE_TXT
或不带FILE_ANSI
修饰符的FILE_CSV
)应该使用ushort
类型的数组来接收,然后通过调用ShortArrayToString
函数将其转换为字符串。ANSI文本文件应该使用uchar
数组来接收,并使用CharArrayToString
函数进行转换。
c
bool FrameInputs(ulong pass, string ¶meters[], uint &count)
FrameInputs
函数允许获取智能交易系统输入参数的描述和值,这些参数用于构成具有指定运行次数的测试运行。parameters
字符串数组将被填充为类似于"ParameterNameN=ValueParameterN"
的行。count
参数将被填充为parameters
数组中的元素数量。
这四个函数只能在OnTesterPass
和OnTesterDeinit
处理程序中调用。
数据帧可能会分批到达终端,在这种情况下,传递它们需要一定的时间。因此,不一定所有数据帧都有时间触发OnTesterPass
事件,并且在优化结束之前不一定会被处理。因此,为了确保接收到所有延迟到达的数据帧,需要在OnTesterDeinit
函数中放置一个使用FrameNext
函数处理数据帧的代码块。
下面来看一个简单的示例FrameTransfer.mq5
。
该智能交易系统有四个测试参数。除了最后一个字符串参数外,其他所有参数都可以包含在优化中。
c
input bool Parameter0;
input long Parameter1;
input double Parameter2;
input string Parameter3;
然而,为了简化示例,参数Parameter1
和Parameter2
的步长数量限制为10(每个参数)。因此,如果不使用Parameter0
,最大运行次数为121次。Parameter3
是一个不能包含在优化中的参数示例。
该智能交易系统不进行交易,而是生成模拟任意应用数据的随机数据。在实际工作项目中不要使用这样的随机化方式:它仅适用于演示。
c
ulong startup; // 跟踪一次运行的时间(就像演示数据一样)
int OnInit()
{
startup = GetMicrosecondCount();
MathSrand((int)startup);
return INIT_SUCCEEDED;
}
数据以两种类型的数据帧发送:来自文件的数据帧和来自数组的数据帧。每种类型都有自己的标识符。
c
#define MY_FILE_ID 100
#define MY_TIME_ID 101
double OnTester()
{
// 以一个数据帧发送文件
const static string filename = "binfile";
int h = FileOpen(filename, FILE_WRITE | FILE_BIN | FILE_ANSI);
FileWriteString(h, StringFormat("Random: %d", MathRand()));
FileClose(h);
FrameAdd(filename, MY_FILE_ID, MathRand(), filename);
// 以另一个数据帧发送数组
ulong dummy[1];
dummy[0] = GetMicrosecondCount() - startup;
FrameAdd("timing", MY_TIME_ID, 0, dummy);
return (Parameter2 + 1) * (Parameter1 + 2);
}
文件以二进制形式写入,包含简单的字符串。OnTester
函数的结果(标准)是一个涉及Parameter1
和Parameter2
的简单算术表达式。
在接收端,在终端图表上以服务模式运行的智能交易系统实例中,我们从所有包含文件的数据帧中收集数据,并将它们放入一个通用的CSV文件中。该文件在OnTesterInit
处理程序中打开。
c
int handle; // 用于收集应用结果的文件
void OnTesterInit()
{
handle = FileOpen("output.csv", FILE_WRITE | FILE_CSV | FILE_ANSI, ",");
}
如前所述,并非所有数据帧都有时间进入OnTesterPass
处理程序,因此需要在OnTesterDeinit
函数中额外检查它们。因此,我们实现了一个辅助函数ProcessFileFrames
,将从OnTesterPass
和OnTesterDeinit
函数中调用它。
在ProcessFileFrames
函数内部,我们维护了一个已处理数据帧的内部计数器framecount
。以它为例,我们将确保数据帧的到达顺序和测试运行的编号通常不匹配。
c
void ProcessFileFrames()
{
static ulong framecount = 0;
...
// 为了在函数中接收数据帧,按照FrameNext函数原型的要求声明必要的变量。这里将接收数据数组声明为uchar类型。
// 如果我们要将一些结构写入二进制文件,我们可以直接将它们读取到相同类型的结构数组中。
ulong pass;
string name;
long id;
double value;
uchar data[];
...
// 下面声明用于获取数据帧所属的当前测试运行的智能交易系统输入变量的变量。
string params[];
uint count;
...
// 然后使用FrameNext函数在循环中读取数据帧。请记住,可能会有多个数据帧同时进入处理程序,因此需要一个循环。
// 对于每个数据帧,我们将测试运行编号、数据帧名称和得到的double值输出到终端日志中。
// 我们跳过ID不等于MY_FILE_ID的数据帧,稍后再处理它们。
ResetLastError();
while(FrameNext(pass, name, id, value, data))
{
PrintFormat("Pass: %lld Frame: %s Value:%f", pass, name, value);
if(id != MY_FILE_ID) continue;
...
}
if(_LastError != 4000 && _LastError != 0)
{
Print("Error: ", E2S(_LastError));
}
}
对于ID为MY_FILE_ID
的数据帧,我们执行以下操作:查询输入变量,找出哪些变量包含在优化中,并将它们的值与数据帧中的信息一起保存到通用的CSV文件中。当数据帧计数器framecount
为0时,我们在header
变量中形成CSV文件的表头。在所有数据帧中,当前(新)的CSV文件记录在record
变量中形成。
c
void ProcessFileFrames()
{
...
if(FrameInputs(pass, params, count))
{
string header, record;
if(framecount == 0) // 准备CSV表头
{
header = "Counter,Pass ID,";
}
record = (string)framecount + "," + (string)pass + ",";
// 收集优化参数及其值
for(uint i = 0; i < count; i++)
{
string name2value[];
int n = StringSplit(params[i], '=', name2value);
if(n == 2)
{
long pvalue, pstart, pstep, pstop;
bool enabled = false;
if(ParameterGetRange(name2value[0],
enabled, pvalue, pstart, pstep, pstop))
{
if(enabled)
{
if(framecount == 0) // 准备CSV表头
{
header += name2value[0] + ",";
}
record += name2value[1] + ","; // 数据字段
}
}
}
}
if(framecount == 0) // 准备CSV表头
{
FileWriteString(handle, header + "Value,File Content\n");
}
// 将数据写入CSV文件
FileWriteString(handle, record + DoubleToString(value) + ","
+ CharArrayToString(data) + "\n");
}
framecount++;
...
}
调用ParameterGetRange
函数也可以更高效地进行,仅在framecount
为0时调用即可。你可以尝试这样做。
在OnTesterPass
处理程序中,我们只需调用ProcessFileFrames
函数。
c
void OnTesterPass()
{
ProcessFileFrames(); // 即时处理数据帧的标准操作
}
此外,我们从OnTesterDeinit
函数中调用相同的函数,并关闭CSV文件。
c
void OnTesterDeinit()
{
ProcessFileFrames(); // 处理延迟到达的数据帧
FileClose(handle); // 关闭CSV文件
..
}
在OnTesterDeinit
函数中,我们处理ID为MY_TIME_ID
的数据帧。这些数据帧中传递了测试运行的持续时间,并且在这里计算一次运行的平均持续时间。理论上,这样做仅对程序中的分析有意义,因为对于用户来说,测试器已经在日志中显示了运行的持续时间。
c
void OnTesterDeinit()
{
...
ulong pass;
string name;
long id;
double value;
ulong data[]; // 与发送的数组类型相同
FrameFilter("timing", MY_TIME_ID); // 回退到第一个数据帧
ulong count = 0;
ulong total = 0;
// 仅循环处理'timing'数据帧
while(FrameNext(pass, name, id, value, data))
{
if(ArraySize(data) == 1)
{
total += data[0];
}
else
{
total += (ulong)value;
}
++count;
}
if(count > 0)
{
PrintFormat("Average timing: %lld", total / count);
}
}
该智能交易系统已准备就绪。让我们为它启用完整的优化(因为选项的总数人为地受到限制,对于遗传算法来说数量太小)。由于该智能交易系统不进行交易,我们只能选择开盘价。因此,你应该选择一个自定义标准(所有其他标准将给出0)。例如,让我们将参数Parameter1
的范围设置为从1到10,步长为1,将参数Parameter2
的范围设置为从 -0.5到 +0.5,步长为0.1。
让我们运行优化。在终端的智能交易系统日志中,我们将看到关于接收到的数据帧的记录,形式如下:
Pass: 0 Frame: binfile Value:5105.000000
Pass: 0 Frame: timing Value:0.000000
Pass: 1 Frame: binfile Value:28170.000000
Pass: 1 Frame: timing Value:0.000000
Pass: 2 Frame: binfile Value:17422.000000
Pass: 2 Frame: timing Value:0.000000
...
Average timing: 1811
带有运行编号、参数值和数据帧内容的相应行将出现在output.csv
文件中:
Counter,Pass ID,Parameter1,Parameter2,Value,File Content
0,0,0,-0.5,5105.00000000,Random: 87
1,1,1,-0.5,28170.00000000,Random: 64
2,2,2,-0.5,17422.00000000,Random: 61
...
37,35,2,-0.2,6151.00000000,Random: 68
38,62,7,0.0,17422.00000000,Random: 61
39,36,3,-0.2,16899.00000000,Random: 71
40,63,8,0.0,17422.00000000,Random: 61
...
117,116,6,0.5,27648.00000000,Random: 74
118,117,7,0.5,16899.00000000,Random: 71
119,118,8,0.5,17422.00000000,Random: 61
120,119,9,0.5,28170.00000000,Random: 64
显然,我们的内部编号(Count
列)是按顺序排列的,而运行编号Pass ID
可能是混乱的(这取决于代理并行处理任务批次的许多因素)。特别是,分配了较高序列号任务的代理可能会首先完成任务批次:在这种情况下,文件中的编号将从较高的运行编号开始。
在测试器的日志中,你可以按数据帧检查服务统计信息。
242 frames (42.78 Kb total, 181 bytes per frame) received
local 121 tasks (100%), remote 0 tasks (0%), cloud 0 tasks (0%)
121 new records saved to cache file 'tester\cache\FrameTransfer.EURUSD.H1. »
» 20220101.20220201.20.9E2DE099D4744A064644F6BB39711DE8.opt'
需要注意的是,在遗传优化期间,优化报告中的运行编号以一对值(世代编号,副本编号)的形式呈现,而在FrameNext
函数中获得的运行编号是ulong
类型。实际上,它是当前优化运行上下文中批处理作业的运行编号。MQL5没有提供将运行编号与遗传报告进行匹配的方法。为此,应该计算每个运行的输入参数的校验和。带有优化缓存的opt
文件已经包含一个带有MD5哈希
测试器的预处理器指令
在关于程序通用属性的章节中,我们首次接触了 MQL 程序中的 #property
指令。随后,我们了解了用于脚本、服务和指标的指令。此外,还有一组用于测试器的指令,我们之前已经提到过其中一些。例如,tester_everytick_calculate
指令会影响指标的计算。
以下表格列出了所有测试器指令及其解释:
指令 | 描述 |
---|---|
tester_indicator "string" | 自定义指标的名称,格式为 "indicator_name.ex5" |
tester_file "string" | 文件名,格式为 "file_name.extension" ,包含程序测试所需的初始数据 |
tester_library "string" | 库的名称,带有扩展名,如 "library.ex5" 或 "library.dll" |
tester_set "string" | 文件名,格式为 "file_name.set" ,包含程序输入参数的优化值和范围的设置 |
tester_no_cache | 禁用读取先前优化的现有缓存(opt 文件) |
tester_everytick_calculate | 禁用测试器中计算指标的资源节省模式 |
最后两个指令没有参数。其他所有指令都需要一个用双引号括起来的字符串,该字符串表示某种类型的文件名。由此也可以看出,指令可以针对不同的文件重复使用,也就是说,你可以包含多个设置文件或多个指标。
tester_indicator
指令用于将那些在被测试程序的源代码中没有以常量字符串(文本)形式提及的指标连接到测试过程中。通常,如果在相应参数中明确指定了指标名称,例如 iCustom(symbol, period, "indicator_name",...)
,编译器可以从 iCustom
调用中自动确定所需的指标。然而,情况并非总是如此。
假设我们正在编写一个通用的智能交易系统,它可以使用不同的移动平均线指标,而不仅仅是标准的内置指标。那么,我们可以创建一个输入变量,让用户指定指标的名称。这样,iCustom
调用将变为 iCustom(symbol, period, CustomIndicatorName,...)
,其中 CustomIndicatorName
是智能交易系统的一个输入变量,在编译时其内容是未知的。此外,在这种情况下,开发人员很可能会使用 IndicatorCreate
而不是 iCustom
,因为指标参数的数量和类型也必须进行配置。在这种情况下,为了调试程序或使用特定指标展示程序,我们应该使用 tester_indicator
指令向测试器提供指标名称。
在源代码中报告指标名称的必要性极大地限制了测试这种可以在线连接各种指标的通用程序的能力。
如果没有 tester_indicator
指令,终端将无法将未在源代码中明确声明的指标发送到代理,结果是相关程序将失去部分或全部功能。
tester_file
指令允许你指定一个文件,该文件将在测试前传输到代理并放置在沙箱中。文件的内容和类型不受限制。例如,这些可以是预训练的神经网络的权重、预先收集的市场深度数据(因为测试器无法重现此类数据)等等。
请注意,只有在编译时存在的情况下,tester_file
指令指定的文件才会被读取。如果在编译源代码时不存在相应的文件,那么它在未来出现也无济于事:编译后的程序将在没有辅助文件的情况下发送到代理。因此,例如,如果在 OnTesterInit
中生成了 tester_file
中指定的文件,你应该确保在编译时已经存在具有给定名称的文件,即使它是空的。我们将在下面演示这一点。
请注意,如果 tester_file
指令中指定的文件不存在,编译器不会生成警告。
连接的文件必须位于终端的沙箱 MQL5/Files/
中。
tester_library
指令通知测试器需要将库传输到代理,库是一种辅助程序,只能在另一个 MQL 程序的上下文中工作。我们将在单独的章节中详细讨论库。
测试所需的库由源代码中的 #import
指令自动确定。然而,如果外部指标使用了任何库,则必须启用此属性。库可以是扩展名为 dll
的,也可以是扩展名为 ex5
的。
tester_set
指令用于处理包含 MQL 程序设置的设置文件。指令中指定的文件将在测试器的上下文菜单中可用,并允许用户快速应用设置。
如果指定的名称没有路径,设置文件必须与智能交易系统位于同一目录中。这有点出乎意料,因为设置文件的默认目录是 Presets
,并且它们是通过终端界面的命令保存在那里的。要从给定目录连接设置文件,必须在指令中显式指定它,并在前面加上一个斜杠,该斜杠表示 MQL5 文件夹内的绝对路径。
cpp
#property tester_set "/Presets/xyz.set"
当没有前导斜杠时,路径是相对于源文本放置的位置的相对路径。
在添加文件并重新编译程序后,需要在测试器中重新选择智能交易系统;否则,文件将不会被读取!
如果你在设置文件的名称中指定智能交易系统名称和版本号为 "<expert_name> _<number> .set"
,那么它将自动添加到版本号为 <number>
的参数版本下载菜单中。例如,名称 "MACD Sample_4.set"
表示它是智能交易系统 "MACD Sample.mq5"
的版本号为 4 的设置文件。
有兴趣的人可以研究设置文件的格式:为此,在策略测试器中手动保存测试/优化设置,然后在文本编辑器中打开以这种方式创建的文件。
现在让我们看一下 tester_no_cache
指令。在执行优化时,策略测试器会将执行的所有运行结果保存到优化缓存(扩展名为 opt
的文件)中,其中为每组输入参数存储了测试结果。这使得在对相同参数进行重新优化时,可以获取现成的结果,而无需重新计算和浪费时间。
然而,对于某些任务,例如数学计算,可能需要无论优化缓存中是否存在现成结果都进行计算。在这种情况下,在源代码中必须包含属性 tester_no_cache
。同时,测试结果本身仍将存储在缓存中,以便你可以在策略测试器中查看已完成运行的所有数据。
tester_everytick_calculate
指令旨在在测试器中启用每个报价(tick)时计算指标的模式。
默认情况下,测试器中仅在访问指标数据时(即请求指标缓冲区的值时)才计算指标。如果不需要在每个报价时获取指标值,这将显著加快测试和优化速度。
然而,某些程序可能需要在每个报价时重新计算指标。正是在这种情况下,属性 tester_everytick_calculate
才有用。
在以下情况下,策略测试器中的指标也会在每个报价时强制计算:
- 在可视化模式下进行测试时。
- 如果指标中存在
EventChartCustom
、OnChartEvent
或OnTimer
函数。
此属性仅适用于策略测试器中的操作。在终端中,指标总是在每个传入的报价时计算。
该指令实际上已在 FrameTransfer.mq5
智能交易系统中使用:
cpp
#property tester_set "FrameTransfer.set"
我们只是没有重点关注它。文件 "FrameTransfer.set"
位于源代码旁边。在同一个智能交易系统中,我们还需要上述表格中的另一个指令:
cpp
#property tester_no_cache
此外,让我们看一个 tester_file
指令的示例。在前面关于优化时智能交易系统参数自动调优的章节中,我们介绍了 BandOsMApro.mq5
,在其中有必要引入几个影子参数,以便将优化范围传递给在代理上运行的源代码。
tester_file
指令将使我们能够摆脱这些额外的参数。我们将新版本命名为 BandOsMAprofile.mq5
。
由于我们现在熟悉了 tester_set
指令,让我们在新版本中添加前面提到的文件 /Presets/MQL5Book/BandOsMA.set
。
cpp
#property tester_set "/Presets/MQL5Book/BandOsMA.set"
关于 FastOsMA
和 SlowOsMA
周期变化范围和步长的信息将保存到文件 "BandOsMAprofile.csv"
中,而不是使用三个额外的输入参数 FastShadow4Optimization
、SlowShadow4Optimization
、StepsShadow4Optimization
。
cpp
#define SETTINGS_FILE "BandOsMAprofile.csv"
#property tester_file SETTINGS_FILE
const string SettingsFile = SETTINGS_FILE;
影子设置 FastSlowCombo4Optimization
对于完整枚举允许的周期组合仍然是必要的。
cpp
input group "A U X I L I A R Y"
sinput int FastSlowCombo4Optimization = 0; // (reserved for optimization)
回想一下,我们在 Iterate
函数中找到它的优化范围。我们第一次在 OnTesterInit
中调用它时,会完整枚举快速和慢速周期的组合。
基本上,我们可以将所有有效的组合存储在结构体 PairOfPeriods
的数组中,并将其写入一个二进制文件以传输到代理。然后,在代理上,我们的智能交易系统可以从文件中读取现成的数组,并通过 FastSlowCombo4Optimization
索引从数组中提取相应的 FastOsMA
和 SlowOsMA
对。
相反,我们将专注于对程序工作逻辑进行最小的更改:我们将继续在 OnInit
处理函数中通过第二次调用 Iterate
来恢复一对周期。这一次,我们将从 CSV 文件而不是影子参数中获取周期值的枚举范围和步长。
以下是对 OnTesterInit
的更改:
cpp
int OnTesterInit()
{
...
// check if the file already exists before compiling
// - if not, the tester will not be able to send it to agents
const bool preExisted = FileIsExist(SettingsFile);
// write the settings to a file for transfer to copy programs on agents
int handle = FileOpen(SettingsFile, FILE_WRITE | FILE_CSV | FILE_ANSI, ",");
FileWrite(handle, "FastOsMA", start1, step1, stop1);
FileWrite(handle, "SlowOsMA", start2, step2, stop2);
FileClose(handle);
if(!preExisted)
{
PrintFormat("Required file %s is missing. It has been just created."
" Please restart again.",
SettingsFile);
ChartClose();
return INIT_FAILED;
}
...
return INIT_SUCCEEDED;
}
请注意,我们将 OnTesterInit
处理函数的返回类型设置为 int
,这使得如果文件不存在,可以取消优化。然而,无论如何,实际数据都会写入文件,所以如果文件不存在,现在它会被创建,后续的优化启动肯定会成功。
如果你想跳过这一步,可以事先创建一个空文件 MQL5/Files/BandOsMAprofile.csv
。
OnInit
处理函数的更改如下:
cpp
int OnInit()
{
if(FastOsMA >= SlowOsMA) return INIT_PARAMETERS_INCORRECT;
PairOfPeriods p = {FastOsMA, SlowOsMA}; // default initial parameters
int handle = FileOpen(SettingsFile, FILE_READ | FILE_TXT | FILE_ANSI);
// during optimization, a file with shadow parameters is needed
if(MQLInfoInteger(MQL_OPTIMIZATION) && handle == INVALID_HANDLE)
{
return INIT_PARAMETERS_INCORRECT;
}
if(handle != INVALID_HANDLE)
{
if(FastSlowCombo4Optimization != -1)
{
// if there is a shadow copy, read the period values from it
const string line1 = FileReadString(handle);
string settings[];
if(StringSplit(line1, ',', settings) == 4)
{
int FastStart = (int)StringToInteger(settings[1]);
int FastStep = (int)StringToInteger(settings[2]);
int FastStop = (int)StringToInteger(settings[3]);
const string line2 = FileReadString(handle);
if(StringSplit(line2, ',', settings) == 4)
{
int SlowStart = (int)StringToInteger(settings[1]);
int SlowStep = (int)StringToInteger(settings[2]);
int SlowStop = (int)StringToInteger(settings[3]);
p = Iterate(FastStart, FastStop, FastStep,
SlowStart, SlowStop, SlowStep, FastSlowCombo4Optimization);
PrintFormat("MA periods are restored from shadow: FastOsMA=%d SlowOsMA=%d",
p.fast, p.slow);
}
}
}
FileClose(handle);
}
在优化后进行单次测试运行时,我们将在日志中看到基于优化值 FastSlowCombo4Optimization
解码的 FastOsMA
和 SlowOsMA
周期值。将来,我们可以将这些值代入周期参数中,并删除 CSV 文件。我们还规定,如果 FastSlowCombo4Optimization
设置为 -1
,则该文件将不被考虑。
管理指标可见性:TesterHideIndicators
默认情况下,可视化测试图表会显示被测试的专家顾问中创建的所有指标。而且,在测试结束后自动打开的图表上也会显示这些指标。不过,这仅适用于在你的代码中直接创建的指标,用于计算主要指标的嵌套指标并不在此范围内。
从开发者的角度来看,并不总是希望指标可见,因为他们可能想隐藏专家顾问的实现细节。在这种情况下,可以使用 TesterHideIndicators
函数来禁止在图表上显示所使用的指标。
c
void TesterHideIndicators(bool hide);
布尔型参数 hide
用于指示是隐藏(值为 true
)还是显示(值为 false
)指标。MQL 程序执行环境会记住所设置的状态,直到再次调用该函数并传入相反的参数值来改变它。此设置的当前状态会影响所有新创建的指标。
换句话说,应该在创建相应指标的描述符之前调用带有所需标志值 hide
的 TesterHideIndicators
函数。具体而言,在调用该函数并传入参数 true
之后,新创建的指标将被标记为隐藏标志,在可视化测试期间以及测试完成后自动打开的图表上都不会显示。
若要禁用隐藏新创建指标的模式,可调用 TesterHideIndicators
并传入 false
。
该函数仅适用于测试器。
如果在 /MQL5/Profiles/Templates
文件夹中为测试器或专家顾问创建了特殊的 .tpl
模板,该函数在性能方面有一些特殊之处。
- 如果
<expert_name>.tpl
文件夹中有特殊模板,那么在可视化测试和测试图表上,将仅显示该模板中的指标。在这种情况下,即使在专家顾问代码中调用了TesterHideIndicators
并传入false
,被测试的专家顾问中使用的任何指标都不会显示。 - 如果
tester.tpl
文件夹中有模板,那么在可视化测试和测试图表上,将显示tester.tpl
模板中的指标,再加上专家顾问中未被TesterHideIndicators
调用禁止显示的指标。TesterHideIndicators
函数不会影响模板中的指标。 - 如果没有
tester.tpl
模板,但有default.tpl
模板,那么其中的指标将按照类似的原则进行处理。
稍后我们将在“大型专家顾问”示例中演示该函数的工作原理。
存款和取款的模拟
MetaTrader 5 测试器允许模拟存款和取款操作。这使得你可以对一些资金管理系统进行实验。
c
bool TesterDeposit(double money)
TesterDeposit
函数在测试过程中,按照money
参数中指定的存款金额来补充账户资金。金额以测试存款货币表示。
c
bool TesterWithdrawal(double money)
TesterWithdrawal
函数进行与money
相等金额的取款操作。
这两个函数在操作成功时都返回true
。
例如,让我们考虑一个基于“套息交易”策略的智能交易系统。对于这个策略,我们需要选择一个在某一交易方向上有较大正掉期(swap)的交易品种,比如买入澳元兑美元(AUDUSD)。该智能交易系统将在指定方向上开仓一个或多个头寸。为了积累掉期收益,亏损的头寸将被持有。当每手达到预定的盈利金额时,盈利的头寸将被平仓。赚取的掉期收益将从账户中取出。源代码可在CrazyCarryTrade.mq5
文件中找到。
在输入参数中,用户可以选择交易方向、一次交易的规模(默认为 0,这意味着最小交易手数)以及每手的最小盈利,达到该盈利时盈利头寸将被平仓。
c
enum ENUM_ORDER_TYPE_MARKET
{
MARKET_BUY = ORDER_TYPE_BUY,
MARKET_SELL = ORDER_TYPE_SELL
};
input ENUM_ORDER_TYPE_MARKET Type;
input double Volume;
input double MinProfitPerLot = 1000;
首先,让我们在OnInit
处理程序中测试TesterWithdrawal
和TesterDeposit
函数的性能。特别是,尝试取出两倍账户余额将导致错误代码 10019。
c
int OnInit()
{
PRTF(TesterWithdrawal(AccountInfoDouble(ACCOUNT_BALANCE) * 2));
/*
没有足够的资金取出 20000.00(可用保证金:10000.00)
TesterWithdrawal(AccountInfoDouble(ACCOUNT_BALANCE)*2)=false / MQL_ERROR::10019(10019)
*/
...
但是,随后取出 100 单位账户货币并再存入 100 单位的操作将成功。
c
PRTF(TesterWithdrawal(100));
/*
交易 #2 余额 -100.00 [取款] 完成
TesterWithdrawal(100)=true / 成功
*/
PRTF(TesterDeposit(100)); // 存入资金
/*
交易 #3 余额 100.00 [存款] 完成
TesterDeposit(100)=true / 成功
*/
return INIT_SUCCEEDED;
}
在OnTick
处理程序中,让我们使用PositionFilter
检查头寸的可用性,并使用当前的盈利/亏损和累计掉期值填充values
数组。
c
void OnTick()
{
const double volume = Volume == 0 ?
SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN) : Volume;
ENUM_POSITION_PROPERTY_DOUBLE props[] = {POSITION_PROFIT, POSITION_SWAP};
double values[][2];
ulong tickets[];
PositionFilter pf;
pf.select(props, tickets, values, true);
...
当没有头寸时,我们在预定义的方向上开仓一个头寸。
c
if(ArraySize(tickets) == 0) // 没有头寸
{
MqlTradeRequestSync request1;
(Type == MARKET_BUY ? request1.buy(volume) : request1.sell(volume));
}
else
{
... // 有头寸 - 见下一个代码块
}
当有头寸时,我们通过循环遍历这些头寸,并平仓那些有足够盈利(已考虑掉期因素)的头寸。同时,我们还会对已平仓头寸的掉期收益和总亏损进行求和。由于掉期收益与时间成比例增长,我们将其用作平仓“旧”头寸的放大因子。因此,有可能会亏损平仓。
c
double loss = 0, swaps = 0;
for(int i = 0; i < ArraySize(tickets); ++i)
{
if(values[i][0] + values[i][1] * values[i][1] >= MinProfitPerLot * volume)
{
MqlTradeRequestSync request0;
if(request0.close(tickets[i]) && request0.completed())
{
swaps += values[i][1];
}
}
else
{
loss += values[i][0];
}
}
...
如果总亏损增加,我们会定期开仓额外的头寸,但当头寸数量较多时,开仓的频率会降低,以便在一定程度上控制风险。
c
if(loss / ArraySize(tickets) <= -MinProfitPerLot * volume * sqrt(ArraySize(tickets)))
{
MqlTradeRequestSync request1;
(Type == MARKET_BUY ? request1.buy(volume) : request1.sell(volume));
}
...
最后,我们从账户中取出掉期收益。
c
if(swaps >= 0)
{
TesterWithdrawal(swaps);
}
在OnDeinit
处理程序中,我们显示取款统计信息。
c
void OnDeinit(const int)
{
PrintFormat("Deposit: %.2f Withdrawals: %.2f",
TesterStatistics(STAT_INITIAL_DEPOSIT),
TesterStatistics(STAT_WITHDRAWAL));
}
例如,当在 2021 年至 2022 年初期间使用默认设置运行该智能交易系统时,对于澳元兑美元(AUDUSD)我们得到以下结果:
最终余额 10091.19 美元 存款:10000.00 取款:197.42
以下是报告和图表的样子。
带有账户取款的智能交易系统报告
因此,在交易最小手数并且在一年多一点的时间内存入不超过 1%的资金的情况下,我们成功取出了大约 200 美元。
强制停止测试:TesterStop
根据观察到的情况,若有必要,开发者可以提前停止智能交易系统的测试。例如,当达到指定数量的亏损交易或亏损额度水平时,就可以这么做。为此,API 提供了 TesterStop
函数。
c
void TesterStop()
该函数会发出终止测试器的命令,也就是说,只有在程序将控制权返回给执行环境后,测试才会停止。
调用 TesterStop
被视为测试的正常结束,因此这将调用 OnTester
函数,并将所有累积的交易统计数据和优化标准的值返回给策略测试器。
还有一种常规的替代方法来中断测试:使用之前介绍过的 ExpertRemove
函数。调用 ExpertRemove
函数也会返回在调用该函数时所收集的交易统计数据。然而,它们之间存在一些差异。
调用 ExpertRemove
函数的结果是,智能交易系统会从代理的内存中卸载。因此,如果你需要使用一组新的参数运行新的测试,重新加载 MQL 程序将需要一些时间。而使用 TesterStop
函数时,不会出现这种情况,从性能角度来看,这种方法更可取。
另一方面,调用 ExpertRemove
函数会在 MQL 程序中设置 _IsStopped
停止标志,该标志可以在程序的不同部分以标准方式用于完成收尾工作(“清理” 资源)。但是调用 TesterStop
函数不会设置此标志,因此开发者可能需要引入自己的全局变量来表示提前终止,并以特定方式处理它。
需要注意的是,TesterStop
函数旨在仅停止测试器的一次运行。
MQL5 没有提供用于提前终止优化的函数。因此,例如,如果你的智能交易系统检测到优化是在错误的报价点生成模型上启动的,而这只有在优化启动后才能检测到(OnTesterInit
函数在这里不起作用),那么调用 TesterStop
或 ExpertRemove
函数将中断新的测试运行,但测试运行本身仍会继续启动,从而产生大量的无效结果。我们将在 “大型智能交易系统示例” 部分看到这种情况,该部分将使用防止在开盘价启动的保护措施。
可能会认为,在终端中运行且实际充当优化管理器的智能交易系统实例中调用 ExpertRemove
函数会停止优化。但事实并非如此。即使关闭这个在框架模式下运行着智能交易系统的图表,也无法停止优化。
建议你亲自尝试这些函数的实际效果。
大型智能交易系统示例
为了归纳和巩固关于测试器功能的知识,让我们逐步来看一个大型的智能交易系统(Expert Advisor)示例。在这个示例中,我们将总结以下几个方面:
- 使用多个交易品种,包括K线同步
- 在智能交易系统中使用指标
- 使用事件
- 独立计算主要交易统计数据
- 计算针对可变手数调整后的自定义R2优化标准
- 发送和处理包含应用程序数据的帧(按交易品种细分的交易报告)
我们将以MultiMartingale.mq5
作为智能交易系统的技术基础,但会通过切换到多货币超买/超卖信号交易来降低风险,并且仅将增加手数作为可选补充。之前,在BandOsMA.mq5
中,我们已经了解了如何基于指标交易信号进行操作。这次,我们将使用UseUnityPercentPro.mq5
作为信号指标。不过,首先需要对其进行修改。我们将新版本命名为UnityPercentEvent.mq5
。
UnityPercentEvent.mq5
回顾一下Unity指标的本质。它会计算给定一组交易工具中货币或代码的相对强度(假定所有工具都有一个可用于转换的共同货币)。在每根K线上,会为所有货币生成读数:有些货币会更贵,有些会更便宜,而两个极端元素处于临界状态。对于它们,可以考虑两种本质上相反的策略:
- 进一步突破(确认并延续强劲的单边走势)
- 回调(因超买和超卖而向中心反转走势)
为了交易这些信号中的任何一个,我们必须用两种货币(或一般意义上的代码)创建一个交易品种,如果在市场报价窗口中有适合这种组合的品种。例如,如果指标的上限线属于欧元(EUR),下限线属于美元(USD),它们对应欧元兑美元(EURUSD)货币对,根据突破策略我们应该买入,但根据回调策略我们应该卖出。
在更一般的情况下,例如,当指标的工作工具篮子中包含差价合约(CFD)或具有共同报价货币的商品时,并不总是能够创建一个真实的交易工具。对于这种情况,需要通过引入合成交易(复合仓位)来使智能交易系统更加复杂,但我们这里不会这样做,而是将范围限制在外汇市场,因为在外汇市场中几乎所有交叉汇率通常都是可用的。
因此,智能交易系统不仅要读取所有指标缓冲区,还要找出对应最大值和最小值的货币名称。这里有一个小障碍。
MQL5不允许读取第三方指标缓冲区的名称,并且一般来说,除了整数类型的属性外,也不允许读取任何线条属性。有三个设置属性的函数:PlotIndexSetInteger
、PlotIndexSetDouble
和PlotIndexSetString
,但只有一个读取属性的函数:PlotIndexGetInteger
。
理论上,当由同一开发者创建编译成单个交易综合体的MQL程序时,这不是一个大问题。特别是,我们可以将指标源代码的一部分分离到一个头文件中,并不仅在指标中包含它,还在智能交易系统中包含它。然后在智能交易系统中,可以重复对指标输入参数的分析,并恢复与指标创建的完全相同的货币列表。重复计算不太美观,但可行。然而,当指标由不同的开发者开发,并且他们不想透露算法或计划在未来更改算法时(这样指标和智能交易系统的编译版本将变得不兼容),也需要一个更通用的解决方案。将他人的指标与自己的指标对接,或者将智能交易系统外包给自由职业者服务,是非常常见的做法。因此,指标开发者应该使其尽可能便于集成。
一种可能的解决方案是让指标在初始化后发送包含缓冲区编号和名称的消息。
这就是在UnityPercentEvent.mq5
指标的OnInit
处理程序中所做的(以下代码为简化形式,因为几乎没有变化):
cpp
int OnInit()
{
// 为所有货币对找到共同货币
const string common = InitSymbols();
...
// 在货币循环中设置显示的线条
int replaceIndex = -1;
for(int i = 0; i <= SymbolCount; i++)
{
string name;
// 更改顺序,使基础(共同)货币位于索引0处,
// 其余部分取决于用户输入货币对的顺序
if(i == 0)
{
name = common;
if(name != workCurrencies.getKey(i))
{
replaceIndex = i;
}
}
else
{
if(common == workCurrencies.getKey(i) && replaceIndex > -1)
{
name = workCurrencies.getKey(replaceIndex);
}
else
{
name = workCurrencies.getKey(i);
}
}
// 设置缓冲区的渲染
PlotIndexSetString(i, PLOT_LABEL, name);
...
// 将索引和缓冲区名称发送到需要它们的程序中
EventChartCustom(0, (ushort)BarLimit, i, SymbolCount + 1, name);
}
...
}
与原始版本相比,这里只添加了一行代码。它包含EventChartCustom
调用。输入变量BarLimit
用作指标副本的标识符(可能有多个指标副本)。由于指标将从智能交易系统中调用,并且不会显示给用户,因此只需指定一个小的正数,至少为1,但我们这里设为10。
现在指标已经准备好,其信号可以在第三方智能交易系统中使用。让我们开始开发智能交易系统UnityMartingale.mq5
。为了简化说明,我们将其分为四个阶段,逐步添加新的模块。我们将有三个初步版本和一个最终版本。
UnityMartingaleDraft1.mq5
在第一阶段,对于UnityMartingaleDraft1.mq5
版本,我们以MultiMartingale.mq5
为基础并进行修改。
我们将之前用于确定系列中第一笔交易方向的输入变量StartType
重命名为SignalType
。它将用于在考虑的突破(BREAKOUT)和回调(PULLBACK)策略之间进行选择。
cpp
enum SIGNAL_TYPE
{
BREAKOUT,
PULLBACK
};
...
input SIGNAL_TYPE StartType = 0; // SignalType
为了设置指标,我们需要一组单独的输入变量。
cpp
input group "U N I T Y S E T T I N G S"
input string UnitySymbols = "EURUSD,GBPUSD,USDCHF,USDJPY,AUDUSD,USDCAD,NZDUSD";
input int UnityBarLimit = 10;
input ENUM_APPLIED_PRICE UnityPriceType = PRICE_CLOSE;
input ENUM_MA_METHOD UnityPriceMethod = MODE_EMA;
input int UnityPricePeriod = 1;
请注意,UnitySymbols
参数包含用于构建指标的一组工具,通常与我们想要交易的工作工具列表不同。交易工具仍然在WorkSymbols
参数中设置。
例如,默认情况下,我们将一组主要的外汇货币对传递给指标,因此我们不仅可以将主要货币对作为交易对象,还可以选择任何交叉货币对。通常,将这个集合限制在交易条件最佳的工具上是有意义的(特别是,点差较小或适中)。此外,最好避免失真,即保持所有货币对中每种货币的数量相等,从而在统计上中和选择某一种货币的不利方向的潜在风险。
接下来,我们将指标控制封装在UnityController
类中。除了指标句柄外,该类的字段还存储以下数据:
- 指标缓冲区的数量,将在指标初始化后从指标的消息中获取
- 开始读取数据的K线编号(通常当前未完成的K线为0,最后完成的K线为1)
- 在指定K线上从指标缓冲区读取的值的数据数组
- 最后一次读取的时间
lastRead
- 按报价或K线操作的标志
tickwise
此外,该类使用MultiSymbolMonitor
对象来同步所有涉及的交易品种的K线。
cpp
class UnityController
{
int handle;
int buffers;
const int bar;
double data[];
datetime lastRead;
const bool tickwise;
MultiSymbolMonitor sync;
...
// 构造函数,通过参数接收指标的所有参数
public:
UnityController(const string symbolList, const int offset, const int limit,
const ENUM_APPLIED_PRICE type, const ENUM_MA_METHOD method, const int period):
bar(offset), tickwise(!offset)
{
handle = iCustom(_Symbol, _Period, "MQL5Book/p6/UnityPercentEvent",
symbolList, limit, type, method, period);
lastRead = 0;
string symbols[];
const int n = StringSplit(symbolList, ',', symbols);
for(int i = 0; i < n; ++i)
{
sync.attach(symbols[i]);
}
}
~UnityController()
{
IndicatorRelease(handle);
}
...
// 通过attached方法设置缓冲区数量
void attached(const int b)
{
buffers = b;
ArrayResize(data, buffers);
}
// 当所有交易品种的最后一根K线时间相同时,isReady方法返回true
bool isReady()
{
return sync.check(true) == 0;
}
// 根据指标操作模式以不同方式定义当前时间
datetime lastTime() const
{
return tickwise ? TimeTradeServer() : iTime(_Symbol, _Period, 0);
}
// 读取指标缓冲区的方法
bool read()
{
if(!buffers) return false;
for(int i = 0; i < buffers; ++i)
{
double temp[1];
if(CopyBuffer(handle, i, bar, 1, temp) == 1)
{
data[i] = temp[0];
}
else
{
return false;
}
}
lastRead = lastTime();
return true;
}
// 判断是否为新时间的方法
bool isNewTime() const
{
return lastRead != lastTime();
}
// 获取最大值和最小值索引的方法
bool getOuterIndices(int &min, int &max)
{
if(isNewTime())
{
if(!read()) return false;
}
max = ArrayMaximum(data);
min = ArrayMinimum(data);
return true;
}
// 重载[]运算符以读取值
double operator[](const int buffer)
{
if(isNewTime())
{
if(!read())
{
return EMPTY_VALUE;
}
}
return data[buffer];
}
};
之前,智能交易系统BandOsMA.mq5
引入了TradingSignal
接口。
cpp
interface TradingSignal
{
virtual int signal(void);
};
基于此,我们将描述使用UnityPercentEvent
指标实现信号的方法。UnityController
对象被传递给构造函数。它还指定了我们想要跟踪信号的货币(缓冲区)索引。我们可以为选定的工作交易品种创建任意一组不同的信号。
cpp
class UnitySignal: public TradingSignal
{
UnityController *controller;
const int currency1;
const int currency2;
public:
UnitySignal(UnityController *parent, const int c1, const int c2):
controller(parent), currency1(c1), currency2(c2) { }
virtual int signal(void) override
{
if(!controller.isReady()) return 0; // 等待K线同步
if(!controller.isNewTime()) return 0; // 等待时间变化
int min, max;
if(!controller.getOuterIndices(min, max)) return 0;
// 超买
if(currency1 == max && currency2 == min) return +1;
// 超卖
if(currency2 == max && currency1 == min) return -1;
return 0;
}
};
signal
方法在不确定的情况下返回0,在两种特定货币的超买和超卖状态下返回 +1 或 -1。
为了规范交易策略,我们使用了TradingStrategy
接口。
cpp
interface TradingStrategy
{
virtual bool trade(void);
};
在这种情况下,基于该接口创建了UnityMartingale
类,它在很大程度上与MultiMartingale.mq5
中的SimpleMartingale
类相同。我们只展示不同之处。
cpp
class UnityMartingale: public TradingStrategy
{
protected:
...
AutoPtr<TradingSignal> command;
public:
UnityMartingale(const Settings &state, TradingSignal *signal)
{
...
command = signal;
}
virtual bool trade() override
{
...
int s = command->signal(); // 获取控制器信号
if(s != 0)
{
if(settings.startType == PULLBACK) s *= -1; // 回调策略反转逻辑
}
ulong ticket = 0;
if(position == NULL) // 全新开始 - 没有持仓(现在也没有)
{
if(s == +1)
{
ticket = openBuy(settings.lots);
}
else if(s == -1)
{
ticket = openSell(settings.lots);
}
}
else
{
if(position->refresh()) // 持仓存在
{
if((position->get(POSITION_TYPE) == POSITION_TYPE_BUY && s == -1)
|| (position->get(POSITION_TYPE) == POSITION_TYPE_SELL && s == +1))
{
// 反向信号 - 需要平仓
PrintFormat("Opposite signal: %d for position %d %lld",
s, position->get(POSITION_TYPE), position->get(POSITION_TICKET));
if(close(position->get(POSITION_TICKET)))
{
// position = NULL; - 将持仓保存在缓存中
}
else
{
position->refresh(); // 控制可能的平仓错误
}
}
else
{
// 信号相同或不存在 - 跟踪止损
position->update();
if(trailing) trailing->trail();
}
}
else // 没有持仓 - 开新仓
{
if(s == 0) // 没有信号
{
// 这里是旧智能交易系统的完整逻辑:
// - 马丁格尔亏损反转
// - 盈利方向按初始手数继续
...
}
else // 有信号
{
double lots;
if(position->get(POSITION_PROFIT) >= 0.0)
{
lots = settings.lots; // 盈利后使用初始手数
}
else // 亏损后增加手数
{
lots = MathFloor((position->get(POSITION_VOLUME) * settings.factor) / lotsStep) * lotsStep;
if(lotsLimit < lots)
{
lots = settings.lots;
}
}
ticket = (s == +1) ? openBuy(lots) : openSell(lots);
}
}
}
}
...
}
交易部分已经完成。接下来考虑初始化部分。在全局级别描述了一个指向UnityController
对象的自动指针和一个包含货币名称的数组。交易系统池与之前的开发完全相同。
cpp
AutoPtr<TradingStrategyPool> pool;
AutoPtr<UnityController> controller;
int currenciesCount;
string currencies[];
在OnInit
处理程序中,我们创建UnityController
对象,并等待指标发送按缓冲区索引划分的货币分布信息。
c
int OnInit()
{
currenciesCount = 0;
ArrayResize(currencies, 0);
if(!StartUp(true)) return INIT_PARAMETERS_INCORRECT;
const bool barwise = UnityPriceType == PRICE_CLOSE && UnityPricePeriod == 1;
controller = new UnityController(UnitySymbols, barwise,
UnityBarLimit, UnityPriceType, UnityPriceMethod, UnityPricePeriod);
// 等待指标发送关于缓冲区中货币的消息
return INIT_SUCCEEDED;
}
如果在指标输入参数中选择了价格类型PRICE_CLOSE
(收盘价)且周期为单个周期,那么控制器中的计算将每根K线执行一次。在所有其他情况下,信号将按报价数据点(tick)更新,但每秒更新频率不超过一次(回想一下控制器中lastTime
方法的实现)。
辅助方法StartUp
的功能总体上与智能交易系统MultiMartingale
中旧的OnInit
处理程序相同。它用设置填充Settings
结构体,检查设置的正确性,并创建一个交易系统池TradingStrategyPool
,该池由针对不同交易品种WorkSymbols
的UnityMartingale
类对象组成。然而,现在这个过程分为两个阶段,因为我们需要等待关于缓冲区中货币分布的信息。因此,StartUp
函数有一个输入参数,用于表示是来自OnInit
的调用,以及后来来自OnChartEvent
的调用。
在分析StartUp
的源代码时,重要的是要记住,当我们只交易与当前图表匹配的一种交易品种,以及当指定了一篮子交易品种时,初始化是不同的。当WorkSymbols
为空字符串时,第一种模式处于激活状态。这对于为特定交易品种优化智能交易系统很方便。在找到几种交易品种的设置后,我们可以在WorkSymbols
中组合它们。
c
bool StartUp(const bool init = false)
{
if(WorkSymbols == "")
{
Settings settings =
{
UseTime, HourStart, HourEnd,
Lots, Factor, Limit,
StopLoss, TakeProfit,
StartType, Magic, SkipTimeOnError, Trailing, _Symbol
};
if(settings.validate())
{
if(init)
{
Print("Input settings:");
settings.print();
}
}
else
{
if(init) Print("Wrong settings, please fix");
return false;
}
if(!init)
{
...// 创建基于指标的交易系统
}
}
else
{
Print("Parsed settings:");
Settings settings[];
if(!Settings::parseAll(WorkSymbols, settings))
{
if(init) Print("Settings are incorrect, can't start up");
return false;
}
if(!init)
{
...// 创建基于指标的交易系统
}
}
return true;
}
在OnInit
中调用StartUp
函数时,参数为true
,这意味着仅检查设置的正确性。交易系统对象的创建会延迟,直到在OnChartEvent
中收到来自指标的消息。
c
void OnChartEvent(const int id,
const long &lparam, const double &dparam, const string &sparam)
{
if(id == CHARTEVENT_CUSTOM + UnityBarLimit)
{
PrintFormat("%lld %f '%s'", lparam, dparam, sparam);
if(lparam == 0) ArrayResize(currencies, 0);
currenciesCount = (int)MathRound(dparam);
PUSH(currencies, sparam);
if(ArraySize(currencies) == currenciesCount)
{
if(pool[] == NULL)
{
start up(); // 确认指标已准备好
}
else
{
Alert("Repeated initialization!");
}
}
}
}
在这里,我们将货币数量记录在全局变量currenciesCount
中,并将它们存储在currencies
数组中,然后我们使用参数false
(默认值,因此省略)调用StartUp
。消息按照它们在指标缓冲区中存在的顺序从队列中到达。因此,我们得到了索引和货币名称之间的对应关系。
当再次调用StartUp
时,将执行额外的代码:
c
bool StartUp(const bool init = false)
{
if(WorkSymbols == "") // 一个当前交易品种
{
...
if(!init) // OnInit 之后的最终初始化
{
controller[].attached(currenciesCount);
// 将 _Symbol 拆分为 currencies 数组中的两种货币
int first, second;
if(!SplitSymbolToCurrencyIndices(_Symbol, first, second))
{
PrintFormat("Can't find currencies (%s %s) for %s",
(first == -1 ? "base" : ""), (second == -1 ? "profit" : ""), _Symbol);
return false;
}
// 从单个策略创建一个池
pool = new TradingStrategyPool(new UnityMartingale(settings,
new UnitySignal(controller[], first, second)));
}
}
else // 交易品种篮子
{
...
if(!init) // OnInit 之后的最终初始化
{
controller[].attached(currenciesCount);
const int n = ArraySize(settings);
pool = new TradingStrategyPool(n);
for(int i = 0; i < n; i++)
{
...
// 将 settings[i].symbol 拆分为 currencies 数组中的两种货币
int first, second;
if(!SplitSymbolToCurrencyIndices(settings[i].symbol, first, second))
{
PrintFormat("Can't find currencies (%s %s) for %s",
(first == -1 ? "base" : ""), (second == -1 ? "profit" : ""),
settings[i].symbol);
}
else
{
// 将一个策略添加到下一个交易品种的池中
pool[].push(new UnityMartingale(settings[i],
new UnitySignal(controller[], first, second)));
}
}
}
}
辅助函数SplitSymbolToCurrencyIndices
选择传入交易品种的基础货币和盈利货币,并在currencies
数组中找到它们的索引。因此,我们得到了在UnitySignal
对象中生成信号的参考数据。每个对象都将有自己的一对货币索引。
c
bool SplitSymbolToCurrencyIndices(const string symbol, int &first, int &second)
{
const string s1 = SymbolInfoString(symbol, SYMBOL_CURRENCY_BASE);
const string s2 = SymbolInfoString(symbol, SYMBOL_CURRENCY_PROFIT);
first = second = -1;
for(int i = 0; i < ArraySize(currencies); ++i)
{
if(currencies[i] == s1) first = i;
else if(currencies[i] == s2) second = i;
}
return first != -1 && second != -1;
}
总体而言,智能交易系统已准备就绪。
你可以看到,在智能交易系统的最后几个示例中,我们有策略类和交易信号类。我们特意让它们成为通用接口TradingStrategy
和TradingSignal
的派生类,以便随后能够收集兼容但不同的实现集合,这些集合可以在未来智能交易系统的开发中进行组合。这样的统一具体类通常应该分离到单独的头文件中。在我们的示例中,为了简化逐步修改过程,我们没有这样做。
然而,所描述的方法对于面向对象编程(OOP)来说是标准的。特别是,正如我们在创建智能交易系统草案的部分中提到的,与 MetaTrader 5 一起提供的还有一个头文件框架,其中包含交易操作、信号指标和资金管理的标准类,这些类在 MQL 向导中使用。其他类似的解决方案发布在 mql5.com 网站的文章和代码库部分中。
如果现成的类层次结构在功能和易用性方面合适,你可以将其用作项目的基础。
为了完善这个内容体系,我们想在智能交易系统中引入我们自己的基于 R2 的优化标准。为了避免 R2 计算公式中的线性回归与我们策略中包含的可变手数之间的矛盾,我们将不是针对通常的余额线计算系数,而是针对每次交易中按手数归一化的累计增量计算系数。
为此,在OnTester
处理程序中,我们选择交易类型为DEAL_TYPE_BUY
(买入)和DEAL_TYPE_SELL
(卖出)且方向为OUT
(平仓)的交易。我们将请求所有构成财务结果(盈利/亏损)的交易属性,即DEAL_PROFIT
(交易盈利)、DEAL_SWAP
(掉期)、DEAL_COMMISSION
(佣金)、DEAL_FEE
(费用),以及它们的DEAL_VOLUME
(交易量)。
c
#define STAT_PROPS 5 // 所需交易属性的数量
double OnTester()
{
HistorySelect(0, LONG_MAX);
const ENUM_DEAL_PROPERTY_DOUBLE props[STAT_PROPS] =
{
DEAL_PROFIT, DEAL_SWAP, DEAL_COMMISSION, DEAL_FEE, DEAL_VOLUME
};
double expenses[][STAT_PROPS];
ulong tickets[]; // 因'select'方法原型所需,但对调试很有用
DealFilter filter;
filter.let(DEAL_TYPE, (1 << DEAL_TYPE_BUY) | (1 << DEAL_TYPE_SELL), IS::OR_BITWISE)
.let(DEAL_ENTRY, (1 << DEAL_ENTRY_OUT) | (1 << DEAL_ENTRY_INOUT) | (1 << DEAL_ENTRY_OUT_BY),
IS::OR_BITWISE)
.select(props, tickets, expenses);
...
// 接下来,在balance数组中,我们累加按交易量归一化的盈亏,并为其计算标准R2值
const int n = ArraySize(tickets);
double balance[];
ArrayResize(balance, n + 1);
balance[0] = TesterStatistics(STAT_INITIAL_DEPOSIT);
for(int i = 0; i < n; ++i)
{
double result = 0;
for(int j = 0; j < STAT_PROPS - 1; ++j)
{
result += expenses[i][j];
}
result /= expenses[i][STAT_PROPS - 1]; // 按交易量归一化
balance[i + 1] = result + balance[i];
}
const double r2 = RSquaredTest(balance);
return r2 * 100;
}
该智能交易系统的第一个版本基本完成了。我们还没有包含使用TickModel.mqh
对报价点模型的检查。假定该智能交易系统将在OHLC M1模式或更高模式下生成报价点时进行测试。当检测到“仅开盘价”模型时,智能交易系统会向终端发送一个带有错误状态的特殊数据帧,并将自身从测试器中卸载。遗憾的是,这只会停止本次运行,但优化仍会继续。因此,在终端中运行的智能交易系统副本会向用户发出“警报”,提示用户手动中断优化。
c
void OnTesterPass()
{
ulong pass;
string name;
long id;
double value;
uchar data[];
while(FrameNext(pass, name, id, value, data))
{
if(name == "status" && id == 1)
{
Alert("请停止优化!");
Alert("报价点模型不正确:需要OHLC M1或更高模式");
// 如果下一次调用能停止所有优化,那将是合理的,但事实并非如此
ExpertRemove();
}
}
}
你可以针对任何交易品种优化SYMBOL SETTINGS
(品种设置)参数,并针对不同的交易品种重复进行优化。同时,COMMON SETTINGS
(通用设置)和UNITY SETTINGS
(统一设置)组应始终包含相同的设置,因为它们适用于所有交易品种和交易系统实例。例如,对于所有优化,追踪止损(Trailing)必须要么启用,要么禁用。还需注意,单个交易品种的输入变量(即SYMBOL SETTINGS
组)仅在WorkSymbols
包含空字符串时才起作用。因此,在优化阶段,应将其保持为空。
例如,为了分散风险,你可以依次在完全独立的交易品种对(如EURUSD、AUDJPY、GBPCHF、NZDCAD)上优化智能交易系统,或者采用其他组合。源代码中连接了三个包含私有设置示例的设置文件。
c
#property tester_set "UnityMartingale-eurusd.set"
#property tester_set "UnityMartingale-gbpchf.set"
#property tester_set "UnityMartingale-audjpy.set"
为了同时在三个交易品种上进行交易,这些设置应“打包”到一个通用参数WorkSymbols
中:
EURUSD+0.01*1.6^5(200,200)[17,21];GBPCHF+0.01*1.2^8(600,800)[7,20];AUDJPY+0.01*1.2^8(600,800)[7,20]
此设置也包含在一个单独的文件中。
c
#property tester_set "UnityMartingale-combo.set"
当前版本智能交易系统的问题之一是,测试器报告将提供所有交易品种的总体统计数据(更准确地说,是所有交易策略的统计数据,因为我们可以在池中包含不同的类),而我们更感兴趣的是分别监控和评估系统的每个组件。
为此,你需要学习如何像测试器为我们做的那样,独立计算交易的主要财务指标。我们将在智能交易系统开发的第二阶段处理这个问题。
UnityMartingaleDraft2.mq5
统计计算可能会经常用到,所以我们将在一个单独的头文件TradeReport.mqh
中实现它,在这个文件中,我们将源代码组织到相应的类中。
我们将主类命名为TradeReport
。许多交易变量取决于账户余额和可用保证金(净值)曲线。因此,该类包含用于跟踪当前账户余额和利润的变量,以及一个不断更新的账户余额历史数组。我们不会存储净值历史,因为它可能在每个报价点都发生变化,最好是实时计算。稍后我们将看到拥有账户余额曲线的原因。
c
class TradeReport
{
double balance; // 当前账户余额
double floating; // 当前浮动利润
double data[]; // 完整的账户余额曲线 - 价格
datetime moments[]; // 以及日期/时间
...
// 更改和读取类字段是通过方法完成的,包括构造函数,在构造函数中通过ACCOUNT_BALANCE属性初始化账户余额
TradeReport()
{
balance = AccountInfoDouble(ACCOUNT_BALANCE);
}
void resetFloatingPL()
{
floating = 0;
}
void addFloatingPL(const double pl)
{
floating += pl;
}
void addBalance(const double pl)
{
balance += pl;
}
double getCurrent() const
{
return balance + floating;
}
...
这些方法将用于迭代计算净值回撤(实时计算)。在一次性计算账户余额回撤时将需要data
账户余额数组(我们将在测试结束时进行此操作)。
基于曲线的波动(无论是账户余额曲线还是净值曲线都无关紧要),应使用相同的算法计算绝对回撤和相对回撤。因此,该算法以及存储中间状态所需的内部变量在嵌套结构DrawDown
中实现。下面的代码展示了它的主要方法和属性。
c
struct DrawDown
{
double
series_start,
series_min,
series_dd,
series_dd_percent,
series_dd_relative_percent,
series_dd_relative;
...
void reset();
void calcDrawdown(const double &data[]);
void calcDrawdown(const double amount);
void print() const;
};
第一个calcDrawdown
方法在我们知道整个数组的情况下计算回撤,这将用于计算账户余额回撤。第二个calcDrawdown
方法迭代计算回撤:每次调用时,它会被告知序列的下一个值,这将用于计算净值回撤。
此外,如我们所知,报告中有大量的标准统计数据,但我们首先只支持其中的一些。为此,我们在另一个嵌套结构GenericStats
中描述相应的字段。它继承自DrawDown
,因为我们在报告中仍然需要回撤数据。
c
struct GenericStats: public DrawDown
{
long deals;
long trades;
long buy_trades;
long wins;
long buy_wins;
long sell_wins;
double profits;
double losses;
double net;
double pf;
double average_trade;
double recovery;
double max_profit;
double max_loss;
double sharpe;
...
通过变量的名称,很容易猜出它们对应的标准指标是什么。一些指标是多余的,因此被省略了。例如,给定交易的总数(trades
)和其中买入交易的数量(buy_trades
),我们可以很容易地计算出卖出交易的数量(trades - sell_trades
)。对于互补的盈利/亏损统计数据也是如此。连胜和连亏情况未被计算在内。有需要的人可以用这些指标来补充我们的报告。
为了与测试器的总体统计数据保持一致,有一个fillByTester
方法,它通过TesterStatistics
函数填充所有字段。我们稍后会用到它。
c
void fillByTester()
{
deals = (long)TesterStatistics(STAT_DEALS);
trades = (long)TesterStatistics(STAT_TRADES);
buy_trades = (long)TesterStatistics(STAT_LONG_TRADES);
wins = (long)TesterStatistics(STAT_PROFIT_TRADES);
buy_wins = (long)TesterStatistics(STAT_PROFIT_LONGTRADES);
sell_wins = (long)TesterStatistics(STAT_PROFIT_SHORTTRADES);
profits = TesterStatistics(STAT_GROSS_PROFIT);
losses = TesterStatistics(STAT_GROSS_LOSS);
net = TesterStatistics(STAT_PROFIT);
pf = TesterStatistics(STAT_PROFIT_FACTOR);
average_trade = TesterStatistics(STAT_EXPECTED_PAYOFF);
recovery = TesterStatistics(STAT_RECOVERY_FACTOR);
sharpe = TesterStatistics(STAT_SHARPE_RATIO);
max_profit = TesterStatistics(STAT_MAX_PROFITTRADE);
max_loss = TesterStatistics(STAT_MAX_LOSSTRADE);
series_start = TesterStatistics(STAT_INITIAL_DEPOSIT);
series_min = TesterStatistics(STAT_EQUITYMIN);
series_dd = TesterStatistics(STAT_EQUITY_DD);
series_dd_percent = TesterStatistics(STAT_EQUITYDD_PERCENT);
series_dd_relative_percent = TesterStatistics(STAT_EQUITY_DDREL_PERCENT);
series_dd_relative = TesterStatistics(STAT_EQUITY_DD_RELATIVE);
}
};
当然,对于测试器无法计算的那些交易系统的单独账户余额和净值,我们需要实现自己的计算方法。上面已经给出了calcDrawdown
方法的原型。在运行过程中,它们会用带有“series_dd
”前缀的最后一组字段进行填充。此外,TradeReport
类包含一个计算夏普比率的方法。它以一组数字和无风险资金利率作为输入。完整的源代码可以在附件文件中找到。
c
static double calcSharpe(const double &data[], const double riskFreeRate = 0);
你可能已经猜到,调用此方法时,TradeReport
类中与账户余额相关的成员数组将作为data
参数传递。填充该数组以及针对特定指标调用上述方法的过程发生在calcStatistics
方法中(见下文)。一个交易对象过滤器(filter
)、初始存款(start
)和时间(origin
)作为输入传递给它。假定调用代码将设置过滤器,使得只有我们感兴趣的交易系统的交易才会被包含在内。
该方法返回一个已填充的GenericStats
结构,此外,它还会分别用账户余额值和变化的时间参考填充TradeReport
对象内部的两个数组data
和moments
。在智能交易系统的最终版本中我们会用到这些。
c
GenericStats calcStatistics(DealFilter &filter,
const double start = 0, const datetime origin = 0,
const double riskFreeRate = 0)
{
GenericStats stats;
ArrayResize(data, 0);
ArrayResize(moments, 0);
ulong tickets[];
if(!filter.select(tickets)) return stats;
balance = start;
PUSH(data, balance);
PUSH(moments, origin);
for(int i = 0; i < ArraySize(tickets); ++i)
{
DealMonitor m(tickets[i]);
if(m.get(DEAL_TYPE) == DEAL_TYPE_BALANCE) // 存款/取款
{
balance += m.get(DEAL_PROFIT);
PUSH(data, balance);
PUSH(moments, (datetime)m.get(DEAL_TIME));
}
else if(m.get(DEAL_TYPE) == DEAL_TYPE_BUY
|| m.get(DEAL_TYPE) == DEAL_TYPE_SELL)
{
const double profit = m.get(DEAL_PROFIT) + m.get(DEAL_SWAP)
+ m.get(DEAL_COMMISSION) + m.get(DEAL_FEE);
balance += profit;
stats.deals++;
if(m.get(DEAL_ENTRY) == DEAL_ENTRY_OUT
|| m.get(DEAL_ENTRY) == DEAL_ENTRY_INOUT
|| m.get(DEAL_ENTRY) == DEAL_ENTRY_OUT_BY)
{
PUSH(data, balance);
PUSH(moments, (datetime)m.get(DEAL_TIME));
stats.trades++; // 交易按平仓交易计算
if(m.get(DEAL_TYPE) == DEAL_TYPE_SELL)
{
stats.buy_trades++; // 以相反方向的交易平仓
}
if(profit >= 0)
{
stats.wins++;
if(m.get(DEAL_TYPE) == DEAL_TYPE_BUY)
{
stats.sell_wins++; // 以相反方向的交易平仓
}
else
{
stats.buy_wins++;
}
}
}
else if(!TU::Equal(profit, 0))
{
PUSH(data, balance); // 入场费用(如果有)
PUSH(moments, (datetime)m.get(DEAL_TIME));
}
if(profit >= 0)
{
stats.profits += profit;
stats.max_profit = fmax(profit, stats.max_profit);
}
else
{
stats.losses += profit;
stats.max_loss = fmin(profit, stats.max_loss);
}
}
}
if(stats.trades > 0)
{
stats.net = stats.profits + stats.losses;
stats.pf = -stats.losses > DBL_EPSILON?
stats.profits / -stats.losses : MathExp(10000.0); // NaN(+inf)
stats.average_trade = stats.net / stats.trades;
stats.sharpe = calcSharpe(data, riskFreeRate);
stats.calcDrawdown(data); // 填充DrawDown子结构的所有字段
stats.recovery = stats.series_dd > DBL_EPSILON?
stats.net / stats.series_dd : MathExp(10000.0);
}
return stats;
}
};
在这里你可以看到我们如何调用calcSharpe
和calcDrawdown
来从数组data
中获取相应的指标。其余指标直接在calcStatistics
内部的循环中计算。
TradeReport
类已完成,我们可以将智能交易系统的功能扩展到UnityMartingaleDraft2.mq5
版本。
让我们向UnityMartingale
类添加新成员。
c
class UnityMartingale: public TradingStrategy
{
protected:
...
TradeReport report;
TradeReport::DrawDown equity;
const double deposit;
const datetime epoch;
...
我们需要report
对象来调用calcStatistics
,其中将包含账户余额回撤的计算。equity
对象用于独立计算净值回撤。初始账户余额和日期,以及净值回撤计算的起始点在构造函数中设置。
c
public:
UnityMartingale(const Settings &state, TradingSignal *signal):
symbol(state.symbol), deposit(AccountInfoDouble(ACCOUNT_BALANCE)),
epoch(TimeCurrent())
{
...
equity.calcDrawdown(deposit);
...
}
每次调用trade
方法时,都会实时继续计算净值回撤。
c
virtual bool trade() override
{
...
if(MQLInfoInteger(MQL_TESTER))
{
if(position[])
{
report.resetFloatingPL();
// 重置后,累加所有浮动利润
// 为什么我们要为每个现有头寸调用addFloatingPL,
// 但这个策略一次最多只有1个头寸
report.addFloatingPL(position[].get(POSITION_PROFIT)
+ position[].get(POSITION_SWAP));
// 考虑所有金额后 - 更新回撤
equity.calcDrawdown(report.getCurrent());
}
}
...
}
这对于正确的计算来说还不够。除了账户余额之外,我们还应该考虑浮动盈亏。上述代码部分仅展示了addFloatingPL
调用,但TradeReport
类还有一个用于修改账户余额的方法:addBalance
。然而,只有在平仓时账户余额才会发生变化。
多亏了面向对象编程(OOP)的概念,在我们这种情况下,平仓对应于删除PositionState
类的持仓对象。那么我们为什么不能拦截这个操作呢?
PositionState
类没有为此提供任何方法,但我们可以声明一个派生类PositionStateWithEquity
,它带有一个特殊的构造函数和析构函数。
在创建对象时,不仅要将持仓标识符传递给构造函数,还要传递一个指向报告对象的指针,需要向该报告对象发送信息。
cpp
class PositionStateWithEquity: public PositionState
{
TradeReport *report;
public:
PositionStateWithEquity(const long t, TradeReport *r):
PositionState(t), report(r) { }
...
在析构函数中,我们通过已平仓的仓位ID找到所有相关交易,计算总财务结果(包括佣金和其他扣除项),然后调用相关报告对象的addBalance
方法。
cpp
~PositionStateWithEquity()
{
if(HistorySelectByPosition(get(POSITION_IDENTIFIER)))
{
double result = 0;
DealFilter filter;
int props[] = {DEAL_PROFIT, DEAL_SWAP, DEAL_COMMISSION, DEAL_FEE};
Tuple4<double, double, double, double> overheads[];
if(filter.select(props, overheads))
{
for(int i = 0; i < ArraySize(overheads); ++i)
{
result += NormalizeDouble(overheads[i]._1, 2)
+ NormalizeDouble(overheads[i]._2, 2)
+ NormalizeDouble(overheads[i]._3, 2)
+ NormalizeDouble(overheads[i]._4, 2);
}
}
if(CheckPointer(report) != POINTER_INVALID) report.addBalance(result);
}
}
};
还有一点需要说明——如何为仓位创建PositionStateWithEquity
类对象,而不是PositionState
类对象。要做到这一点,只需在TradingStrategy
类中调用new
操作符的几个地方进行修改即可。
cpp
position = MQLInfoInteger(MQL_TESTER)?
new PositionStateWithEquity(tickets[0], &report) : new PositionState(tickets[0]);
这样,我们就实现了数据的收集。现在我们需要直接生成一份报告,也就是调用calcStatistics
。这里我们需要扩展TradingStrategy
接口:向其中添加statement
方法。
cpp
interface TradingStrategy
{
virtual bool trade(void);
virtual bool statement();
};
然后,在我们当前为该策略设计的实现中,我们就能将工作推进到逻辑上的结尾部分。
cpp
class UnityMartingale: public TradingStrategy
{
...
virtual bool statement() override
{
if(MQLInfoInteger(MQL_TESTER))
{
Print("Separate trade report for ", settings.symbol);
// 权益回撤应该已经在运行时计算好了
Print("Equity DD:");
equity.print();
// 余额回撤在最终报告中计算
Print("Trade Statistics (with Balance DD):");
// 为特定策略配置过滤器
DealFilter filter;
filter.let(DEAL_SYMBOL, settings.symbol)
.let(DEAL_MAGIC, settings.magic, IS::EQUAL_OR_ZERO);
// 零“魔术”数字对于最后一笔平仓交易是必要的
// - 这是由测试器自己完成的
HistorySelect(0, LONG_MAX);
TradeReport::GenericStats stats =
report.calcStatistics(filter, deposit, epoch);
stats.print();
}
return false;
}
...
新方法将简单地在日志中打印出所有计算出的指标。通过TradingStrategyPool
交易系统池转发相同的方法,我们从OnTester
处理程序中请求所有符号的单独报告。
cpp
double OnTester()
{
...
if(pool[] != NULL)
{
pool[].statement(); // 要求所有交易系统显示它们的结果
}
...
}
让我们检查一下报告的正确性。为此,我们在测试器中一次运行一个符号的智能交易系统,并将标准报告与我们的计算结果进行比较。例如,设置UnityMartingale-eurusd.set
,在EURUSD H1上进行交易,我们将得到2021年的如下指标。
2021年,EURUSD H1的测试器报告
2021年,EURUSD H1的测试器报告
在日志中,我们的版本显示为两个结构:包含权益回撤的DrawDown
和包含余额回撤指标及其他统计数据的GenericStats
。
EURUSD的单独交易报告
权益回撤:
[maxpeak] [minpeak] [series_start] [series_min] [series_dd] [series_dd_percent] »
[0] 10022.48 10017.03 10000.00 9998.20 6.23 0.06 »
» [series_dd_relative_percent] [series_dd_relative]
» 0.06 6.23
交易统计数据(包含余额回撤):
[maxpeak] [minpeak] [series_start] [series_min] [series_dd] [series_dd_percent] »
[0] 10022.40 10017.63 10000.00 9998.51 5.73 0.06 »
» [series_dd_relative_percent] [series_dd_relative] »
» 0.06 5.73 »
» [deals] [trades] [buy_trades] [wins] [buy_wins] [sell_wins] [profits] [losses] [net] [pf] »
» 194 97 43 42 19 23 57.97 -39.62 18.35 1.46 »
» [average_trade] [recovery] [max_profit] [max_loss] [sharpe]
» 0.19 3.20 2.00 -2.01 0.15
很容易验证这些数字与测试器的报告是相符的。
现在让我们开始在同一时期同时对三个符号进行交易(设置UnityMartingale-combo.set
)。
除了EURUSD的记录外,日志中还会出现GBPCHF和AUDJPY的结构。
GBPCHF的单独交易报告
权益回撤:
[maxpeak] [minpeak] [series_start] [series_min] [series_dd] [series_dd_percent] »
[0] 10029.50 10000.19 10000.00 9963.65 62.90 0.63 »
» [series_dd_relative_percent] [series_dd_relative]
» 0.63 62.90
交易统计数据(包含余额回撤):
[maxpeak] [minpeak] [series_start] [series_min] [series_dd] [series_dd_percent] »
[0] 10023.68 9964.28 10000.00 9964.28 59.40 0.59 »
» [series_dd_relative_percent] [series_dd_relative] »
» 0.59 59.40 »
» [deals] [trades] [buy_trades] [wins] [buy_wins] [sell_wins] [profits] [losses] [net] [pf] »
» 600 300 154 141 63 78 394.53 -389.33 5.20 1.01 »
» [average_trade] [recovery] [max_profit] [max_loss] [sharpe]
» 0.02 0.09 9.10 -6.73 0.01
AUDJPY的单独交易报告
权益回撤:
[maxpeak] [minpeak] [series_start] [series_min] [series_dd] [series_dd_percent] »
[0] 10047.14 10041.53 10000.00 9961.62 48.20 0.48 »
» [series_dd_relative_percent] [series_dd_relative]
» 0.48 48.20
交易统计数据(包含余额回撤):
[maxpeak] [minpeak] [series_start] [series_min] [series_dd] [series_dd_percent] »
[0] 10045.21 10042.75 10000.00 9963.62 44.21 0.44 »
» [series_dd_relative_percent] [series_dd_relative] »
» 0.44 44.21 »
» [deals] [trades] [buy_trades] [wins] [buy_wins] [sell_wins] [profits] [losses] [net] [pf] »
» 332 166 91 89 54 35 214.79 -170.20 44.59 1.26 »
» [average_trade] [recovery] [max_profit] [max_loss] [sharpe]
» 0.27 1.01 7.58 -5.17 0.09
在这种情况下,测试器报告将包含汇总数据,所以多亏了我们的类,我们得到了以前无法获取的详细信息。
然而,在日志中查看伪报告不是很方便。此外,至少我希望能看到余额曲线的图形表示,因为它的外观往往比枯燥的统计数据更能说明系统的适用性。
让我们改进智能交易系统,使其能够生成HTML格式的可视化报告:毕竟,测试器的报告也可以导出为HTML格式,保存下来,并随着时间进行比较。此外,将来在优化过程中,这样的报告可以直接在终端的框架中传输,用户甚至可以在整个过程完成之前就开始研究特定测试过程的报告。
这将是示例UnityMartingaleDraft3.mq5
的倒数第二个版本。
UnityMartingaleDraft3.mq5
交易报告的可视化包括一条余额曲线和一个包含统计指标的表格。我们不会生成与测试器报告类似的完整报告,而是只选择最重要的值。我们的目的是实现一个可行的机制,然后可以根据个人需求进行定制。
我们将以TradeReportWriter
类(TradeReportWriter.mqh
)的形式安排算法的基础部分。该类能够存储来自不同交易系统的任意数量的报告:每个报告都在一个单独的DataHolder
对象中,该对象包括余额值数组和时间戳数组(分别为data
和when
)、包含统计数据的stats
结构,以及要显示的线条的标题、颜色和宽度。
cpp
class TradeReportWriter
{
protected:
class DataHolder
{
public:
double data[]; // 余额变化
datetime when[]; // 余额时间戳
string name; // 描述
color clr; // 颜色
int width; // 线条宽度
TradeReport::GenericStats stats; // 交易指标
};
...
我们有一个为DataHolder
类对象分配的自动指针数组curves
。此外,我们还需要对金额和时间的通用限制,以便在图片中匹配所有交易系统的线条。这将由变量lower
、upper
、start
和stop
来提供。
cpp
AutoPtr<DataHolder> curves[];
double lower, upper;
datetime start, stop;
public:
TradeReportWriter(): lower(DBL_MAX), upper(-DBL_MAX), start(0), stop(0) { }
...
addCurve
方法用于添加一条余额曲线。
cpp
virtual bool addCurve(double &data[], datetime &when[], const string name,
const color clr = clrNONE, const int width = 1)
{
if(ArraySize(data) == 0 || ArraySize(when) == 0) return false;
if(ArraySize(data) != ArraySize(when)) return false;
DataHolder *c = new DataHolder();
if(!ArraySwap(data, c.data) || !ArraySwap(when, c.when))
{
delete c;
return false;
}
const double max = c.data[ArrayMaximum(c.data)];
const double min = c.data[ArrayMinimum(c.data)];
lower = fmin(min, lower);
upper = fmax(max, upper);
if(start == 0) start = c.when[0];
else if(c.when[0] != 0) start = fmin(c.when[0], start);
stop = fmax(c.when[ArraySize(c.when) - 1], stop);
c.name = name;
c.clr = clr;
c.width = width;
ZeroMemory(c.stats); // 默认没有统计数据
PUSH(curves, c);
return true;
}
addCurve
方法的第二个版本不仅添加一条余额曲线,还添加GenericStats
结构中的一组财务变量。
cpp
virtual bool addCurve(TradeReport::GenericStats &stats,
double &data[], datetime &when[], const string name,
const color clr = clrNONE, const int width = 1)
{
if(addCurve(data, when, name, clr, width))
{
curves[ArraySize(curves) - 1][].stats = stats;
return true;
}
return false;
}
最重要的用于可视化报告的类方法是抽象方法。
cpp
virtual void render() = 0;
这使得可以实现多种显示报告的方式,例如,既可以记录到不同格式的文件中,也可以直接在图表上绘制。现在我们将只限于生成HTML文件,因为这是最具技术先进性和广泛使用的方法。
新类HTMLReportWriter
有一个构造函数,其参数指定了文件名以及余额曲线图片的大小。我们将以著名的SVG矢量图形格式生成图片本身:在这种情况下它是理想的选择,因为它是XML语言的一个子集,而XML语言本身就是HTML。
cpp
class HTMLReportWriter: public TradeReportWriter
{
int handle;
int width, height;
public:
HTMLReportWriter(const string name, const int w = 600, const int h = 400):
width(w), height(h)
{
handle = FileOpen(name,
FILE_WRITE | FILE_TXT | FILE_ANSI | FILE_REWRITE);
}
~HTMLReportWriter()
{
if(handle != 0) FileClose(handle);
}
void close()
{
if(handle != 0) FileClose(handle);
handle = 0;
}
...
在介绍主要的公共render
方法之前,有必要向读者介绍一种技术,这将在本书的最后一部分第7部分中详细描述。我们要说的是资源:连接到MQL程序的任意数据的文件和数组,用于处理多媒体(声音和图像)、嵌入已编译的指标,或者简单地作为应用程序信息的存储库。我们现在将使用的就是后一种选择。
关键在于,最好不要完全用MQL代码生成HTML页面,而是基于一个模板(页面模板),MQL代码只需将一些变量的值插入其中。这是编程中一种众所周知的技术,它允许将算法与程序的外部表示(或其工作结果)分开。因此,我们可以分别对HTML模板和MQL代码进行实验,在熟悉的环境中处理每个组件。具体来说,MetaEditor仍然不太适合编辑网页和查看网页,就像标准浏览器对MQL5一无所知一样(尽管这可以解决)。
我们将把HTML报告模板存储在作为资源连接到MQL5源代码的文本文件中。连接是使用特殊指令#resource
完成的。例如,在文件TradeReportWriter.mqh
中有以下一行。
cpp
#resource "TradeReportPage.htm" as string ReportPageTemplate
这意味着在源代码旁边应该有文件TradeReportPage.htm
,它将在MQL代码中作为字符串ReportPageTemplate
可用。通过文件扩展名,你可以知道该文件是一个网页。以下是这个文件的内容(有缩写)(我们的任务不是教读者网页开发,尽管显然这方面的知识对交易者也可能有用)。添加了缩进以直观地表示HTML标签的嵌套层次结构;文件中没有缩进。
html
<!DOCTYPE html>
<html>
<head>
<title>Trade Report</title>
<style>
*{font: 9pt "Segoe UI";}
.center{width:fit-content;margin:0 auto;}
...
</style>
</head>
<body>
<div class="center">
<h1>Trade Report</h1>
~
</div>
</body>
<script>
...
</script>
</html>
模板的基本内容由开发者选择。有大量现成的 HTML 模板系统,但它们提供了很多冗余功能,因此对于我们的示例来说过于复杂。我们将开发自己的概念。
首先,需要注意的是,大多数网页都有一个初始部分(头部)、一个结尾部分(尾部),有用的信息位于它们之间。从这个意义上说,上述报告草案也不例外。它使用波浪字符 ~
来表示有用的内容。相反,MQL 代码将不得不插入一个余额图像和一个包含指标的表格。但 ~
的存在不是必需的,因为页面可以是一个整体,即非常有用的中间部分:毕竟,如果需要,MQL 代码可以将一个模板的处理结果插入到另一个模板中。
为了完成关于 HTML 模板的题外话,让我们再注意一件事。从理论上讲,一个网页由执行本质上不同功能的标签组成。标准 HTML 标签告诉浏览器要显示什么。除了它们之外,还有层叠样式表(CSS),它描述了如何显示内容。最后,页面可以有一个以 JavaScript 脚本形式的动态组件,它以交互方式控制前两者。
通常,这三个组件是独立模板化的,也就是说,例如,严格来说,一个 HTML 模板应该只包含 HTML,而不包含 CSS 或 JavaScript。这允许“解绑”网页的内容、外观和行为,这有利于开发(在 MQL5 中也建议遵循相同的方法!)。
然而,在我们的示例中,我们在模板中包含了所有组件。特别是,在上述模板中,我们看到了带有 CSS 样式的 <style>
标签和带有一些省略的 JavaScript 函数的 <script>
标签。这样做是为了简化示例,重点在于 MQL5 的特性,而不是网页开发。
有了作为资源连接在 ReportPageTemplate
变量中的网页模板,我们就可以编写 render
方法。
cpp
virtual void render() override
{
string headerAndFooter[2];
StringSplit(ReportPageTemplate, '~', headerAndFooter);
FileWriteString(handle, headerAndFooter[0]);
renderContent();
FileWriteString(handle, headerAndFooter[1]);
}
...
它实际上通过 ~
字符将页面分成上下两部分,按原样显示它们,并在它们之间调用辅助方法 renderContent
。
我们已经描述过报告将由一个带有余额曲线的总图和包含交易系统指标的表格组成,所以 renderContent
的实现是很自然的。
cpp
private:
void renderContent()
{
renderSVG();
renderTables();
}
在 renderSVG
内部生成图像是基于另一个模板文件 TradeReportSVG.htm
,它绑定到一个字符串变量 SVGBoxTemplate
:
cpp
#resource "TradeReportSVG.htm" as string SVGBoxTemplate
这个模板的内容是我们在这里列出的最后一个。有兴趣的人可以自己查看其余模板的源代码。
html
<span id="params" style="display:block;width:%WIDTH%px;text-align:center;"></span>
<a id="main" style="display:block;text-align:center;">
<svg width="%WIDTH%" height="%HEIGHT%" xmlns="http://www.w3.org/2000/svg">
<style>.legend {font: bold 11px Consolas;}</style>
<rect x="0" y="0" width="%WIDTH%" height="%HEIGHT%"
style="fill:none; stroke-width:1; stroke: black;"/>
~
</svg>
</a>
在 renderSVG
方法的代码中,我们会看到熟悉的将内容分成“之前”和“之后”两个块的技巧,但这里也有一些新东西。
cpp
void renderSVG()
{
string headerAndFooter[2];
if(StringSplit(SVGBoxTemplate, '~', headerAndFooter) != 2) return;
StringReplace(headerAndFooter[0], "%WIDTH%", (string)width);
StringReplace(headerAndFooter[0], "%HEIGHT%", (string)height);
FileWriteString(handle, headerAndFooter[0]);
for(int i = 0; i < ArraySize(curves); ++i)
{
renderCurve(i, curves[i][].data, curves[i][].when,
curves[i][].name, curves[i][].clr, curves[i][].width);
}
FileWriteString(handle, headerAndFooter[1]);
}
在页面顶部的字符串 headerAndFooter[0]
中,我们寻找特殊形式的子字符串 %WIDTH%
和 %HEIGHT%
,并将它们替换为所需的图像宽度和高度。这就是我们模板中值替换的工作原理。例如,在这个模板中,这些子字符串实际上出现在 rect
标签中:
html
<rect x="0" y="0" width="%WIDTH%" height="%HEIGHT%" style="fill:none; stroke-width:1; stroke: black;"/>
因此,如果报告的尺寸设置为 600×400,这一行将转换为以下内容:
html
<rect x="0" y="0" width="600" height="400" style="fill:none; stroke-width:1; stroke: black;"/>
这将在浏览器中显示一个指定尺寸的 1 像素厚的黑色边框。
绘制特定余额线条的标签生成由 renderCurve
方法处理,我们将所有必要的数组和其他设置(名称、颜色和粗细)传递给它。我们将把这个方法和其他高度专业化的方法(renderTables
,renderTable
)留给读者自行研究。
让我们回到 UnityMartingaleDraft3.mq5
智能交易系统的主模块。设置余额图图像的大小并连接 TradeReportWriter.mqh
。
cpp
#define MINIWIDTH 400
#define MINIHEIGHT 200
#include <MQL5Book/TradeReportWriter.mqh>
为了将策略与报告生成器“连接”起来,需要修改 TradingStrategy
接口中的 statement
方法:传递一个指向 TradeReportWriter
对象的指针,调用代码可以创建并配置该对象。
cpp
interface TradingStrategy
{
virtual bool trade(void);
virtual bool statement(TradeReportWriter *writer = NULL);
};
现在让我们在我们的 UnityMartingale
策略类中这个方法的具体实现中添加一些代码行。
cpp
class UnityMartingale: public TradingStrategy
{
...
TradeReport report;
...
virtual bool statement(TradeReportWriter *writer = NULL) override
{
if(MQLInfoInteger(MQL_TESTER))
{
...
// 已经完成的操作
DealFilter filter;
filter.let(DEAL_SYMBOL, settings.symbol)
.let(DEAL_MAGIC, settings.magic, IS::EQUAL_OR_ZERO);
HistorySelect(0, LONG_MAX);
TradeReport::GenericStats stats =
report.calcStatistics(filter, deposit, epoch);
...
// 添加这部分
if(CheckPointer(writer) != POINTER_INVALID)
{
double data[]; // 余额值
datetime time[]; // 余额点时间,用于同步曲线
report.getCurve(data, time); // 填充数组并传输以写入文件
return writer.addCurve(stats, data, time, settings.symbol);
}
return true;
}
return false;
}
这一切都归结为从报告对象(TradeReport
类)获取余额数组和带有指标的结构,并将其传递给 TradeReportWriter
对象,调用 addCurve
方法。
当然,交易策略池确保将同一个 TradeReportWriter
对象传递给所有策略,以生成组合报告。
cpp
class TradingStrategyPool: public TradingStrategy
{
...
virtual bool statement(TradeReportWriter *writer = NULL) override
{
bool result = false;
for(int i = 0; i < ArraySize(pool); i++)
{
result = pool[i][].statement(writer) || result;
}
return result;
}
最后,OnTester
处理程序经历了最大的修改。以下几行代码足以生成交易策略的 HTML 报告。
cpp
double OnTester()
{
...
const static string tempfile = "temp.html";
HTMLReportWriter writer(tempfile, MINIWIDTH, MINIHEIGHT);
if(pool[] != NULL)
{
pool[].statement(&writer); // 要求策略报告它们的结果
}
writer.render(); // 将接收到的数据写入文件
writer.close();
}
然而,为了清晰和用户方便,最好在报告中添加一个总余额曲线,以及一个包含总指标的表格。只有在智能交易系统设置中指定了多个符号时,输出它们才有意义,因为否则,一个策略的报告与文件中的总报告是一致的。
这需要更多一点的代码。
cpp
double OnTester()
{
...
// 之前就有的
DealFilter filter;
// 设置过滤器并根据它填充交易数组 tickets
...
const int n = ArraySize(tickets);
// 添加这部分
const bool singleSymbol = WorkSymbols == "";
double curve[]; // 总余额曲线
datetime stamps[]; // 总余额点的日期和时间
if(!singleSymbol) // 只有当有多个符号/策略时才显示总余额
{
ArrayResize(curve, n + 1);
ArrayResize(stamps, n + 1);
curve[0] = TesterStatistics(STAT_INITIAL_DEPOSIT);
// MQL5 不允许知道测试开始时间,
// 这可以从第一笔交易中得知,
// 但它不在特定系统的过滤条件范围内,
// 所以我们就同意在计算中跳过时间 0
stamps[0] = 0;
}
for(int i = 0; i < n; ++i) // 交易循环
{
double result = 0;
for(int j = 0; j < STAT_PROPS - 1; ++j)
{
result += expenses[i][j];
}
if(!singleSymbol)
{
curve[i + 1] = result + curve[i];
stamps[i + 1] = (datetime)HistoryDealGetInteger(tickets[i], DEAL_TIME);
}
...
}
if(!singleSymbol) // 将测试器的统计数据和总曲线发送到报告中
{
TradeReport::GenericStats stats;
stats.fillByTester();
writer.addCurve(stats, curve, stamps, "Overall", clrBlack, 3);
}
...
}
让我们看看我们得到了什么。如果我们使用设置 UnityMartingale-combo.set
运行智能交易系统,我们将在其中一个代理的 MQL5/Files
文件夹中得到 temp.html
文件。这是它在浏览器中的样子。
多个交易策略/符号的智能交易系统的 HTML 报告
多个交易策略/符号的智能交易系统的 HTML 报告
现在我们知道了如何在一次测试过程中生成报告,我们可以在优化期间将它们发送到终端,随时选择最好的报告,并在整个过程结束之前将它们呈现给用户。所有报告将被放在终端的 MQL5/Files
内的一个单独文件夹中。该文件夹将获得一个名称,其中包含来自测试器设置的符号和时间框架,以及智能交易系统的名称。
UnityMartingale.mq5
如我们所知,要将文件发送到终端,只需调用函数 FrameAdd
。我们已经在之前版本的框架内生成了文件。
cpp
double OnTester()
{
...
if(MQLInfoInteger(MQL_OPTIMIZATION))
{
FrameAdd(tempfile, 0, r2 * 100, tempfile);
}
}
在接收智能交易系统实例中,我们将进行必要的准备。让我们描述 Pass
结构,它包含每个优化过程的主要参数。
cpp
struct Pass
{
ulong id; // 过程编号
double value; // 优化标准值
string parameters; // 优化参数,格式为 'name=value' 的列表
string preset; // 用于生成设置文件的文本(包含所有参数)
};
在 parameters
字符串中,“name=value” 对用 &
符号连接。这在将来对于报告网页的交互会很有用(&
符号是在网址中组合参数的标准)。我们没有描述设置文件的格式,但以下形成 preset
字符串的源代码允许在实践中研究这个问题。
随着框架的到来,我们将根据优化标准将改进写入 TopPasses
数组。当前最好的过程将始终是数组中的最后一个过程,并且也可在 BestPass
变量中获取。
cpp
Pass TopPasses[]; // 不断改进的过程堆栈(最后一个是最好的)
Pass BestPass; // 当前最好的过程
string ReportPath; // 此优化的所有 html 文件的专用文件夹
在 OnTesterInit
处理程序中,让我们创建一个文件夹名称。
cpp
void OnTesterInit()
{
BestPass.value = -DBL_MAX;
ReportPath = _Symbol + "-" + PeriodToString(_Period) + "-"
+ MQLInfoString(MQL_PROGRAM_NAME) + "/";
}
在 OnTesterPass
处理程序中,我们将依次仅选择那些指标有所改进的框架,为它们找到优化和其他参数的值,并将所有这些信息添加到 Pass
结构数组中。
cpp
void OnTesterPass()
{
ulong pass;
string name;
long id;
double value;
uchar data[];
// 与当前框架对应的过程的输入参数
string params[];
uint count;
while(FrameNext(pass, name, id, value, data))
{
// 收集统计数据有所改进的过程
if(value > BestPass.value && FrameInputs(pass, params, count))
{
BestPass.preset = "";
BestPass.parameters = "";
// 获取用于生成设置文件的优化和其他参数
for(uint i = 0; i < count; i++)
{
string name2value[];
int n = StringSplit(params[i], '=', name2value);
if(n == 2)
{
long pvalue, pstart, pstep, pstop;
bool enabled = false;
if(ParameterGetRange(name2value[0], enabled, pvalue, pstart, pstep, pstop))
{
if(enabled)
{
if(StringLen(BestPass.parameters)) BestPass.parameters += "&";
BestPass.parameters += params[i];
}
BestPass.preset += params[i] + "||" + (string)pstart + "||"
+ (string)pstep + "||" + (string)pstop + "||"
+ (enabled? "Y" : "N") + "<br>\n";
}
else
{
BestPass.preset += params[i] + "<br>\n";
}
}
}
BestPass.value = value;
BestPass.id = pass;
PUSH(TopPasses, BestPass);
// 将带有报告的框架写入 HTML 文件
const string text = CharArrayToString(data);
int handle = FileOpen(StringFormat(ReportPath + "%06.3f-%lld.htm", value, pass),
FILE_WRITE | FILE_TXT | FILE_ANSI);
FileWriteString(handle, text);
FileClose(handle);
}
}
}
生成的带有改进的报告将保存在文件名中包含优化标准值和过程编号的文件中。
现在最有趣的部分来了。在 OnTesterDeinit
处理程序中,我们可以形成一个通用的 HTML 文件(overall.htm
),它允许一次性查看所有报告(或者比如说,前 100 个)。它使用与我们之前介绍的相同的模板方案。
cpp
#resource "OptReportPage.htm" as string OptReportPageTemplate
#resource "OptReportElement.htm" as string OptReportElementTemplate
void OnTesterDeinit()
{
int handle = FileOpen(ReportPath + "overall.htm",
FILE_WRITE | FILE_TXT | FILE_ANSI, 0, CP_UTF8);
string headerAndFooter[2];
StringSplit(OptReportPageTemplate, '~', headerAndFooter);
StringReplace(headerAndFooter[0], "%MINIWIDTH%", (string)MINIWIDTH);
StringReplace(headerAndFooter[0], "%MINIHEIGHT%", (string)MINIHEIGHT);
FileWriteString(handle, headerAndFooter[0]);
// 从 TopPasses 中读取不超过 100 条最佳记录
for(int i = ArraySize(TopPasses) - 1, k = 0; i >= 0 && k < 100; --i, ++k)
{
string p = TopPasses[i].parameters;
StringReplace(p, "&", " ");
const string filename = StringFormat("%06.3f-%lld.htm",
TopPasses[i].value, TopPasses[i].id);
string element = OptReportElementTemplate;
StringReplace(element, "%FILENAME%", filename);
StringReplace(element, "%PARAMETERS%", TopPasses[i].parameters);
StringReplace(element, "%PARAMETERS_SPACED%", p);
StringReplace(element, "%PASS%", IntegerToString(TopPasses[i].id));
StringReplace(element, "%PRESET%", TopPasses[i].preset);
StringReplace(element, "%MINIWIDTH%", (string)MINIWIDTH);
StringReplace(element, "%MINIHEIGHT%", (string)MINIHEIGHT);
FileWriteString(handle, element);
}
FileWriteString(handle, headerAndFooter[1]);
FileClose(handle);
}
以下图片展示了在多货币模式下,通过 UnityPricePeriod
参数对 UnityMartingale.mq5
进行优化后,概览网页的样子。
最佳优化过程交易报告的概览网页
最佳优化过程交易报告的概览网页
对于每份报告,我们仅展示其包含余额图表的上半部分。仅通过查看这部分内容,就能很方便地进行评估。
每个图表上方会显示优化参数列表(格式为 "name=value&name=value..."
)。点击某一行,会展开一个包含该过程所有设置的文本块,其内容可用于生成设置文件。若点击该文本块内部,其内容会被复制到剪贴板。你可以将其保存到文本编辑器中,从而得到一个现成的设置文件。
点击图表会跳转到包含计分表(上文已给出)的具体报告页面。
在本节结尾,我们再探讨一个问题。之前我们承诺展示 TesterHideIndicators
函数的效果。当前,UnityMartingale.mq5
智能交易系统使用了 UnityPercentEvent.mq5
指标。每次测试结束后,该指标会显示在打开的图表上。假设我们希望向用户隐藏智能交易系统的工作机制以及信号来源,那么可以在 OnInit
处理程序中,在创建 UnityController
对象(通过 iCustom
获取描述符)之前调用 TesterHideIndicators
函数(参数设为 true
)。
cpp
int OnInit()
{
...
TesterHideIndicators(true);
...
controller = new UnityController(UnitySymbols, barwise,
UnityBarLimit, UnityPriceType, UnityPriceMethod, UnityPricePeriod);
return INIT_SUCCEEDED;
}
此版本的智能交易系统将不再在图表上显示该指标。然而,隐藏效果并不理想。若查看测试器日志,在大量有用信息中会看到有关加载程序的记录:首先是加载智能交易系统本身的消息,稍后是加载指标的消息。
...
expert file added: Experts\MQL5Book\p6\UnityMartingale.ex5.
...
program file added: \Indicators\MQL5Book\p6\UnityPercentEvent.ex5.
...
因此,细心的用户仍能发现指标的名称。我们可借助之前在网页模板上下文中提及的资源机制来消除这种可能性。实际上,已编译的指标也能作为资源嵌入到 MQL 程序(智能交易系统或其他指标)中。而且,此类资源程序不会在测试器日志中显示。我们将在本书第 7 部分详细研究资源,现在先展示最终版本的智能交易系统中与之相关的代码行。
首先,使用 #resource
指标指令描述资源。实际上,它仅包含已编译指标文件的路径(显然,该文件必须事先编译好),并且在此必须使用双反斜杠作为分隔符,因为资源路径中不支持使用单正斜杠。
cpp
#resource "\\Indicators\\MQL5Book\\p6\\UnityPercentEvent.ex5"
然后,在调用 iCustom
的代码行中,替换之前的操作:
cpp
UnityController(const string symbolList, const int offset, const int limit,
const ENUM_APPLIED_PRICE type, const ENUM_MA_METHOD method, const int period):
bar(offset), tickwise(!offset)
{
handle = iCustom(_Symbol, _Period,
"MQL5Book/p6/UnityPercentEvent", // <---
symbolList, limit, type, method, period);
...
改为使用指向资源的引用(注意,这里需要使用前导双冒号 ::
,以区分文件系统中的普通路径和资源内的路径)。
cpp
UnityController(const string symbolList, const int offset, const int limit,
const ENUM_APPLIED_PRICE type, const ENUM_MA_METHOD method, const int period):
bar(offset), tickwise(!offset)
{
handle = iCustom(_Symbol, _Period,
"::Indicators\\MQL5Book\\p6\\UnityPercentEvent.ex5", // <---
symbolList, limit, type, method, period);
...
现在,已编译的智能交易系统版本可以单独提供给用户,无需附带单独的指标,因为指标已隐藏在智能交易系统内部。这不会对其性能产生任何影响,并且结合 TesterHideIndicators
的功能,内部机制得以隐藏。需要记住的是,如果之后更新指标,也需要重新编译智能交易系统。
数学计算
MetaTrader 5 终端中的测试器不仅可用于测试交易策略,还能进行数学计算。为此,需在测试器设置的“Simulation”下拉列表中选择合适的模式。这个列表也是我们选择报价生成方法的地方,但在此情况下,测试器不会生成报价或行情,甚至不会连接交易环境(交易账户和交易品种)。
在参数的完全枚举法和遗传算法之间进行选择,取决于搜索空间的大小。对于优化标准,选择“Custom max”。测试器设置中的其他输入字段(如日期范围或延迟)并不重要,因此会自动禁用。
在“数学计算”模式下,每次测试代理运行时仅调用三个函数:OnInit
、OnTester
和 OnDeinit
。
在 MetaTrader 5 测试器中解决的典型数学问题是寻找多变量函数的极值。要解决这个问题,需将函数参数以输入变量的形式声明,并将计算其值的代码块放在 OnTester
中。
对于特定输入变量集的函数值,作为 OnTester
的输出值返回。在计算过程中,除了数学函数外,不要使用任何内置函数。
必须记住,在进行优化时,始终是寻找 OnTester
函数的最大值。因此,若需要寻找最小值,应返回其倒数或乘以 -1 的值。
为了理解其工作原理,我们以一个相对简单的双变量函数为例,该函数有一个最大值。我们将在 MathCalc.mq5
智能交易系统算法中对其进行描述。
通常假定我们不知道该函数的解析表达式,否则就可以直接计算其极值了。但现在我们使用一个已知的公式,以确保答案的正确性。
cpp
input double X1;
input double X2;
double OnTester()
{
const double r = 1 + sqrt(X1 * X1 + X2 * X2);
return sin(r) / r;
}
该智能交易系统配有 MathCalc.set
文件,其中包含优化参数:参数 X1
和 X2
在 [-15, +15] 范围内以 0.5 为步长进行迭代。
让我们运行优化并在优化表中查看解决方案。最佳运行给出了正确的结果:
plaintext
X1=0.0
X2=0.0
OnTester 结果 0.8414709848078965
在优化图表中,你可以开启 3D 模式,直观地查看曲面形状。
数学计算模式下函数优化(最大化)的结果
同时,在数学计算模式下使用测试器并不局限于纯粹的科学研究。特别是在此基础上,可以使用其他知名的优化方法(如“粒子群算法”或“模拟退火算法”)来对交易系统进行优化。当然,为此需要将报价或行情历史数据上传到文件中,并将其连接到被测试的智能交易系统,同时模拟交易执行、持仓和资金管理。由于可以自由定制优化过程(与内置的遗传算法“黑箱”不同)并控制资源(主要是内存),这项常规工作可能会颇具吸引力。
调试与性能分析
MetaTrader 5 测试器不仅对测试交易策略的盈利能力很有用,对于调试 MQL 程序也同样重要。错误检测首先与重现问题情境的能力相关。如果我们只能在线运行 MQL 程序,那么调试和分析源代码的执行情况将需要付出极大的努力,甚至是不切实际的。然而,测试器允许我们在任意历史数据区间上 “运行” 程序,还能更改账户设置和交易品种。
回想一下,在 MetaEditor 的 “调试” 菜单中有两个命令:
- 基于真实数据启动/继续(F5)
- 基于历史数据启动/继续(Ctrl-F5)
在这两种情况下,程序都会以一种特殊的方式快速重新编译,在 ex5 文件中添加额外的调试信息,然后直接在终端中启动(第一种选项),或者在测试器中启动(第二种选项)。
在测试器中进行调试时,你既可以使用快速(后台)模式,也可以使用可视化模式。此设置在 “设置” 对话框的 “调试/性能分析” 选项卡中提供:启用或禁用 “在历史数据调试中使用可视化模式” 标志。正在调试的程序的环境和设置可以直接从测试器中获取(按照上次为此程序设置的那样),或者在同一对话框中 “使用指定设置” 标志下的输入字段中进行设置(为了使这些设置生效,必须启用该标志)。
你可以在认为程序开始出现问题的部分,在操作符上预先设置断点(F9)。当测试器到达源代码中的指定位置时,它将暂停程序的执行。
请注意,在测试器中,启动时加载的历史 K 线数量取决于不同的因素(包括时间框架、一年中的日期等),并且可能会有很大差异。如有必要,请将测试的开始时间向前调整。
除了那些会导致程序停止或明显出现故障的明显错误之外,还有一类细微的错误会对程序性能产生负面影响。通常,这些错误并不那么明显,但随着处理数据量的增加,它们会变成问题,例如在历史记录非常长的交易账户上,或者在有大量标记对象的图表上。
为了找到性能方面的 “瓶颈”,调试器提供了一种源代码性能分析机制。它也可以在线进行,或者在测试器中进行,而在测试器中进行性能分析尤其有价值,因为它可以显著压缩时间。相应的命令也可以在调试菜单中找到:
- 基于真实数据启动性能分析
- 基于历史数据启动性能分析
为了进行性能分析,程序同样会使用特殊设置进行预编译,所以在调试或性能分析完成后,不要忘记以正常模式再次编译程序(特别是如果你计划将其发送给客户或上传到 MQL5 市场)。
在 MetaEditor 中进行性能分析后,你将获得代码执行的时间统计信息,这些信息按行和函数(方法)进行了细分。这样一来,究竟是什么导致程序运行缓慢就会一目了然。开发的下一步通常是对源代码进行重构,即使用改进的算法、数据结构或模块(组件)构建组织的其他原则来重写代码。遗憾的是,在编程过程中,相当一部分时间都花在了重写现有代码、查找和修复错误上。
如果有必要,程序本身可以查明其运行模式,并使其行为适应环境(例如,当在测试器中运行时,它不会尝试从互联网下载数据,因为此功能已被禁用,而是会从某个文件中读取数据)。
在编译阶段,由于预处理器宏 _DEBUG
和 _RELEASE
的存在,程序的调试版本和发布版本的形成方式可能会有所不同。
在程序执行阶段,可以使用 MQLInfoInteger
函数的选项来区分其运行模式。
以下表格总结了所有会影响运行时特性的可用组合:
运行时\标志 | MQL_DEBUG | MQL_PROFILER |
---|---|---|
正常(发布版) | 在线:+ 测试器( MQL_TESTER ):+测试器( MQL_TESTER+MQL_VISUAL_MODE ):+ | 在线:+ 测试器( MQL_TESTER ):+测试器( MQL_TESTER+MQL_VISUAL_MODE ):- |
在测试器中进行性能分析时只能在不使用可视化模式的情况下进行,所以你应该在线测量与图表和对象相关的操作。
在优化过程中不允许进行调试,包括特殊的处理程序 OnTesterInit
、OnTesterDeinit
和 OnTesterPass
。如果你需要检查它们的性能,可以考虑在其他条件下调用它们的代码。
测试器中函数的限制
在使用测试器时,你应当考虑到对内置函数所施加的一些限制。部分MQL5 API函数在策略测试器中永远不会被执行,而有些函数仅在单次运行中起作用,在优化过程中则不生效。
因此,为了在优化智能交易系统时提高性能,Comment
、Print
和 PrintFormat
函数不会被执行。
但在 OnInit
处理程序内部使用这些函数是个例外,这样做是为了更轻松地查找初始化错误的可能原因。
那些提供与“外部世界”进行交互的函数在策略测试器中不会被执行。这些函数包括 MessageBox
、PlaySound
、SendFTP
、SendMail
、SendNotification
、WebRequest
以及用于处理套接字的函数。
此外,许多用于处理图表和对象的函数也不起作用。具体来说,你无法通过调用 ChartSetSymbolPeriod
函数来更改当前图表的交易品种或周期,无法使用 ChartIndicatorGet
函数列出所有指标(包括从属指标),也无法使用 ChartSaveTemplate
函数处理模板等等。
在测试器中,即使处于可视化模式,也不会为 OnChartEvent
处理程序生成交互式图表、对象、键盘和鼠标事件。