使用 SQLite 搜索交易策略的示例

我们尝试使用 SQLite 来解决实际问题。我们将把带有报价历史的结构体导入 MqlRates 数据库,并对其进行分析,以识别模式并搜索潜在的交易策略。当然,任何选定的逻辑也可以在 MQL5 中实现,但 SQL 允许你以不同的方式来实现,在许多情况下更有效,并且可以使用许多有趣的内置 SQL 函数。本书的主题旨在学习 MQL5,不会深入研究这项技术,但我们提及它是值得算法交易者关注的。

用于将报价历史转换为数据库格式的脚本名为 DBquotesImport.mq5。在输入参数中,你可以设置数据库名称的前缀和事务的大小(一个事务中的记录数)。

input string Database = "MQL5Book/DB/Quotes";
input int TransactionSize = 1000;

为了使用我们的 ORM 层将 MqlRates 结构体添加到数据库,该脚本定义了一个辅助的 MqlRatesDB 结构体,它提供了将结构体字段绑定到数据库列的规则。由于我们的脚本只向数据库写入数据而不从数据库中读取数据,因此它不需要使用 DatabaseReadBind 函数进行绑定,这将对结构体的“简单性”施加一定的限制。如果没有这个限制,则可以从 MqlRates 派生 MqlRatesDB 结构体(并且不重复字段的描述)。

struct MqlRatesDBpublic MqlRates
{
   /* for reference:
   
      datetime time;
      double   open;
      double   high;
      double   low;
      double   close;
      long     tick_volume;
      int      spread;
      long     real_volume;
   */
   
   bool bindAll(DBQuery &qconst
   {
      return q.bind(0time)
         && q.bind(1open)
         && q.bind(2high)
         && q.bind(3low)
         && q.bind(4close)
         && q.bind(5tick_volume)
         && q.bind(6spread)
         && q.bind(7real_volume);
   }
   
   long rowid(const long setter = 0)
   {
 // rowid is set by us according to the bar time
      return time;
   }
};
   
DB_FIELD_C1(MqlRatesDBdatetimetimeDB_CONSTRAINT::PRIMARY_KEY);
DB_FIELD(MqlRatesDBdoubleopen);
DB_FIELD(MqlRatesDBdoublehigh);
DB_FIELD(MqlRatesDBdoublelow);
DB_FIELD(MqlRatesDBdoubleclose);
DB_FIELD(MqlRatesDBlongtick_volume);
DB_FIELD(MqlRatesDBintspread);
DB_FIELD(MqlRatesDBlongreal_volume);

数据库名称由前缀 Database、当前运行脚本的图表的名称和时间周期构成。数据库中创建了单个名为 "MqlRatesDB" 的表,其字段配置由 DB_FIELD 宏指定。请注意,主键不会由数据库根据 time 字段(柱线开盘时间)生成,而是直接从柱线中获取。

void OnStart()
{
   Print("");
   DBSQLite db(Database + _Symbol + PeriodToString());
   if(!PRTF(db.isOpen())) return;
   
   PRTF(db.deleteTable(typename(MqlRatesDB)));
   
   if(!PRTF(db.createTable<MqlRatesDB>(true))) return;
   ...

接下来,我们使用 TransactionSize 个柱线的数据包,从历史记录中请求柱线并将它们添加到表中。这是由辅助函数 ReadChunk 完成的,只要有数据(函数返回 true)或用户不手动停止脚本,该函数就会一直被循环调用。函数代码如下所示。

   int offset = 0;
   while(ReadChunk(dboffsetTransactionSize) && !IsStopped())
   {
      offset += TransactionSize;
   }

过程完成后,我们向数据库查询表中生成的记录数并将其输出到日志。

   DBRow *rows[];
   if(db.prepare(StringFormat("SELECT COUNT(*) FROM %s",
      typename(MqlRatesDB))).readAll(rows))
   {
      Print("Records added: "rows[0][0].integer_value);
   }
}

ReadChunk 函数如下所示。

bool ReadChunk(DBSQLite &dbconst int offsetconst int size)
{
   MqlRates rates[];
   MqlRatesDB ratesDB[];
   const int n = CopyRates(_SymbolPERIOD_CURRENToffsetsizerates);
   if(n > 0)
   {
      DBTransaction tr(dbtrue);
      Print(rates[0].time);
      ArrayResize(ratesDBn);
      for(int i = 0i < n; ++i)
      {
         ratesDB[i] = rates[i];
      }
      
      return db.insert(ratesDB);
   }
   else
   {
      Print("CopyRates failed: "_LastError" "E2S(_LastError));
   }
   return false;
}

它通过内置的 CopyRates 函数调用来填充 rates 柱线数组。然后将柱线传输到 ratesDB 数组,以便仅使用一条语句 db.insert(ratesDB) 就可以将信息写入数据库(我们已经在 MqlRatesDB 中规范了如何正确执行此操作)。

块内部存在 DBTransaction 对象(启用了自动“提交”选项)意味着对数组的所有操作都被事务“覆盖”。为了指示进度,在处理每个柱线块期间,第一根柱线的标签会显示在日志中。

CopyRates 函数返回数据并且它们成功插入数据库时,OnStart 中的循环继续,并将复制的柱线编号向历史深处移动。当达到可用历史的末尾或终端设置中设置的柱线限制时,CopyRates 将返回错误 4401 (HISTORY_NOT_FOUND),脚本将退出。

我们在 EURUSD, H1 图表上运行该脚本。日志应该显示类似这样的内容。

