Skip to content

矩阵和向量

MQL5 语言提供了特殊的对象数据类型:矩阵和向量。它们可用于解决一大类数学问题。这些类型提供了方法,能编写出接近线性或微分方程数学表示法的简洁易懂的代码。

所有编程语言都支持数组的概念,数组是多个元素的集合。大多数算法,尤其是算法交易中的算法,都是基于数值类型数组(如 intdouble)或结构体构建的。可以通过索引访问数组元素,这使得能够在循环内实现操作。我们知道,数组可以有一维、二维或更多维。

相对简单的数据存储和处理任务通常可以通过使用数组来实现。但在处理复杂的数学问题时,大量的嵌套循环会使使用数组编程和阅读代码都变得困难。即使是最简单的线性代数运算也需要大量代码,并且需要对数学有较好的理解。编程的函数式范式以矩阵和向量方法函数的形式体现,可以简化这个任务。这些操作在“幕后”完成了许多常规操作。

机器学习、神经网络和 3D 图形等现代技术广泛使用线性代数问题的求解,这些求解涉及向量和矩阵的运算。MQL5 中添加了新的数据类型,以便快速方便地处理此类对象。

在撰写本书时,用于处理矩阵和向量的函数集正在积极开发中,因此这里可能没有提及许多有趣的新特性。请关注 mql5.com 网站上的发布说明和文章板块。

在本章中,我们将进行简要介绍。有关矩阵和向量的更多详细信息,请参阅相应的帮助部分“矩阵和向量方法”。

同时假设读者熟悉线性代数理论。如有必要,你可以随时查阅网络上的参考文献和手册。

矩阵和向量的类型

向量是实型或复型的一维数组,而矩阵是实型或复型的二维数组。因此,这些对象元素的有效数值类型列表包括 double(被视为默认类型)、floatcomplex

从线性代数(而非编译器)的角度来看,一个质数也是最小的向量,而向量又可以被视为矩阵的一种特殊情况。

根据元素类型的不同,向量使用以下 vector(带或不带后缀)关键字之一来描述:

  • vector:元素类型为 double 的向量
  • vectorf:元素类型为 float 的向量
  • vectorc:元素类型为 complex 的向量

尽管向量可以是垂直的和水平的,但 MQL5 并不做这样的区分。向量所需的方向由其在表达式中的位置决定(隐含确定)。

对向量定义了以下操作:加法、乘法,以及 Norm(使用相关的范数方法)来获取向量的长度或模。

你可以将矩阵视为一个数组,其中第一个索引是行号,第二个索引是列号。然而,与线性代数不同的是,行和列的编号像数组一样从 0 开始。

矩阵

矩阵的两个维度也称为轴,编号如下:0 表示水平轴(沿行方向),1 表示垂直轴(沿列方向)。轴编号在许多矩阵函数中都会用到。特别是,当我们谈到将矩阵分割成部分时,水平分割意味着在行之间切割,垂直分割意味着在列之间切割。

根据元素类型的不同,矩阵使用以下 matrix(带或不带后缀)关键字之一来描述:

  • matrix:元素类型为 double 的矩阵
  • matrixf:元素类型为 float 的矩阵
  • matrixc:元素类型为 complex 的矩阵

对于在模板函数中的应用,你可以使用 matrix<double>matrix<float>matrix<complex>vector<double>vector<float>vector<complex> 这样的表示法,来替代相应的类型。

c
vectorf v_f1 = {0, 1, 2, 3,};
vector<float> v_f2 = v_f1;
matrix m = {{0, 1}, {2, 3}};
 
void OnStart()
{
   Print(v_f2);
   Print(m);
}

当记录日志时,矩阵和向量以数字序列的形式打印,数字之间用逗号分隔,并括在方括号内。

[0,1,2,3]
[[0,1]
 [2,3]]

为矩阵定义了以下代数运算:

  • 相同大小矩阵的加法
  • 合适大小矩阵的乘法,此时第一个矩阵的列数必须等于第二个矩阵的行数
  • 根据矩阵乘法规则,矩阵与列向量的乘法以及行向量与矩阵的乘法(从这个意义上说,向量是矩阵的一种特殊情况)
  • 矩阵与一个数的乘法

此外,矩阵和向量类型具有与 Python 中流行的机器学习库 NumPy 类似的内置方法,所以你可以在文档和库示例中获得更多提示。完整的方法列表可以在 MQL5 帮助的相应部分找到。

遗憾的是,MQL5 不支持将一种类型的矩阵和向量转换为另一种类型(例如,从 double 转换为 float)。而且,在期望是矩阵的表达式中,编译器不会自动将向量视为(具有一列或一行的)矩阵。这意味着,尽管矩阵和向量这些结构之间存在明显的关系,但它们之间并不存在(面向对象编程所具有的)继承概念。

矩阵和向量的创建与初始化

有多种方式来声明和初始化矩阵与向量。根据其用途,可将这些方式分为几类:

  1. 不指定大小的声明
  2. 指定大小的声明
  3. 带初始化的声明
  4. 静态创建方法
  5. 非静态(重新)配置和初始化方法

最简单的创建方式是不指定大小进行声明,也就是不为数据分配内存。只需指定变量的类型和名称即可:

c
matrix         matrix_a;   // double 类型的矩阵
matrix<double> matrix_a1;  // 在函数或类模板中使用的 double 类型矩阵
matrix<float>  matrix_a2;  // float 类型的矩阵
vector         vector_v;   // double 类型的向量
vector<double> vector_v1;  // 另一种创建 double 类型向量的表示法
vector<float>  vector_v2;  // float 类型的向量

之后,你可以改变所创建对象的大小,并使用所需的值填充它们。这些对象也能用于内置的矩阵和向量方法中,以获取计算结果。本章后续会按组详细讨论这些方法。

你也可以声明指定大小的矩阵或向量,这会分配内存,但不会进行任何初始化。在变量名后的括号中指定大小(对于矩阵,第一个是行数,第二个是列数):

c
matrix         matrix_a(128, 128);      // 可以将常量和变量作为参数
matrix<double> matrix_a1(nRows, nCols); 
matrix<float>  matrix_a2(nRows, 1);     // 列向量的类似表示
vector         vector_v(256);
vector<double> vector_v1(nSize);
vector<float>  vector_v2(nSize + 16);    // 表达式作为参数

第三种创建对象的方式是带初始化的声明。在这种情况下,矩阵和向量的大小由花括号中指定的初始化序列决定:

c
matrix         matrix_a = {{0.1, 0.2, 0.3}, {0.4, 0.5, 0.6}};
matrix<double> matrix_a1 = matrix_a;     // 必须是相同类型的矩阵
matrix<float>  matrix_a2 = {{1, 2}, {3, 4}};
vector         vector_v = {-5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5};
vector<double> vector_v1 = {1, 5, 2.4, 3.3};
vector<float>  vector_v2 = vector_v1;    // 必须是相同类型的向量

还有一些静态方法,可用于创建指定大小并以特定方式初始化(特别是针对某种规范形式)的矩阵和向量。以下是所有这些方法,它们具有相似的原型(向量与矩阵的区别仅在于没有第二个维度):

c
static matrix<T> matrix<T>::Eye∫Tri(const ulong rows, const ulong cols, const int diagonal = 0);
static matrix<T> matrix<T>::Identity∫Ones∫Zeros(const ulong rows, const ulong cols);
static matrix<T> matrix<T>::Full(const ulong rows, const ulong cols, const double value);
  • Eye:构建一个在指定对角线上为 1,其他位置为 0 的矩阵。
  • Tri:构建一个在指定对角线及其下方为 1,其他位置为 0 的矩阵。
  • Identity:构建一个指定大小的单位矩阵。
  • Ones:构建一个(或向量)所有元素都为 1 的矩阵。
  • Zeros:构建一个(或向量)所有元素都为 0 的矩阵。
  • Full:构建一个(或向量)所有元素都为给定值的矩阵。

如果需要,你可以使用非静态方法 Identity(无参数)将任何现有矩阵转换为单位矩阵。

以下是这些方法的使用示例:

c
matrix         matrix_a = matrix::Eye(4, 5, 1);
matrix<double> matrix_a1 = matrix::Full(3, 4, M_PI);
matrixf        matrix_a2 = matrixf::Identity(5, 5);
matrixf<float> matrix_a3 = matrixf::Ones(5, 5);
matrix         matrix_a4 = matrix::Tri(4, 5, -1);
vector         vector_v = vector::Ones(256);
vectorf        vector_v1 = vector<float>::Zeros(16);
vector<float>  vector_v2 = vectorf::Full(128, float_value);