   db.isOpen()=true / ok
   db.deleteTable(typename(MqlRatesDB))=true / ok
   db.createTable<MqlRatesDB>(true)=true / ok
   2022.06.29 20:00:00
   2022.05.03 04:00:00
   2022.03.04 10:00:00
   ...
   CopyRates failed: 4401 HISTORY_NOT_FOUND
   Records added: 100000

我们现在可以基于 QuotesEURUSDH1.sqlite 进行实验,以测试各种交易假设。可以在 MetaEditor 中打开它以确保数据正确传输。

我们检查一个基于历史规律的最简单策略之一。我们将按日内时间和星期几细分,查找连续两根同向柱线的统计数据。如果对于某个时间和星期几的组合存在明显的优势,将来可以将其视为沿第一根柱线方向入市的信号。

首先,让我们设计一个 SQL 查询,该查询请求某个时期的报价并计算每个柱线上的价格变动,即相邻开盘价之间的差额。

由于柱线的时间存储为秒数(按照 MQL5 中 datetime 的标准,并且同时也是 SQL 的“Unix 纪元时间”),因此最好将其显示转换为字符串以便于阅读,所以我们从基于 DATETIME 函数的 datetime 字段开始 SELECT 查询:

SELECT
   DATETIME(time, 'unixepoch') as datetime, open, ...

该字段不参与分析,此处仅供用户参考。之后,显示价格以供参考,以便我们可以通过调试打印来检查价格增量的计算。

由于我们将从整个文件中选择某个时期,因此条件将需要“纯粹形式”的 time 字段,并且也应将其添加到请求中。此外,根据计划的报价分析,我们需要从柱线标签中分离出其日内时间以及星期几(它们的编号与 MQL5 中采用的编号一致,0 是星期日)。我们分别调用查询的最后两列 intradayday,并使用 TIME 和 STRFTIME 函数来获取它们。

SELECT
   DATETIME(time, 'unixepoch') as datetime, open,
   time,
   TIME(time, 'unixepoch') AS intraday,
   STRFTIME('%w', time, 'unixepoch') AS day, ...

要在 SQL 中计算价格增量,可以使用 LAG 函数。它返回指定列的值,并带有指定行数的偏移量。例如,LAG(X, 1) 表示获取前一条记录中 X 的值,第二个参数 1 表示偏移量默认为 1,即可以省略以获得等效的条目 LAG(X)。要获取下一条记录的值,请调用 LAG(X,-1)。在任何情况下,使用 LAG 时,都需要一个额外的语法结构来指定记录的排序顺序,最简单的形式是 OVER(ORDER BY column)

因此,要获取两个相邻柱线开盘价之间的价格增量,我们可以编写:

   ...
   (LAG(open,-1) OVER (ORDER BY time) - open) AS delta, ...

这一列具有预测性,因为它展望了未来。

我们可以通过将增量与它们相乘来揭示两根柱线是否同向形成:正值表示持续上涨或下跌:

...

(LAG(open,-1) OVER (ORDER BY time) - open) * (open - LAG(open) OVER (ORDER BY time))

AS product, ...

这个指标被选为计算中使用起来最简单的:对于真实的交易系统,你可以选择更复杂的标准。

为了评估系统在回测中产生的利润,你需要将前一根柱线的方向(它作为未来运动的指标)乘以下一根柱线上的价格增量。方向在 direction 列中计算(使用 SIGN 函数),仅供参考。estimate 列中的利润估算是前一个运动 direction 与下一个柱线 (delta) 的乘积:如果方向保持不变,我们将获得正结果(以点为单位)。

...

SIGN(open - LAG(open) OVER (ORDER BY time)) AS direction,

(LAG(open,-1) OVER (ORDER BY time) - open) * SIGN(open - LAG(open) OVER (ORDER BY time))

AS estimate ...

在 SQL 命令的表达式中,你不能使用在同一命令中定义的 AS 别名。因此,我们不能将 estimate 定义为 delta * direction,而必须显式重复乘积的计算。然而,我们回想一下,deltadirection 列并非程序化分析所必需,此处添加它们仅为向用户可视化表格。

在 SQL 命令的末尾,我们指定从中进行选择的表,以及回测日期范围的筛选条件:两个参数 "from" 和 "to"。

...
FROM MqlRatesDB
WHERE (time >= ?1 AND time < ?2)

(可选)我们可以添加一个约束 LIMIT?3(并输入一些较小的值,例如 10),这样在首次对查询结果进行可视化验证时,就不必费力查看数万条记录。

你可以使用 DatabasePrint 函数来检查 SQL 命令的运行情况,然而,遗憾的是,该函数不允许处理带有参数的预置查询。因此,我们将不得不使用 StringFormat 将 SQL 参数占位符 '?n' 替换为查询字符串格式化,并在那里替换参数值。或者,也可以完全避免使用 DatabasePrint,并独立地逐行将结果输出到日志(通过 DBRow 数组)。

因此,请求的最终片段将变为:

   ...
   WHERE (time >= %ld AND time < %ld)
   ORDER BY time LIMIT %d;

应该注意的是,此查询中的 datetime 值将以“机器”格式从 MQL5 传入,即自 1970 年以来的秒数。如果想在 MetaEditor 中调试相同的 SQL 查询,那么使用日期字面量(字符串)来编写日期范围条件会更方便,如下所示:

   WHERE (time >= STRFTIME('%s', '2015-01-01') AND time < STRFTIME('%s', '2021-01-01'))

再次强调,这里我们需要使用 STRFTIME 函数(SQL 中的 %s 修饰符将指定的日期字符串转换为“Unix 纪元时间”标签;%s 类似于 MQL5 格式字符串纯属巧合)。

将设计好的 SQL 查询保存到一个单独的文本文件 DBQuotesIntradayLag.sql,并将其作为资源连接到同名的测试脚本 DBQuotesIntradayLag.mq5

#resource "DBQuotesIntradayLag.sql" as string sql1

脚本的第一个参数允许你在数据库名称中设置一个前缀,在图表上以相同交易品种和时间周期运行 DBquotesImport.mq5 后,该数据库应该已经存在。后续的输入用于日期范围和日志中调试打印的长度限制。

input string Database = "MQL5Book/DB/Quotes";
input datetime SubsetStart = D'2022.01.01';
input datetime SubsetStop = D'2023.01.01';
input int Limit = 10;

报价表是预先知道的,来自上一个脚本。

const string Table = "MqlRatesDB";

OnStart 函数中,我们打开数据库并确保报价表可用。

void OnStart()
{
   Print("");
   DBSQLite db(Database + _Symbol + PeriodToString());
   if(!PRTF(db.isOpen())) return;
   if(!PRTF(db.hasTable(Table))) return;
   ...

接下来,我们在 SQL 查询字符串中替换参数。我们不仅要注意将 SQL 参数 '?n' 替换为格式序列,还要首先将百分号 % 加倍,否则 StringFormat 函数会将其视为自己的命令,而不会将它们传递到 SQL 中。

   string sqlrep = sql1;
   StringReplace(sqlrep"%""%%");
   StringReplace(sqlrep"?1""%ld");
   StringReplace(sqlrep"?2""%ld");
   StringReplace(sqlrep"?3""%d");
   
   const string sqlfmt = StringFormat(sqlrepSubsetStartSubsetStopLimit);
   Print(sqlfmt);

所有这些操作都只是为了在 DatabasePrint 函数的上下文中执行请求所必需的。在分析脚本的工作版本中,我们会读取查询结果并以编程方式分析它们,从而绕过格式化和调用 DatabasePrint

最后,我们执行 SQL 查询并将结果表输出到日志中。

   DatabasePrint(db.getHandle(), sqlfmt0);
}

以下是我们在 2022 年初对 10 条 EURUSD,H1 柱线所看到的内容。

db.isOpen()=true / ok

db.hasTable(Table)=true / ok

SELECT

DATETIME(time, 'unixepoch') as datetime,

open,

time,

TIME(time, 'unixepoch') AS intraday,

STRFTIME('%w', time, 'unixepoch') AS day,

(LAG(open,-1) OVER (ORDER BY time) - open) AS delta,

SIGN(open - LAG(open) OVER (ORDER BY time)) AS direction,

(LAG(open,-1) OVER (ORDER BY time) - open) * (open - LAG(open) OVER (ORDER BY time))

AS product,

(LAG(open,-1) OVER (ORDER BY time) - open) * SIGN(open - LAG(open) OVER (ORDER BY time))

AS estimate

FROM MqlRatesDB

WHERE (time >= 1640995200 AND time < 1672531200)

ORDER BY time LIMIT 10;

#| datetime open time intraday day delta dir product estimate

--+------------------------------------------------------------------------------------------------

1| 2022-01-03 00:00:00 1.13693 1641168000 00:00:00 1 0.0003200098

2| 2022-01-03 01:00:00 1.13725 1641171600 01:00:00 1 2.999999e-05 1 9.5999478e-09 2.999999e-05

3| 2022-01-03 02:00:00 1.13728 1641175200 02:00:00 1 -0.001060006 1 -3.1799748e-08 -0.001060006

4| 2022-01-03 03:00:00 1.13622 1641178800 03:00:00 1 -0.0003400007 -1 3.6040028e-07 0.0003400007

5| 2022-01-03 4:00:00 1.13588 1641182400 4:00:00 1 -0.001579991 -1 5.3719982e-07 0.001579991

6| 2022-01-03 05:00:00 1.1343 1641186000 05:00:00 1 0.0005299919 -1 -8.3739827e-07 -0.0005299919

7| 2022-01-03 06:00:00 1.13483 1641189600 06:00:00 1 -0.0007699937 1 -4.0809905e-07 -0.0007699937

8| 2022-01-03 7:00:00 1.13406 1641193200 7:00:00 1 -0.0002600149 -1 2.0020098e-07 0.0002600149

9| 2022-01-03 08:00:00 1.1338 1641196800 08:00:00 1 0.000510001 -1 -1.3260079e-07 -0.000510001

10| 2022-01-03 9:00:00 1.13431 1641200400 9:00:00 1 0.0004800036 1 2.4480023e-07 0.0004800036

...

很容易就能确保柱线的盘中时间以及星期 - 1(对应周一)分配正确。还可以检查 delta 增量。第一行的 productestimate 值是空的,因为它们需要计算缺失的前一行。

我们将 SQL 查询复杂化,将具有相同时间组合 (intraday) 和星期组合 (day) 的记录分组,并计算某个目标指标,该指标可以说明每个组合的交易成功率。我们把平均单元大小 product 除以相同乘积的标准差作为这样一个指标。相邻柱线价格增量的平均乘积越大,预期利润就越高,而这些乘积的点差越小,预测就越稳定。SQL 查询中的指标名称为 objective

除目标指标外,我们还将计算利润估计值 (backtest_profit) 和利润因子 (backtest_PF)。我们将以盘中时间和星期为背景,将利润估算为所有柱线的价格增量总和 (estimate)(作为价格增量的开盘柱线的大小是以每个柱线的点数为单位的未来利润的模拟值)。利润系数传统上是正增量和负增量之商。

SELECT

AVG(product) / STDDEV(product) AS objective,

SUM(estimate) AS backtest_profit,

SUM(CASE WHEN estimate >= 0 THEN estimate ELSE 0 END) /

SUM(CASE WHEN estimate < 0 THEN -estimate ELSE 0 END) AS backtest_PF,

intraday, day

FROM

(

SELECT

time,

TIME(time, 'unixepoch') AS intraday,

STRFTIME('%w', time, 'unixepoch') AS day,

(LAG(open,-1) OVER (ORDER BY time) - open) AS delta,

SIGN(open - LAG(open) OVER (ORDER BY time)) AS direction,

(LAG(open,-1) OVER (ORDER BY time) - open) * (open - LAG(open) OVER (ORDER BY time))

AS product,

(LAG(open,-1) OVER (ORDER BY time) - open) * SIGN(open - LAG(open) OVER (ORDER BY time))

AS estimate

FROM MqlRatesDB

WHERE (time >= STRFTIME('%s', '2015-01-01') AND time < STRFTIME('%s', '2021-01-01'))

)

GROUP BY intraday, day

ORDER BY objective DESC

第一个 SQL 查询已变为嵌套查询,我们现在从这个嵌套查询中通过外部 SQL 查询累积数据。通过 GROUP BY intraday, day,我们可以按日内时间和星期几的所有组合进行分组,并对每个组合进行聚合统计。此外,我们还添加了按目标指标排序 (ORDER BY objective DESC),以便最佳选项位于表的顶部。

在嵌套查询中,我们移除了 LIMIT 参数,因为分组后的数量已变得可接受,远少于分析的柱线数量。因此,对于 H1,我们得到 120 个选项 (24 * 5)。

扩展后的查询放置在 DBQuotesIntradayLagGroup.sql 文本文件中,该文件又作为资源连接到同名的测试脚本 DBQuotesIntradayLagGroup.mq5。其源代码与前一个几乎没有区别,因此我们将直接显示其针对默认日期范围(从 2015 年初到 2021 年初,不包括 2021 年和 2022 年)的启动结果。

db.isOpen()=true / ok

db.hasTable(Table)=true / ok

SELECT

AVG(product) / STDDEV(product) AS objective,

SUM(estimate) AS backtest_profit,

SUM(CASE WHEN estimate >= 0 THEN estimate ELSE 0 END) /

SUM(CASE WHEN estimate < 0 THEN -estimate ELSE 0 END) AS backtest_PF,

intraday, day

FROM

(

SELECT

...

FROM MqlRatesDB

WHERE (time >= 1420070400 AND time < 1609459200)

)

GROUP BY intraday, day

ORDER BY objective DESC

#| objective backtest_profit backtest_PF intraday day

---+---------------------------------------------------------------------------

1| 0.16713214428916 0.073200000000001 1.46040631486258 16:00:00 5

2| 0.118128291843983 0.0433099999999995 1.33678071539657 20:00:00 3

3| 0.103701251751617 0.00929999999999853 1.14148790506616 5:00:00 2

4| 0.102930330078208 0.0164399999999973 1.1932071923845 8:00:00 4

5| 0.089531492651001 0.0064300000000006 1.10167615433271 7:00:00 2

6| 0.0827628326995007 -8.99999999970369e-05 0.999601152226913 17:00:00 4

7| 0.0823433025146974 0.0159700000000012 1.21665988332657 21:00:00 1

8| 0.0767938336191962 0.00522999999999874 1.04226945769012 13:00:00 1

9| 0.0657741522256548 0.0162299999999986 1.09699976093712 15:00:00 2

10| 0.0635243373432768 0.00932000000000044 1.08294766820933 22:00:00 3

...

110| -0.0814131025461459 -0.0189100000000015 0.820605255668329 21:00:00 5

111| -0.0899571263478305 -0.0321900000000028 0.721250432975386 22:00:00 4

112| -0.0909772560603298 -0.0226100000000016 0.851161872161138 19:00:00 4

113| -0.0961794181717023 -0.00846999999999931 0.936377976414036 12:00:00 5

114| -0.108868074018582 -0.0246099999999998 0.634920634920637 0:00:00 5

115| -0.109368419185336 -0.0250700000000013 0.744496534855268 8:00:00 2

116| -0.121893581607986 -0.0234599999999998 0.610945273631843 0:00:00 3

117| -0.135416609546408 -0.0898899999999971 0.343437294573087 0:00:00 1

118| -0.142128458003631 -0.0255200000000018 0.681835182645536 6:00:00 4

119| -0.142196924506816 -0.0205700000000004 0.629769618430515 0:00:00 2

120| -0.15200009633513 -0.0301499999999988 0.708864426419475 2:00:00 1

因此,从分析结果可以得知,周五的 16 点 H1 柱线是基于前一根柱线延续趋势的最佳候选。其次优选的是周三的 20 点柱线。以此类推。

然而,最好是在远期(样本外)时段检验找到的这些设置。

为此,我们可以不仅在“过去”的日期范围(在我们的测试中直到 2021 年)执行当前的 SQL 查询,而且在“未来”(从 2021 年初开始)再次执行。两个查询的结果应按我们的分组 (intraday, day) 进行连接 (JOIN)。然后,在保持按目标指标排序的同时,我们将在相邻的列中看到相同时间和星期几组合的盈利和盈利因子,以及它们下降了多少。

以下是最终的 SQL 查询(缩写版):

SELECT * FROM

(

SELECT

AVG(product) / STDDEV(product) AS objective,

SUM(estimate) AS backtest_profit,

SUM(CASE WHEN estimate >= 0 THEN estimate ELSE 0 END) /

SUM(CASE WHEN estimate < 0 THEN -estimate ELSE 0 END) AS backtest_PF,

intraday, day

FROM

(

SELECT ...

FROM MqlRatesDB

WHERE (time >= STRFTIME('%s', '2015-01-01') AND time < STRFTIME('%s', '2021-01-01'))

)

GROUP BY intraday, day

) backtest

JOIN

(

SELECT

SUM(estimate) AS forward_profit,

SUM(CASE WHEN estimate >= 0 THEN estimate ELSE 0 END) /

SUM(CASE WHEN estimate < 0 THEN -estimate ELSE 0 END) AS forward_PF,

intraday, day

FROM

(

SELECT ...

FROM MqlRatesDB

WHERE (time >= STRFTIME('%s', '2021-01-01'))

)

GROUP BY intraday, day

) forward

USING(intraday, day)

ORDER BY objective DESC

请求的完整文本在 DBQuotesIntradayBackAndForward.sql 文件中提供。它在 DBQuotesIntradayBackAndForward.mq5 脚本中作为资源连接。

以默认设置运行脚本,我们可以得到以下指标(缩写):

#| objective backtest_profit backtest_PF intraday day forward_profit forward_PF

--+------------------------------------------------------------------------------------------------

1| 0.16713214428916 0.073200000001 1.46040631486 16:00:00 5 0.004920000048 1.12852664576

2| 0.118128291843983 0.0433099999995 1.33678071539 20:00:00 3 0.007880000055 1.277856135

3| 0.103701251751617 0.00929999999853 1.14148790506 5:00:00 2 0.002210000082 1.12149532710

4| 0.102930330078208 0.0164399999973 1.1932071923 8:00:00 4 0.001409999969 1.07253086419

5| 0.089531492651001 0.0064300000006 1.10167615433 7:00:00 2 -0.009119999869 0.561749159058

6| 0.0827628326995007 -8.99999999970e-05 0.999601152226 17:00:00 4 0.009070000091 1.18809622563

7| 0.0823433025146974 0.0159700000012 1.21665988332 21:00:00 1 0.00250999999 1.12131464475

8| 0.0767938336191962 0.00522999999874 1.04226945769 13:00:00 1 -0.008490000055 0.753913043478

9| 0.0657741522256548 0.0162299999986 1.09699976093 15:00:00 2 0.01423999997 1.34979120609

10| 0.0635243373432768 0.00932000000044 1.08294766820 22:00:00 3 -0.00456999993 0.828967065868

...

因此,具有最佳交易时间安排的交易系统在“未来”时期继续显示盈利,尽管不如回测时那么大。

当然,所考虑的示例仅仅是交易系统的一个特例。例如,我们可以找到在一周中的某个时间和日期组合,此时反转策略在相邻柱线上有效,或者完全基于其他原则(分析分时报价、日历、交易信号组合等)。

最重要的是,SQLite 引擎提供了许多方便的工具,否则这些工具在 MQL5 中需要由我们自行实现。说实话,学习 SQL 需要时间。该平台允许你选择两种技术的最佳组合以进行高效编程。