此外,还有非静态方法 InitFill 用于用给定值初始化矩阵/向量:

c
void matrix<T>::Init(const ulong rows, const ulong cols, func_reference rule = NULL, ...)
void matrix<T>::Fill(const T value)

Init 方法(构造函数也有此特性)的一个重要优点是,能够在参数中指定一个初始化函数,根据给定规则填充矩阵/向量的元素(见以下示例)。

可以在指定大小后,通过在 rules 参数中指定函数标识符(不带引号)来传递对该函数的引用(这不是 typedef (*pointer)(...) 意义上的指针,也不是包含名称的字符串)。

初始化函数的第一个参数必须是对要填充对象的引用,还可以有其他额外参数:在这种情况下,这些参数的值在函数引用之后传递给 Init 或构造函数。如果未指定规则引用,它将仅创建指定维度的矩阵。

Init 方法还允许更改矩阵的配置。

下面通过小示例展示上述内容:

c
matrix m(2, 2);
m.Fill(10);
Print("matrix m \n", m);
/*
  matrix m
  [[10,10]
  [10,10]]
*/
m.Init(4, 6);
Print("matrix m \n", m);
/*
  matrix m
  [[10,10,10,10,0.0078125,32.00000762939453]
  [0,0,0,0,0,0]
  [0,0,0,0,0,0]
  [0,0,0,0,0,0]]
*/

这里使用 Init 方法调整了已初始化矩阵的大小,结果新元素被填充为随机值。

以下函数使用指数增长的数字填充矩阵:

c
template<typename T>
void MatrixSetValues(matrix<T> &m, const T initial = 1)
{
   T value = initial;
   for(ulong r = 0; r < m.Rows(); r++)
   {
      for(ulong c = 0; c < m.Cols(); c++)
      {
         m[r][c] = value;
         value *= 2;
      }
   }
}

然后可以使用该函数创建矩阵:

c
void OnStart()
{
   matrix M(3, 6, MatrixSetValues);
   Print("M = \n", M);
}

执行结果如下:

M = 
[[1,2,4,8,16,32]
 [64,128,256,512,1024,2048]
 [4096,8192,16384,32768,65536,131072]]

在这种情况下,在构造函数调用中,未在初始化函数标识符之后指定其参数的值,因此使用了默认值(1)。但我们可以为 MatrixSetValues 传递起始值 -1,这将用负数填充矩阵:

c
matrix M(3, 6, MatrixSetValues, -1);

矩阵、向量和数组的复制

复制矩阵和向量最简单且最常见的方法是通过赋值运算符“=”。

c
matrix a = {{2, 2}, {3, 3}, {4, 4}};
matrix b = a + 2;
matrix c;
Print("matrix a \n", a);
Print("matrix b \n", b);
c.Assign(b);
Print("matrix c \n", c);

这段代码片段生成以下日志条目:

matrix a
[[2,2]
 [3,3]
 [4,4]]
matrix b
[[4,4]
 [5,5]
 [6,6]]
matrix c
[[4,4]
 [5,5]
 [6,6]]

CopyAssign 方法也可用于复制矩阵和向量。AssignCopy 之间的区别在于,Assign 不仅可以复制矩阵,还可以复制数组。

c
bool matrix<T>::Copy(const matrix<T> &source)
bool matrix<T>::Assign(const matrix<T> &source)
bool matrix<T>::Assign(const T &array[])

向量也有类似的方法和原型。

通过 Assign,可以将向量写入矩阵:结果将是一个单行矩阵。

c
bool matrix<T>::Assign(const vector<T> &v)

也可以将矩阵赋值给向量:它将被展开,即矩阵的所有行将排列成一行(相当于调用 Flat 方法)。

c
bool vector<T>::Assign(const matrix<T> &m)

在撰写本章时,MQL5 中没有将矩阵或向量导出到数组的方法,尽管存在一种“传输”数据的机制(请参阅后面的 Swap 方法)。

下面的示例展示了如何将整数数组 int_arr 复制到 double 类型的矩阵中。在这种情况下,结果矩阵会自动调整为与复制数组的大小一致。

c
matrix double_matrix = matrix::Full(2, 10, 3.14);
Print("double_matrix before Assign() \n", double_matrix);
int int_arr[5][5] = {{1, 2}, {3, 4}, {5, 6}};
Print("int_arr: ");
ArrayPrint(int_arr);
double_matrix.Assign(int_arr);
Print("double_matrix after Assign(int_arr) \n", double_matrix);

我们在日志中得到以下输出:

double_matrix before Assign() 
[[3.14,3.14,3.14,3.14,3.14,3.14,3.14,3.14,3.14,3.14]
 [3.14,3.14,3.14,3.14,3.14,3.14,3.14,3.14,3.14,3.14]]
 
int_arr: 
    [,0][,1][,2][,3][,4]
[0,]   1   2   0   0   0
[1,]   3   4   0   0   0
[2,]   5   6   0   0   0
[3,]   0   0   0   0   0
[4,]   0   0   0   0   0
 
double_matrix after Assign(int_arr) 
[[1,2,0,0,0]
 [3,4,0,0,0]
 [5,6,0,0,0]
 [0,0,0,0,0]
 [0,0,0,0,0]]

所以,Assign 方法可用于从数组转换为矩阵,并自动进行大小和类型转换。

在矩阵、向量和数组之间传输数据的一种更高效(快速且不涉及复制)的方法是使用 Swap 方法。

c
bool matrix<T>::Swap(vector<T> &vec)
bool matrix<T>::Swap(matrix<T> &vec)
bool matrix<T>::Swap(T &arr[])
bool vector<T>::Swap(vector<T> &vec)
bool vector<T>::Swap(matrix<T> &vec)
bool vector<T>::Swap(T &arr[])

它们的工作方式类似于 ArraySwap:交换两个对象内部指向数据缓冲区的内部指针。结果,矩阵或向量的元素在源对象中消失,并出现在接收数组中,或者反之,它们从数组移动到矩阵或向量中。

Swap 方法允许处理动态数组,包括多维数组。对于多维数组(array[][N1][N2]...)的最高维度的固定大小有一定条件:这些维度的乘积必须是矩阵或向量大小的倍数。所以,[][2][3] 的数组以 6 个元素为块进行重新分配。因此,它可以与大小为 6、12、18 等的矩阵和向量互换。

将时间序列复制到矩阵和向量中

matrix<T>::CopyRates 方法可将带有报价历史的时间序列直接复制到矩阵或向量中。该方法的工作方式与我们将在第五部分关于时间序列的章节中详细介绍的函数类似,即 CopyRates 函数以及针对 MqlRates 结构每个字段的单独 Copy 函数。

c
bool matrix<T>::CopyRates(const string symbol, ENUM_TIMEFRAMES tf, ulong rates_mask,
  ulong start, ulong count)

bool matrix<T>::CopyRates(const string symbol, ENUM_TIMEFRAMES tf, ulong rates_mask,
  datetime from, ulong count)

bool matrix<T>::CopyRates(const string symbol, ENUM_TIMEFRAMES tf, ulong rates_mask,
  datetime from, datetime to)

在参数中,你需要指定交易品种(symbol)、时间框架(tf)以及请求的 K 线范围:可以通过起始序号和数量来指定,也可以通过日期范围来指定。数据复制时,最旧的元素会放置在矩阵/向量的开头。

rates_mask 参数指定来自 ENUM_COPY_RATES 枚举的标志组合以及一组可用字段。通过标志组合,你可以在一次请求中从历史数据获取多个时间序列。在这种情况下,矩阵中行的顺序将与 ENUM_COPY_RATES 枚举中的值的顺序相对应,特别是,矩阵中包含最高价(High)数据的行将始终位于包含最低价(Low)数据的行之上。

当复制到向量中时,只能指定 ENUM_COPY_RATES 枚举中的一个值。否则,将会发生错误。

标识符描述
COPY_RATES_OPEN1开盘价
COPY_RATES_HIGH2最高价
COPY_RATES_LOW4最低价
COPY_RATES_CLOSE8收盘价
COPY_RATES_TIME16K 线开盘时间
COPY_RATES_VOLUME_TICK32挂单成交量
COPY_RATES_VOLUME_REAL64真实成交量
COPY_RATES_SPREAD128点差
组合
COPY_RATES_OHLC15开盘价、最高价、最低价、收盘价
COPY_RATES_OHLCT31开盘价、最高价、最低价、收盘价、时间

我们将在“求解方程”部分查看使用此函数的示例。

将tick历史数据复制到矩阵或向量中

和处理K线数据一样,你也能把tick数据复制到向量或矩阵里。这可以通过CopyTicksCopyTicksRange方法的重载形式实现。它们的工作原理和CopyTicks以及CopyTicksRange函数类似,不过是把数据存入调用者对象中。这些函数会在第五部分关于MqlTick结构体的真实tick数组章节里详细介绍。这里仅展示函数原型并提及要点。

c
bool matrix<T>::CopyTicks(const string symbol, uint flags, ulong from_msc, uint count)
bool vector<T>::CopyTicks(const string symbol, uint flags, ulong from_msc, uint count)
bool matrix<T>::CopyTicksRange(const string symbol, uint flags, ulong from_msc, ulong to_msc)
bool matrix<T>::CopyTicksRange(const string symbol, uint flags, ulong from_msc, ulong to_msc)

symbol参数设定了请求tick数据的金融工具名称。tick数据的范围可以通过不同方式指定:

  • CopyTicks里,可指定为从某个时刻(from_msc,以毫秒为单位)开始的tick数量(count参数)。
  • CopyTicksRange里,可指定为两个时间点组成的范围(从from_mscto_msc)。

每个tick的复制数据组成由flags参数指定,该参数是ENUM_COPY_TICKS枚举值的位掩码。

标识符描述
COPY_TICKS_INFO1由买入价(Bid)和/或卖出价(Ask)变化产生的tick
COPY_TICKS_TRADE2由最后成交价(Last)和成交量(Volume)变化产生的tick
COPY_TICKS_ALL3所有tick
COPY_TICKS_TIME_MS1 << 8以毫秒为单位的时间
COPY_TICKS_BID1 << 9买入价
COPY_TICKS_ASK1 << 10卖出价
COPY_TICKS_LAST1 << 11最后成交价
COPY_TICKS_VOLUME1 << 12成交量
COPY_TICKS_FLAGS1 << 13tick标志

前三位(低字节)确定请求的tick集合,其余位(高字节)确定这些tick的属性。

高字节标志只能用于矩阵,因为向量中只会存放所有tick的某个特定字段值的一行数据。所以,填充向量时应只选择最高字节的一位。

在填充矩阵时选择tick的多个属性,矩阵中行的顺序会和枚举中元素的顺序对应。例如,买入价所在的行总是会比卖出价所在的行位置更高(索引更小)。

关于tick数据和向量使用的示例会在机器学习部分给出。

矩阵和向量表达式的求值

你可以对矩阵和向量逐元素地执行数学运算(使用运算符),例如加法、减法、乘法和除法。对于这些运算,两个对象必须是相同类型且具有相同的维度。矩阵/向量中的每个元素会与第二个矩阵/向量中对应的元素进行交互。

作为第二个运算项(乘数、减数或除数),你也可以使用相应类型的标量(doublefloatcomplex)。在这种情况下,矩阵或向量中的每个元素都会结合该标量进行处理。

c
matrix matrix_a = {{0.1, 0.2, 0.3}, {0.4, 0.5, 0.6}};
matrix matrix_b = {{1, 2, 3}, {4, 5, 6}};
matrix matrix_c1 = matrix_a + matrix_b;
matrix matrix_c2 = matrix_b - matrix_a;
matrix matrix_c3 = matrix_a * matrix_b;   // 哈达玛积(逐元素相乘)
matrix matrix_c4 = matrix_b / matrix_a;
matrix_c1 = matrix_a + 1;
matrix_c2 = matrix_b - double_value;
matrix_c3 = matrix_a * M_PI;
matrix_c4 = matrix_b / 0.1;
matrix_a += matrix_b;                     // 支持“原地”操作 
matrix_a /= 2;

与常规的二元运算(操作数保持不变,结果会创建一个新对象)不同,“原地”操作会修改原始矩阵(或向量),并将结果存入其中。

此外,矩阵和向量可以作为参数传递给大多数数学函数。在这种情况下,矩阵或向量会被逐元素地处理。例如:

c
matrix a = {{1, 4}, {9, 16}};
Print("matrix a=\n", a);
a = MathSqrt(a);
Print("MatrSqrt(a)=\n", a);
/*
   matrix a=
   [[1,4]
    [9,16]]
   MatrSqrt(a)=
   [[1,2]
    [3,4]]
*/

对于 MathModMathPow 函数,第二个参数可以是标量,也可以是大小合适的矩阵或向量。

矩阵和向量的操作

在处理矩阵和向量时,有一些无需计算的基本操作。列表开头的操作是矩阵特有的方法,而最后四个方法也适用于向量。

  1. 转置(Transpose):矩阵转置。
  2. Col、Row、Diag:按编号提取和设置行、列和对角线。
  3. TriL、TriU:根据对角线编号获取下三角矩阵和上三角矩阵。
  4. SwapCols、SwapRows:重新排列指定编号的行和列。
  5. Flat:通过全索引设置和获取矩阵元素。
  6. Reshape:“原地”重塑矩阵。
  7. Split、Hsplit、Vsplit:将矩阵分割成几个子矩阵。
  8. resize:“原地”调整矩阵或向量的大小。
  9. Compare、CompareByDigits:以给定的实数精度比较两个矩阵或两个向量。
  10. Sort:“原地”排序(元素置换),以及获取索引向量或矩阵进行排序。
  11. clip:“原地”限制元素的值的范围。

请注意,不提供向量分割操作。

以下是矩阵的方法原型:

c
matrix<T> matrix<T>::Transpose()

vector matrix<T>::Col∫Row(const ulong n)

void matrix<T>::Col∫Row(const vector v, const ulong n)

vector matrix<T>::Diag(const int n = 0)

void matrix<T>::Diag(const vector v, const int n = 0)

matrix<T> matrix<T>::TriL∫TriU(const int n = 0)

bool matrix<T>::SwapCols∫SwapRows(const ulong n1, const ulong n2)

T matrix<T>::Flat(const ulong i)

bool matrix<T>::Flat(const ulong i, const T value)

bool matrix<T>::Resize(const ulong rows, const ulong cols, const ulong reserve = 0)

void matrix<T>::Reshape(const ulong rows, const ulong cols)

ulong matrix<T>::Compare(const matrix<T> &m, const T epsilon)

ulong matrix<T>::CompareByDigits(const matrix &m, const int digits)

bool matrix<T>::Split(const ulong nparts, const int axis, matrix<T> &splitted[])

void matrix<T>::Split(const ulong &parts[], const int axis, matrix<T> &splitted[])

bool matrix<T>::Hsplit∫Vsplit(const ulong nparts, matrix<T> &splitted[])

void matrix<T>::Hsplit∫Vsplit(const ulong &parts[], matrix<T> &splitted[])

void matrix<T>::Sort(func_reference compare = NULL, T context)

void matrix<T>::Sort(const int  axis, func_reference compare = NULL, T context)

matrix<T> matrix<T>::Sort(func_reference compare = NULL, T context)

matrix<T> matrix<T>::Sort(const int axis, func_reference compare = NULL, T context)

bool matrix<T>::Clip(const T min, const T max)

对于向量,可用的方法较少:

c
bool vector<T>::Resize(const ulong size, const ulong reserve = 0)

ulong vector<T>::Compare(const vector<T> &v, const T epsilon)

ulong vector<T>::CompareByDigits(const vector<T> &v, const int digits)

void vector<T>::Sort(func_reference compare = NULL, T context)

vector vector<T>::Sort(func_reference compare = NULL, T context)

bool vector<T>::Clip(const T min, const T max)

矩阵转置示例

c
matrix a = {{0, 1, 2}, {3, 4, 5}};
Print("matrix a \n", a);
Print("a.Transpose() \n", a.Transpose());
/*
   matrix a
   [[0,1,2]
    [3,4,5]]
   a.Transpose()
   [[0,3]
    [1,4]
    [2,5]]
*/

使用Diag方法设置不同对角线的几个示例

c
vector v1 = {1, 2, 3};
matrix m1;
m1.Diag(v1);
Print("m1\n", m1);
/* 
   m1
   [[1,0,0]
    [0,2,0]
    [0,0,3]]
*/
  
matrix m2;
m2.Diag(v1, -1);
Print("m2\n", m2);
/*
   m2
   [[0,0,0]
    [1,0,0]
    [0,2,0]
    [0,0,3]]
*/
  
matrix m3;
m3.Diag(v1, 1);
Print("m3\n", m3);
/*
   m3
   [[0,1,0,0]
    [0,0,2,0]
    [0,0,0,3]]
*/

使用Reshape改变矩阵配置示例

c
matrix matrix_a = {{1, 2, 3}, {4, 5, 6}, {7, 8, 9}, {10, 11, 12}};
Print("matrix_a\n", matrix_a);
/*
   matrix_a
   [[1,2,3]
    [4,5,6]
    [7,8,9]
    [10,11,12]]
*/
  
matrix_a.Reshape(2, 6);
Print("Reshape(2,6)\n", matrix_a);
/*
   Reshape(2,6)
   [[1,2,3,4,5,6]
    [7,8,9,10,11,12]]
*/
  
matrix_a.Reshape(3, 5);
Print("Reshape(3,5)\n", matrix_a);
/*
   Reshape(3,5)
   [[1,2,3,4,5]
    [6,7,8,9,10]
    [11,12,0,3,0]]
*/
  
matrix_a.Reshape(2, 4);
Print("Reshape(2,4)\n", matrix_a);
/*
   Reshape(2,4)
   [[1,2,3,4]
    [5,6,7,8]]
*/

我们将在“求解方程”的示例中应用将矩阵分割为子矩阵的操作。

ColRow 方法不仅可以按编号获取矩阵的列或行,还可以将它们“原地”插入到先前定义的矩阵中。在这种情况下,矩阵的维度以及列向量(对于 Col 方法)或行向量(对于 Row 方法)之外的元素值都不会改变。

如果将这两个方法中的任何一个应用于尚未设置维度的矩阵,则会创建一个大小为 [N * M] 的零矩阵,其中 NM 对于 ColRow 的定义不同,这取决于向量的长度以及给定的列或行索引:

  • 对于 Col 方法,N 是列向量的长度,M 比插入列的指定索引大 1。
  • 对于 Row 方法,N 比插入行的指定索引大 1,M 是行向量的长度。

在撰写本章时,MQL5 没有提供用于完全插入行和列并扩展后续元素的方法,也没有提供排除指定行和列的方法。

矩阵与向量的乘积

矩阵乘法是各种数值方法中的基本运算之一。例如,在神经网络层中实现前向和反向传播方法时经常会用到它。

各类卷积也可归结为矩阵乘积的范畴。MQL5 中的这类函数如下所示:

  • MatMul:两个矩阵的矩阵乘积
  • Power:将方阵提升到指定的整数幂
  • Inner:两个矩阵的内积
  • Outer:两个矩阵或两个向量的外积
  • Kron:两个矩阵、一个矩阵和一个向量、一个向量和一个矩阵或两个向量的克罗内克积
  • CorrCoef:计算矩阵的行或列之间,或者向量之间的皮尔逊相关性
  • Cov:计算矩阵的行或列的协方差矩阵,或者两个向量之间的协方差矩阵
  • Correlate:计算两个向量的互相关(互协方差)
  • Convolve:计算两个向量的离散线性卷积
  • Dot:两个向量的标量积

为了让你大致了解如何使用这些方法,下面给出它们的原型(顺序为:从矩阵运算,到矩阵 - 向量混合运算,再到向量运算):

matrix<T> matrix<T>::MatMul(const matrix<T> &m)
matrix<T> matrix<T>::Power(const int power)
matrix<T> matrix<T>::Inner(const matrix<T> &m)
matrix<T> matrix<T>::Outer(const matrix<T> &m)
matrix<T> matrix<T>::Kron(const matrix<T> &m)
matrix<T> matrix<T>::Kron(const vector<T> &v)
matrix<T> matrix<T>::CorrCoef(const bool rows = true)
matrix<T> matrix<T>::Cov(const bool rows = true)
matrix<T> vector<T>::Cov(const vector<T> &v)
T vector<T>::CorrCoef(const vector<T> &v)
vector<T> vector<T>::Correlate(const vector<T> &v, ENUM_VECTOR_CONVOLVE mode)
vector<T> vector<T>::Convolve(const vector<T> &v, ENUM_VECTOR_CONVOLVE mode)
matrix<T> vector<T>::Outer(const vector<T> &v)
matrix<T> vector<T>::Kron(const matrix<T> &m)
matrix<T> vector<T>::Kron(const vector<T> &v)
T vector<T>::Dot(const vector<T> &v)

以下是一个使用 MatMul 方法计算两个矩阵乘积的简单示例:

cpp
matrix a = {{1, 0, 0},
            {0, 1, 0}};
matrix b = {{4, 1},
            {2, 2},
            {1, 3}};
matrix c1 = a.MatMul(b);
matrix c2 = b.MatMul(a);
Print("c1 = \n", c1);
Print("c2 = \n", c2);
/*
   c1 = 
   [[4,1]
    [2,2]]
   c2 = 
   [[4,1,0]
    [2,2,0]
    [1,3,0]]
*/

形如 A[M,N] * B[N,K] = C[M,K] 的矩阵可以相乘,即第一个矩阵的列数必须等于第二个矩阵的行数。如果维度不一致,结果将是一个空矩阵。

在矩阵与向量相乘时,允许两种情况:

  • 水平向量(行向量)右乘矩阵,向量的长度等于矩阵的行数。
  • 矩阵右乘垂直向量(列向量),向量的长度等于矩阵的列数。

向量之间也可以相互相乘。在 MatMul 中,这始终等同于行向量与列向量的点积(Dot 方法),而列向量乘以行向量得到矩阵的情况由另一个方法 Outer 支持。

下面演示向量 v5 与向量 v3 的外积,以及它们的反向顺序外积。在这两种情况下,都假设左边是列向量,右边是行向量。

cpp
vector v3 = {1, 2, 3};
vector v5 = {1, 2, 3, 4, 5};
Print("v5 = \n", v5);
Print("v3 = \n", v3);
Print("v5.Outer(v3) = m[5,3] \n", v5.Outer(v3));
Print("v3.Outer(v5) = m[3,5] \n", v3.Outer(v5));
/*
   v5 =
   [1,2,3,4,5]
   v3 =
   [1,2,3]
   v5.Outer(v3) = m[5,3]
   [[1,2,3]
    [2,4,6]
    [3,6,9]
    [4,8,12]
    [5,10,15]]
   v3.Outer(v5) = m[3,5]
   [[1,2,3,4,5]
    [2,4,6,8,10]
    [3,6,9,12,15]]
*/

矩阵变换(分解)

矩阵变换是处理数据时最常用的操作。然而,许多复杂的变换无法通过解析方法以绝对精度完成。

矩阵变换(或者换句话说,矩阵分解)是将矩阵分解为其组成部分的方法,这使得计算更复杂的矩阵运算变得更容易。矩阵分解方法,也称为矩阵因式分解方法,是线性代数算法的基础,例如求解线性方程组、计算矩阵的逆或行列式。

特别是,奇异值分解(SVD)在机器学习中被广泛使用,它允许将原始矩阵表示为另外三个矩阵的乘积。SVD分解用于解决各种问题,从最小二乘法逼近到压缩和图像识别。

可用的方法列表如下:

  • Cholesky:计算乔列斯基分解
  • Eig:计算方阵的特征值和右特征向量
  • Eig Vals:计算普通矩阵的特征值
  • LU:将矩阵实现为下三角矩阵和上三角矩阵的乘积,即进行LU分解
  • LUP:实现带部分旋转的LUP分解,这是一种仅进行行置换的LU分解:PA = LU
  • QR:实现矩阵的QR分解
  • SVD:奇异值分解

以下是这些方法的原型:

bool matrix<T>::Cholesky(matrix<T> &L)
bool matrix<T>::Eig(matrix<T> &eigen_vectors, vector<T> &eigen_values)
bool matrix<T>::EigVals(vector<T> &eigen_values)
bool matrix<T>::LU(matrix<T> &L, matrix<T> &U)
bool matrix<T>::LUP(matrix<T> &L, matrix<T> &U, matrix<T> &P)
bool matrix<T>::QR(matrix<T> &Q, matrix<T> &R)
bool matrix<T>::SVD(matrix<T> &U, matrix<T> &V, vector<T> &singular_values)

下面展示一个使用SVD方法进行奇异值分解的示例(见文件MatrixSVD.mq5)。首先,我们初始化原始矩阵:

cpp
matrix a = {{0, 1, 2, 3, 4, 5, 6, 7, 8}};
a = a - 4;
a.Reshape(3, 3);
Print("matrix a \n", a);

现在进行SVD分解:

cpp
matrix U, V;
vector singular_values;
a.SVD(U, V, singular_values);
Print("U \n", U);
Print("V \n", V);
Print("singular_values = ", singular_values);

让我们验证分解结果:必须满足以下等式:U * "奇异值对角矩阵" * V = A。

cpp
matrix matrix_s;
matrix_s.Diag(singular_values);
Print("matrix_s \n", matrix_s);
matrix matrix_vt = V.Transpose();
Print("matrix_vt \n", matrix_vt);
matrix matrix_usvt = (U.MatMul(matrix_s)).MatMul(matrix_vt);
Print("matrix_usvt \n", matrix_usvt);

让我们比较结果矩阵和原始矩阵的误差:

cpp
ulong errors = (int)a.Compare(matrix_usvt, 1e-9);
Print("errors=", errors);

日志输出应该如下所示:

matrix a
[[-4,-3,-2]
 [-1,0,1]
 [2,3,4]]
U
[[-0.7071067811865474,0.5773502691896254,0.408248290463863]
 [-6.827109697437648e-17,0.5773502691896253,-0.8164965809277256]
 [0.7071067811865472,0.5773502691896255,0.4082482904638627]]
V
[[0.5773502691896258,-0.7071067811865474,-0.408248290463863]
 [0.5773502691896258,1.779939029415334e-16,0.8164965809277258]
 [0.5773502691896256,0.7071067811865474,-0.408248290463863]]
singular_values = [7.348469228349533,2.449489742783175,3.277709923350408e-17]
  
matrix_s
[[7.348469228349533,0,0]
 [0,2.449489742783175,0]
 [0,0,3.277709923350408e-17]]
matrix_vt
[[0.5773502691896258,0.5773502691896258,0.5773502691896256]
 [-0.7071067811865474,1.779939029415334e-16,0.7071067811865474]
 [-0.408248290463863,0.8164965809277258,-0.408248290463863]]
matrix_usvt
[[-3.999999999999997,-2.999999999999999,-2]
 [-0.9999999999999981,-5.977974170712231e-17,0.9999999999999974]
 [2,2.999999999999999,3.999999999999996]]
errors=0

Convolve方法的另一个实际应用案例包含在“机器学习方法”的示例中。

获取统计数据

以下列出的方法旨在获取矩阵和向量的描述性统计信息。所有这些方法既可以应用于整个向量或矩阵,也可以应用于给定矩阵的轴(水平或垂直方向)。当完全应用于一个对象时,这些函数会返回一个标量(单个值)。当沿着矩阵的任意一个轴应用时,则会返回一个向量。

方法原型的一般形式如下:

T vector<T>::Method(const vector<T> &v)
T matrix<T>::Method(const matrix<T> &m)
vector<T> matrix<T>::Method(const matrix<T> &m, const int axis)

方法列表:

  • ArgMaxArgMin:查找最大值和最小值的索引
  • MaxMin:查找最大值和最小值
  • Ptp:查找数值范围
  • SumProd:计算元素的总和或乘积
  • CumSumCumProd:计算元素的累积总和或乘积
  • MedianMeanAverage:计算中位数、算术平均值或加权算术平均值
  • StdVar:计算标准差和方差
  • PercentileQuantile:计算百分位数和分位数
  • RegressionMetric:计算预定义的回归度量之一,例如矩阵/向量数据上与回归线的偏差误差

MatrixStdPercentile.mq5文件中给出了一个计算当前交易品种和时间框架内柱线范围(以点数为单位)的标准差和百分位数的示例。

cpp
input int BarCount = 1000;
input int BarOffset = 0;

void OnStart()
{
   // 获取当前图表的报价
   matrix rates;
   rates.CopyRates(_Symbol, _Period, COPY_RATES_OPEN | COPY_RATES_CLOSE, 
      BarOffset, BarCount);
   // 计算柱线上的价格增量
   vector delta = MathRound((rates.Row(1) - rates.Row(0)) / _Point);
   // 调试打印初始柱线数据
   rates.Resize(rates.Rows(), 10);
   Normalize(rates);
   Print(rates);
   // 打印增量度量
   PRTF((int)delta.Std());
   PRTF((int)delta.Percentile(90));
   PRTF((int)delta.Percentile(10));
}

日志输出:

(EURUSD,H1)        [[1.00832,1.00808,1.00901,1.00887,1.00728,1.00577,1.00485,1.00652,1.00538,1.00409]
(EURUSD,H1)         [1.00808,1.00901,1.00887,1.00728,1.00577,1.00485,1.00655,1.00537,1.00412,1.00372]]
(EURUSD,H1)        (int)delta.Std()=163 / ok
(EURUSD,H1)        (int)delta.Percentile(90)=170 / ok
(EURUSD,H1)        (int)delta.Percentile(10)=-161 / ok

矩阵和向量的特征

以下这组方法可用于获取矩阵的主要特征:

  • RowsCols:矩阵的行数和列数
  • Norm:预定义的矩阵范数之一(ENUM_MATRIX_NORM
  • Cond:矩阵的条件数
  • Det:非退化方阵的行列式
  • SLogDet:计算矩阵行列式的符号和对数
  • Rank:矩阵的秩
  • Trace:矩阵主对角线元素之和(迹)
  • Spectrum:矩阵的谱,即其特征值的集合

此外,向量也定义了以下特征:

  • Size:向量的长度
  • Norm:预定义的向量范数之一(ENUM_VECTOR_NORM

对象的大小(以及其中元素的索引)使用ulong类型的值。

ulong matrix<T>::Rows()
ulong matrix<T>::Cols()
ulong vector<T>::Size()

其他大多数特征是实数。

double vector<T>::Norm(const ENUM_VECTOR_NORM norm, const int norm_p = 2)
double matrix<T>::Norm(const ENUM_MATRIX_NORM norm)
double matrix<T>::Cond(const ENUM_MATRIX_NORM norm)
double matrix<T>::Det()
double matrix<T>::SLogDet(int &sign)
double matrix<T>::Trace()

秩和谱分别是整数和向量。

int matrix<T>::Rank()
vector matrix<T>::Spectrum()

矩阵秩计算示例

cpp
matrix a = matrix::Eye(4, 4);
Print("matrix a (eye)\n", a);
Print("a.Rank()=", a.Rank());

a[3, 3] = 0;
Print("matrix a (defective eye)\n", a);
Print("a.Rank()=", a.Rank());

matrix b = matrix::Ones(1, 4);
Print("b \n", b);
Print("b.Rank()=", b.Rank());

matrix zeros = matrix::Zeros(4, 1);
Print("zeros \n", zeros);
Print("zeros.Rank()=", zeros.Rank());

脚本执行结果

matrix a (eye)
[[1,0,0,0]
 [0,1,0,0]
 [0,0,1,0]
 [0,0,0,1]]
a.Rank()=4

matrix a (defective eye)
[[1,0,0,0]
 [0,1,0,0]
 [0,0,1,0]
 [0,0,0,0]]
a.Rank()=3

b
[[1,1,1,1]]
b.Rank()=1

zeros
[[0]
 [0]
 [0]
 [0]]
zeros.Rank()=0

方程求解

在机器学习方法和优化问题中,常常需要找到线性方程组的解。MQL5 包含四种方法,可根据矩阵类型来求解此类方程。

  • Solve:求解线性矩阵方程或线性代数方程组
  • LstSq:近似求解线性代数方程组(适用于非方阵或退化矩阵)
  • Inv:使用乔丹 - 高斯方法计算非奇异方阵的乘法逆矩阵
  • PInv:通过摩尔 - 彭罗斯方法计算伪逆矩阵

以下是这些方法的原型:

vector<T> matrix<T>::Solve(const vector<T> b)
vector<T> matrix<T>::LstSq(const vector<T> b)
matrix<T> matrix<T>::Inv()
matrix<T> matrix<T>::PInv()

SolveLstSq 方法用于求解形如 A*X = B 的方程组,其中 A 是矩阵,B 是通过参数传递的函数值(或 “因变量”)的向量。

让我们尝试应用 LstSq 方法来求解一个方程组,该方程组是理想投资组合交易的模型(在我们的例子中,我们将分析主要外汇货币的投资组合)。为此,在给定数量的 “历史” 柱线上,我们需要为每种货币找到这样的手数规模,使得资金余额线趋向于一条不断增长的直线。

我们将第 i 种货币对表示为 Si。其在索引为 k 的柱线上的报价等于 Si[k]。柱线的编号将从过去到未来,就像由 CopyRates 方法填充的矩阵和向量一样。因此,为训练模型而收集的报价的起始点对应于标记为数字 0 的柱线,但在时间轴上,它将是(根据算法设置,我们处理的那些柱线中)最古老的历史柱线。在它右边(未来方向)的柱线编号为 12 等等,一直到用户要求计算的柱线总数。

一个交易品种在第 0 根柱线和第 N 根柱线之间的价格变化决定了到第 N 根柱线时的利润(或亏损)。

考虑到一组货币,例如,我们得到第 1 根柱线的以下利润方程: (S1[1] - S1[0]) * X1 + (S2[1] - S2[0]) * X2 +... + (Sm[1] - Sm[0]) * Xm = B 这里 m 是交易品种的总数,Xi 是每个交易品种的手数规模,B 是浮动利润(如果锁定利润,则为条件余额)。

为了简化表示,让我们缩短符号。让我们从绝对值转换为价格增量(Ai [k] = Si [k] - Si [0])。考虑到柱线的变化,我们将得到虚拟余额曲线的几个表达式:

A1[1] * X1 + A2[1] * X2 +... + Am[1] * Xm = B[1]
A1[2] * X1 + A2[2] * X2 +... + Am[2] * Xm = B[2]
...
A1[K] * X1 + A2[K] * X2 +... + Am[K] * Xm = B[K]

成功的交易特征是每根柱线都有持续的利润,即右手边向量 B 的模型是一个单调递增的函数,理想情况下是一条直线。

让我们实现这个模型,并根据报价为其选择 X 系数。由于我们还不知道应用程序编程接口(API),我们不会编写一个完整的交易策略。我们只是使用标准头文件 Graphic.mqh 中的 GraphPlot 函数来构建一个虚拟余额图表(我们之前已经用它来演示数学函数)。

新示例的完整源代码在脚本 MatrixForexBasket.mq5 中。

在输入参数中,让用户选择数据采样的柱线总数(BarCount),以及在这个选择范围内的柱线编号(BarOffset),在该柱线处条件过去结束,条件未来开始。

将在条件过去上构建模型(将求解上述线性方程组),并在条件未来上进行前向测试。

cpp
input int BarCount = 20;  // BarCount (已知 "历史" 和 "未来")
input int BarOffset = 10; // BarOffset (where "未来" 开始)
input ENUM_CURVE_TYPE CurveType = CURVE_LINES;

为了用理想余额填充向量,我们编写 ConstantGrow 函数:稍后将在初始化期间使用它。

cpp
void ConstantGrow(vector &v)
{
   for(ulong i = 0; i < v.Size(); ++i)
   {
      v[i] = (double)(i + 1);
   }
}

交易工具列表(主要外汇货币对)在 OnStart 函数的开头硬编码设置 —— 可根据您的要求和交易环境进行编辑。

cpp
void OnStart()
{
   const string symbols[] =
   {
      "EURUSD", "GBPUSD", "USDJPY", "USDCAD", 
      "USDCHF", "AUDUSD", "NZDUSD"
   };
   const int size = ArraySize(symbols);
   ...

让我们创建将添加交易品种报价的 rates 矩阵、具有期望余额曲线的模型向量,以及用于逐个交易品种请求柱线收盘价的辅助 close 向量(它的数据将被复制到 rates 矩阵的列中)。

cpp
   matrix rates(BarCount, size);
   vector model(BarCount - BarOffset, ConstantGrow);
   vector close;

在交易品种循环中,我们将收盘价复制到 close 向量中,计算价格增量,并将它们写入 rates 矩阵的相应列中。

cpp
   for(int i = 0; i < size; i++)
   {
      if(close.CopyRates(symbols[i], _Period, COPY_RATES_CLOSE, 0, BarCount))
      {
         // 计算增量(所有柱线和每根柱线的利润,在一行中计算)
         close -= close[0];
         // 根据点值调整利润
         close *= SymbolInfoDouble(symbols[i], SYMBOL_TRADE_TICK_VALUE) /
            SymbolInfoDouble(symbols[i], SYMBOL_TRADE_TICK_SIZE);
         // 将向量放置在矩阵列中
         rates.Col(close, i);
      }
      else
      {
         Print("vector.CopyRates(%d, COPY_RATES_CLOSE) failed. Error ", 
            symbols[i], _LastError);
         return;
      }
   }
   ...

我们将在第 5 部分中考虑计算一个价格点的值(以存款货币为单位)。

同样重要的是要注意,不同金融工具上具有相同索引的柱线可能具有不同的时间戳,例如,如果其中一个国家有假期且市场关闭(在外汇市场之外,理论上交易品种可能有不同的交易时段安排)。为了解决这个问题,我们需要在将报价插入 rates 矩阵之前,更深入地分析报价,考虑柱线时间并对其进行同步。为了保持简单,我们在这里不这样做,而且还因为外汇市场在大多数时候都按照相同的规则运行。

我们将矩阵分成两部分:初始部分将用于找到解(这模拟了对历史数据的优化),后续部分将用于前向测试(计算后续余额变化)。

cpp
   matrix split[];
   if(BarOffset > 0)
   {
      // 在 BarCount - BarOffset 根柱线上进行训练
      // 在 BarOffset 根柱线上进行检查
      ulong parts[] = {BarCount - BarOffset, BarOffset};
      rates.Split(parts, 0, split);
   }
  
   // 为模型求解线性方程组
   vector x = (BarOffset > 0) ? split[0].LstSq(model) : rates.LstSq(model);
   Print("Solution (lots per symbol): ");
   Print(x);
   ...

现在,当我们有了解之后,让我们为样本的所有柱线构建余额曲线(理想的 “历史” 部分将在开头,然后是 “未来” 部分,这部分未用于调整模型)。

cpp
   vector balance = vector::Zeros(BarCount);
   for(int i = 1; i < BarCount; ++i)
   {
      balance[i] = 0;
      for(int j = 0; j < size; ++j)
      {
         balance[i] += (float)(rates[i][j] * x[j]);
      }
   }
   ...

让我们通过 R2 准则来评估解的质量。

cpp
   if(BarOffset > 0)
   {
      // 复制余额
      vector backtest = balance;
      // 仅选择 “历史” 柱线进行回测
      backtest.Resize(BarCount - BarOffset);
      // 前向测试的柱线必须手动复制
      vector forward(BarOffset);
      for(int i = 0; i < BarOffset; ++i)
      {
         forward[i] = balance[BarCount - BarOffset + i];
      }
      // 分别为两部分计算回归度量
      Print("Backtest R2 = ", backtest.RegressionMetric(REGRESSION_R2));
      Print("Forward R2 = ", forward.RegressionMetric(REGRESSION_R2));
   }
   else
   {
      Print("R2 = ", balance.RegressionMetric(REGRESSION_R2));
   }
   ...

为了在图表上显示余额曲线,需要将数据从向量传输到数组。

cpp
   double array[];
   balance.Swap(array);
   
   // 以 2 位精度打印变化余额的值
   Print("Balance: ");
   ArrayPrint(array, 2);
  
   // 在图表对象中绘制余额曲线(“回测” 和 “前向测试”)
   GraphPlot(array, CurveType);
}

以下是在 EURUSD,H1 上运行脚本得到的日志示例。

Solution (lots per symbol): 
[-0.0057809334,-0.0079846876,0.0088985749,-0.0041461736,-0.010710154,-0.0025694175,0.01493552]
Backtest R2 = 0.9896645616246145
Forward R2 = 0.8667852183780984
Balance: 
 0.00  1.68  3.38  3.90  5.04  5.92  7.09  7.86  9.17  9.88 
 9.55 10.77 12.06 13.67 15.35 15.89 16.28 15.91 16.85 16.58

以下是虚拟余额曲线的样子。

Virtual balance of trading in a basket of currencies by lots according to the decision of the SLAU(where "future" starts)

Virtual balance of trading a portfolio of currencies by lots according to the  decision

左边一半的形状更平滑,R2 值更高,这并不奇怪,因为模型(X 变量)是专门为它调整的。

只是出于好奇,我们将训练和验证的深度增加 10 倍,即我们在参数中设置 BarCount = 200BarOffset = 100。我们将得到一个新的图像。

Virtual balance of trading in a basket of currencies by lots according to the decision of the SLAU(where "future" starts)

Virtual balance of trading a portfolio of currencies by lots according to the decision

“未来” 部分看起来不那么平滑,甚至可以说,尽管模型如此简单,但它继续增长是很幸运的。通常,在前向测试期间,虚拟余额曲线会显著恶化并开始下降。

重要的是要注意,为了测试模型,我们直接采用了从方程组解中得到的 X 值,而在实践中,我们需要将它们归一化到最小手数和手数步长,这将对结果产生负面影响,并使结果更接近现实。

机器学习方法

在矩阵和向量的内置方法中,有几种在机器学习任务中,特别是在神经网络的实现中很有需求。

顾名思义,神经网络是许多神经元的集合,神经元是基本的计算单元。它们之所以被称为基本,是因为它们执行相当简单的计算:通常情况下,一个神经元有一组权重系数,这些系数应用于特定的输入信号,然后信号的加权和被输入到一个函数中,该函数是一个非线性转换器。

激活函数的使用会放大弱信号,并限制过强的信号,防止进入饱和状态(实际计算的溢出)。然而,最重要的是,非线性赋予了网络新的计算能力,使其能够解决更复杂的问题。

基本神经网络

神经网络的强大之处在于将大量神经元组合起来,并在它们之间建立连接。通常,神经元被组织成层(可以与矩阵或向量相类比),包括具有递归(循环)连接的层,并且还可以具有效果不同的激活函数。这使得可以使用各种算法来分析体积数据,特别是通过在其中找到隐藏的模式。

请注意,如果每个神经元中没有非线性,多层神经网络可以等效地表示为单层,其系数通过所有层的矩阵乘积得到(Wtotal = W1 * W2 *... * WL,其中 1..L 是层的编号)。而这将只是一个简单的线性加法器。因此,激活函数的重要性在数学上是有依据的。

一些最著名的激活函数

神经网络的主要分类之一是根据所使用的学习算法将其分为有监督学习网络和无监督学习网络。有监督学习网络需要人类专家为原始数据集提供期望的输出(例如,交易系统状态的离散标记,或隐含价格增量的数值指标)。无监督网络则自行识别数据中的聚类。

无论如何,训练神经网络的任务是找到使训练样本和测试样本上的误差最小化的参数,为此要使用损失函数:它对目标值和网络接收到的响应之间的误差进行定性或定量估计。

成功应用神经网络的最重要方面包括选择有信息价值且相互独立的预测变量(分析特征)、根据学习算法的特点进行数据转换(归一化和清理),以及网络架构和规模的优化。请注意,使用机器学习算法并不能保证成功。

在这里,我们不会深入探讨神经网络的理论、它们的分类以及要解决的典型任务。这个主题太宽泛了。感兴趣的人可以在 mql5.com 网站和其他来源找到相关文章。

MQL5 提供了三种已成为矩阵和向量 API 一部分的机器学习方法。

  • Activation:计算激活函数的值。
  • Derivative:计算激活函数的导数的值。
  • Loss:计算损失函数的值。

激活函数的导数能够根据学习过程中变化的模型误差有效地更新模型参数。

前两种方法将结果写入传入的向量/矩阵,并返回一个成功指示符(真或假),而损失函数返回一个数字。下面给出它们的原型(在对象<T>类型下,我们同时标记了矩阵<T>和向量<T>):

c++
bool object<T>::Activation(object<T> &out, ENUM_ACTIVATION_FUNCTION activation)
bool object<T>::Derivative(object<T> &out, ENUM_ACTIVATION_FUNCTION loss)
T object<T>::Loss(const object<T> &target, ENUM_LOSS_FUNCTION loss)

一些激活函数允许使用第三个可选参数来设置一个参数。

有关 ENUM_ACTIVATION_FUNCTION 枚举中支持的激活函数列表以及 ENUM_LOSS_FUNCTION 枚举中支持的损失函数列表,请参阅 MQL5 文档。

作为一个入门示例,让我们考虑分析实际的报价流数据的问题。一些交易员认为报价数据是垃圾噪声,而另一些人则实践基于报价数据的高频交易。有一种假设认为,高频算法通常会给大玩家带来优势,并且仅仅基于对价格信息的软件处理。基于此,我们将提出一个假设,即由于做市商当前活跃的机器人,报价流中存在短期记忆效应。然后,可以使用机器学习方法来找到这种依赖关系,并预测几个未来的报价数据。

机器学习总是涉及提出假设、为这些假设合成一个模型,并在实践中对其进行测试。显然,并非总是能得出有成效的假设。这是一个漫长的试错过程,在这个过程中,失败是改进和产生新想法的源泉。

我们将使用最简单的神经网络类型之一:双向联想记忆(BAM)。这样的网络只有两层:输入层和输出层。对于输入信号,在输出层会形成某种响应(联想)。层的大小可以不同。当大小相同时,得到的就是一个霍普菲尔德网络。

全连接双向联想记忆

使用这样的网络,我们将比较最近的 N 个过去的报价数据和 M 个接下来预测的报价数据,从最近的过去到给定的深度形成一个训练样本。报价数据将以转换为二进制值 [+1, -1] 的正或负价格增量的形式输入到网络中(二进制信号是 BAM 和霍普菲尔德网络中的标准编码形式)。

BAM 的最重要优点是学习过程几乎是瞬时的(与大多数其他迭代方法相比),该学习过程包括计算权重矩阵。下面我们给出公式。

然而,这种简单性也有缺点:BAM 的容量(它可以记住的图像数量)在训练样本向量中满足 +1 和 -1 的特殊分布条件的情况下,被限制为最小的层大小。

因此,对于我们的情况,网络将对训练样本中的所有报价序列进行泛化,然后在正常工作过程中,根据呈现的新报价序列,它将归结为一个或另一个存储的图像。在实践中效果如何取决于非常多的因素,包括网络的大小和设置、当前报价流的特征等等。

由于假设报价流只有短期记忆,所以最好实时或接近实时地重新训练网络,因为训练实际上可以简化为几个矩阵操作。

所以,为了让网络记住联想图像(在我们的例子中,是报价流的过去和未来),需要以下等式:

c++
W = Σi(AiTBi)

其中 W 是网络的权重矩阵。求和是对所有输入向量 Ai 和相应的输出向量 Bi 的成对乘积进行的。

然后,当网络运行时,我们将输入图像输入到第一层,将 W 矩阵应用于它,从而激活第二层,在第二层中计算每个神经元的激活函数。之后,使用转置后的 W T 矩阵,信号反向传播到第一层,在第一层的神经元中也应用激活函数。此时,输入图像不再到达第一层,即网络中的自由振荡过程继续。这个过程会一直持续到网络神经元的信号变化稳定下来(即变得小于某个预定值)。

在这种状态下,网络的第二层包含找到的关联输出图像——预测结果。

让我们在脚本 MatrixMachineLearning.mq5 中实现这个机器学习场景。

在输入参数中,可以设置从历史数据中请求的最后报价数据的总数(TicksToLoad),以及其中用于测试的数量(TicksToTest)。相应地,模型(权重)将基于(TicksToLoad - TicksToTest)个报价数据。

c++
input int TicksToLoad = 100;
input int TicksToTest = 50;
input int PredictorSize = 20;
input int ForecastSize = 10;

此外,在输入变量中,选择输入向量的大小(已知报价数据的数量 PredictorSize)和输出向量的大小(未来报价数据的数量 ForecastSize)。

在 OnStart 函数的开头请求报价数据。在这种情况下,我们只处理卖价(Ask 价格)。不过,你也可以添加买价(Bid)、最新价(Last)以及成交量的处理。

c++
void OnStart()
{
   vector ticks;
   ticks.CopyTicks(_Symbol, COPY_TICKS_ALL | COPY_TICKS_ASK, 0, TicksToLoad);
   ...

让我们将报价数据分割为训练集和测试集。

c++
   vector ask1(n - TicksToTest);
   for(int i = 0; i < n - TicksToTest; ++i)
   {
      ask1[i] = ticks[i];
   }
   
   vector ask2(TicksToTest);
   for(int i = 0; i < TicksToTest; ++i)
   {
      ask2[i] = ticks[i + TicksToLoad - TicksToTest];
   }
   ...

为了计算价格增量,我们使用带有额外向量 {+1, -1} 的 Convolve 方法。请注意,带有增量的向量将比原始向量短 1 个元素。

c++
   vector differentiator = {+1, -1};
   vector deltas = ask1.Convolve(differentiator, VECTOR_CONVOLVE_VALID);
   ...

根据 VECTOR_CONVOLVE_VALID 算法进行卷积意味着只考虑向量的完全重叠(即较小的向量沿着较大的向量依次移动,且不超出其边界)。其他类型的卷积允许向量仅重叠一个元素,或元素的一半(在这种情况下,其余元素超出相应向量,并且卷积值显示边界效应)。

为了将增量的连续值转换为单位脉冲(根据向量初始元素的符号为正或负),我们将使用一个辅助函数 Binary(此处未显示):它返回一个新的向量副本,其中每个元素要么是 +1 要么是 -1。

c++
   vector inputs = Binary(deltas);

基于接收到的输入序列,我们使用 TrainWeights 函数来计算 W 神经网络权重矩阵。我们稍后会考虑这个函数的结构。目前,请注意将 PredictorSize 和 ForecastSize 参数传递给它,这使得可以根据 BAM 输入层和输出层的大小,将连续的报价序列分割为成对的输入和输出向量集。

c++
   matrix W = TrainWeights(inputs, PredictorSize, ForecastSize);
   Print("Check training on backtest: ");   
   CheckWeights(W, inputs);
   ...

在训练完网络后,我们立即在训练集上检查它的准确性:只是为了确保网络已经被训练。这是通过 CheckWeights 函数实现的。

然而,更重要的是检查网络在不熟悉的测试数据上的表现。为此,让我们对第二个向量 ask2 进行求导和二值化,然后也将其发送到 CheckWeights 函数。

c++
   vector test = Binary(ask2.Convolve(differentiator, VECTOR_CONVOLVE_VALID));
   Print("Check training on forwardtest: ");   
   CheckWeights(W, test);
   ...
}

现在是时候来了解一下 TrainWeights 函数了,在这个函数中,我们定义矩阵 A 和矩阵 B,以便从传入的输入序列(也就是从数据向量)中“切分”出向量。

c++
template<typename T>
matrix<T> TrainWeights(const vector<T> &data, const uint predictor, const uint responce, 
   const uint start = 0, const uint _stop = 0, const uint step = 1)
{
   const uint sample = predictor + responce;
   const uint stop = _stop <= start? (uint)data.Size() : _stop;
   const uint n = (stop - sample + 1 - start) / step;
   matrix<T> A(n, predictor), B(n, responce);
   
   ulong k = 0;
   for(ulong i = start; i < stop - sample + 1; i += step, ++k)
   {
      for(ulong j = 0; j < predictor; ++j)
      {
         A[k][j] = data[start + i * step + j];
      }
      for(ulong j = 0; j < responce; ++j)
      {
         B[k][j] = data[start + i * step + j + predictor];
      }
   }
   ...

每一个连续的 A 模式是从数量等于 predictor 的连续报价数据中获取的,与之对应的未来模式则是从接下来的响应元素中获取的。只要数据总量允许,这个窗口就会向右移动,每次移动一个元素,从而形成更多新的图像对。图像按行编号,其中的报价数据按列编号。

接下来,我们应该为权重矩阵 W 分配内存,并使用矩阵方法来填充它:我们使用 Outer 方法依次将矩阵 A 和矩阵 B 的行相乘,然后进行矩阵求和。

c++
   matrix<T> W = matrix<T>::Zeros(predictor, responce);
   
   for(ulong i = 0; i < k; ++i)
   {
      W += A.Row(i).Outer(B.Row(i));
   }
   
   return W;
}

CheckWeights 函数对神经网络执行类似的操作,神经网络的权重系数以现成的形式在第一个参数 W 中传入。训练向量的大小是从 W 矩阵本身提取的。

c++
template<typename T>
void CheckWeights(const matrix<T> &W, 
   const vector<T> &data, 
   const uint start = 0, const uint _stop = 0, const uint step = 1)
{
   const uint predictor = (uint)W.Rows();
   const uint responce = (uint)W.Cols();
   const uint sample = predictor + responce;
   const uint stop = _stop <= start? (uint)data.Size() : _stop;
   const uint n = (stop - sample + 1 - start) / step;
   matrix<T> A(n, predictor), B(n, responce);
   
   ulong k = 0;
   for(ulong i = start; i < stop - sample + 1; i += step, ++k)
   {
      for(ulong j = 0; j < predictor; ++j)
      {
         A[k][j] = data[start + i * step + j];
      }
      for(ulong j = 0; j < responce; ++j)
      {
         B[k][j] = data[start + i * step + j + predictor];
      }
   }
   
   const matrix<T> w = W.Transpose();
   ...

在这种情况下,矩阵 A 和矩阵 B 不是为了计算 W 而形成的,而是作为用于测试的向量“供应者”。我们还需要 W 的转置副本,以便计算从网络第二层返回第一层的信号。

在网络中允许暂态过程进行直到收敛的迭代次数,受到极限常量的限制。

c++
   const uint limit = 100;
   
   int positive = 0;
   int negative = 0;
   int average = 0;

变量 positive、negative 和 average 用于计算成功和失败预测的统计数据,以便评估训练质量。

接下来,在测试模式对的循环中激活网络,并获取其最终响应。每个下一个输入向量被写入向量 a 中,输出层 b 用零填充。然后,使用矩阵 W 并应用激活函数 AF_TANH 来启动从 a 到 b 的信号传输迭代,以及从 b 到 a 的反馈信号迭代,同样也使用 AF_TANH 激活函数。这个过程会一直持续,直到达到极限循环次数(这种情况不太可能发生),或者直到满足收敛条件,在收敛条件下,a 和 b 神经元状态向量实际上不再变化(这里我们使用 Compare 方法以及上一次迭代中 x 和 y 向量的辅助副本)。

c++
   for(ulong i = 0; i < k; ++i)
   {
      vector a = A.Row(i);
      vector b = vector::Zeros(responce);
      vector x, y;
      uint j = 0;
      
      for( ; j < limit; ++j)
      {
         x = a;
         y = b;
         a.MatMul(W).Activation(b, AF_TANH);
         b.MatMul(w).Activation(a, AF_TANH);
         if(!a.Compare(x, 0.00001) && !b.Compare(y, 0.00001)) break;
      }
      
      Binarize(a);
      Binarize(b);
      ...

在达到稳定状态后,我们使用 Binarize 函数将神经元的状态从连续(实值)转换为二进制的 +1 和 -1(它与前面提到的 Binary 函数类似,但会就地改变向量的状态)。

现在,我们只需要计算输出层与目标向量的匹配数量。为此,进行向量的标量乘法。正结果意味着正确猜测的报价数量超过了错误的数量。总命中数累加到“average”中。

c++
      const int match = (int)(b.Dot(B.Row(i)));
      if(match > 0) positive++;
      else if(match < 0) negative++;
      
      average += match; // 0 in match means 50/50 precision (i.e. random guessing)
   }

在对所有测试样本完成循环后,我们显示统计数据。

c++
   float skew = (float)average / k; // average number of matches per vector
   
   PrintFormat("Count=%d Positive=%d Negative=%d Accuracy=%.2f%%", 
      k, positive, negative, ((skew + responce) / 2 / responce) * 100);
}

该脚本还包括 RunWeights 函数,它表示对于来自最后 predictor 个报价数据的在线向量,通过神经网络的权重矩阵 W 来运行该神经网络。该函数将返回一个包含估计的未来报价数据的向量。

c++
template<typename T>
vector<T> RunWeights(const matrix<T> &W, const vector<T> &data)
{
   const uint predictor = (uint)W.Rows();
   const uint responce = (uint)W.Cols();
   vector a = data;
   vector b = vector::Zeros(responce);
   
   vector x, y;
   uint j = 0;
   const uint limit = LIMIT;
   const matrix<T> w = W.Transpose();
   
   for( ; j < limit; ++j)
   {
      x = a;
      y = b;
      a.MatMul(W).Activation(b, AF_TANH);
      b.MatMul(w).Activation(a, AF_TANH);
      if(!a.Compare(x, 0.00001) && !b.Compare(y, 0.00001)) break;
   }
   
   Binarize(b);
   
   return b;
}

在 OnStart 函数的末尾,我们暂停执行 1 秒钟(以便有一定概率等待新的报价数据),请求最后 PredictorSize + 1 个报价数据(不要忘记加上 +1 用于求差分),并对它们进行在线预测。

c++
void OnStart()
{
   ...
   Sleep(1000);
   vector ask3;
   ask3.CopyTicks(_Symbol, COPY_TICKS_ALL | COPY_TICKS_ASK, 0, PredictorSize + 1);
   vector online = Binary(ask3.Convolve(differentiator, VECTOR_CONVOLVE_VALID));
   Print("Online: ", online);
   vector forecast = RunWeights(W, online);
   Print("Forecast: ", forecast);
}

在周五晚上,使用默认设置在欧元兑美元(EURUSD)上运行该脚本得到了以下结果。

回测训练检查: Count=20 Positive=20 Negative=0 Accuracy=85.50% 前向测试训练检查: Count=20 Positive=12 Negative=2 Accuracy=58.50% 在线数据: [1,1,1,1,-1,-1,-1,1,-1,1,1,-1,1,1,-1,-1,1,1,-1,-1] 预测结果: [-1,1,-1,1,-1,-1,1,1,-1,1]

这里没有提及交易品种和时间,因为市场情况会显著影响算法的适用性以及特定的网络配置。当市场开盘时,每次运行脚本,随着越来越多的报价数据进入,你都会得到新的结果。这是与短期记忆形成假设一致的预期行为。

正如我们所看到的,训练精度是可以接受的,但在测试数据上明显下降,并且可能会低于 50%。

此时,我们从编程领域顺利过渡到了科学研究领域。MQL5 中内置的机器学习工具包允许你实现许多其他配置的神经网络和分析器,采用不同的交易策略以及准备初始数据的原则。