MQL5 自优化 EA(第12部分):利用矩阵分解构建线性分类器
矩阵分解对想要开发数值驱动型程序的算法交易者而言,是一项重要工具。借助这些工具,我们可以搭建各类机器学习算法,以及更多其他模型。到目前为止的讨论中,我们只研究了回归任务。接下来我们把目光转向分类问题。本次我们将尝试构建一个市场分类器。该分类器能够区分市场上涨与下跌行情。我们希望借助它正确地执行交易。分类器的任务是从历史市场行情数据中学习,推断出在特定交易日应当采取的正确操作。
我们的交易策略逻辑如下:策略目标是根据移动平均线指标的预期走势预判市场行情。除此之外,我们要求价格走势与移动平均线方向一致。也就是说:如果分类模型预测移动平均线将下行,我们希望看到价格进一步跌破并运行在该指标下方。若预判移动平均线与价格同步下跌,我们就开空单。移动平均线用于指示价格方向,但在开仓前,我们还希望价格加速突破该指标并进一步远离它。
做多逻辑同理。我们预判均线上行,且价格要大幅向上突破均线,此时才进场强势做多。
从上面的描述能看出,模型会同时输出两个分类结果。但不要把它和多分类模型混淆。模型预测的这两个变量,各自都是二分类结果。换句话说,模型同时处理两组独立的二分类任务。这里介绍的架构并不适用于单次执行多类别分类。

图 1:交易策略运行可视化
用矩阵分解构建线性分类器
和我们大多数交易程序一样,先定义系统参数。在本例中,输入数据矩阵 X 共包含 6 个输入特征。
//+------------------------------------------------------------------+ //| Linear Regression.mq5 | //| Gamuchirai Ndawana | //| https://www.mql5.com/en/users/gamuchiraindawa | //+------------------------------------------------------------------+ #property copyright "Gamuchirai Ndawana" #property link "https://www.mql5.com/en/users/gamuchiraindawa" #property version "1.00" //+------------------------------------------------------------------+ //| System constants | //+------------------------------------------------------------------+ #define TOTAL_INPUTS 6
继续往下,定义完系统常量后,我们设置可供终端用户调整的输入参数。具体来说,用户可以自行选择合适的时间周期以及止损大小。
//+------------------------------------------------------------------+ //| System Inputs | //+------------------------------------------------------------------+ int bars = 200;//Number of historical bars to fetch int horizon = 1;//How far into the future should we forecast int MA_PERIOD = 50; //Moving average period ENUM_TIMEFRAMES TIME_FRAME = PERIOD_D1;//User Time Frame input ENUM_TIMEFRAMES RISK_TIME_FRAME = PERIOD_D1; input double sl_size = 2;
此外,程序需要引入必要的系统库,包括交易库,以及我编写的两个自定义库,分别用于时间处理和获取交易关键信息。
//+------------------------------------------------------------------+ //| Dependencies | //+------------------------------------------------------------------+ #include <Trade\Trade.mqh> #include <VolatilityDoctor\Time\Time.mqh> #include <VolatilityDoctor\Trade\TradeInfo.mqh>
接下来,程序还需要为本案例创建关键全局变量。本例中,全局变量将作为技术指标的句柄。我们需要全局变量,用来保存从市场获取的输入数据与输出数据。同时还需要指标缓冲区,用来读取技术指标的实时数值。
//+------------------------------------------------------------------+ //| Global Variables | //+------------------------------------------------------------------+ int ma_close_handler; double ma_close[]; Time *Timer; TradeInfo *TradeInformation; vector bias,temp,Z1,Z2; matrix X,y,prediction,b; int time; CTrade Trade; int state; int atr_handler; double atr[];
当系统首次加载时,会调用一个专门的 initialize 方法,完成所有系统变量的初始化工作。
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { //--- initialize(); //--- return(INIT_SUCCEEDED); }
当系统不再使用时,我们需要释放技术指标所占用的内存。
//+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { //--- IndicatorRelease(atr_handler); IndicatorRelease(ma_close_handler); }
每当新的 K 线形成时,程序会调用两个专用方法。第一个方法是 setup 方法,第二个是 find_setup 方法。后续章节我们会详细讲解这两个函数。
//+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick() { //--- if(Timer.NewCandle()) { setup(); find_setup(); } }
初始化各类对象与指标句柄,并将它们赋给相应的全局标识符。举个例子:我们会动态创建时间库对象,用来跟踪 K 线生成状态。同时创建交易信息类对象。该类负责记录各类关键信息,例如当前品种允许的最小交易手数、当前卖价和买价。除此之外,initialize 函数还负责为矩阵及相关标识符加载全部默认值。
//+------------------------------------------------------------------+ //| Initialize our system variables | //+------------------------------------------------------------------+ void initialize(void) { Timer = new Time(Symbol(),TIME_FRAME); TradeInformation = new TradeInfo(Symbol(),TIME_FRAME); ma_close_handler = iMA(Symbol(),TIME_FRAME,MA_PERIOD,0,MODE_SMA,PRICE_CLOSE); atr_handler = iATR(Symbol(),RISK_TIME_FRAME,14); bias = vector::Ones(TOTAL_INPUTS); Z1 = vector::Ones(TOTAL_INPUTS); Z2 = vector::Ones(TOTAL_INPUTS); X = matrix::Ones(TOTAL_INPUTS,bars); y = matrix::Ones(1,bars); time = 0; state = 0; }
find_setup () 函数,相比前面介绍的其他函数逻辑要复杂很多。首先,findSetup 函数记录当前收盘价,接着复制指标的实时数值。也就是把 ATR 与移动平均线的读数拷贝到对应的缓冲区中。接下来,如果当前没有持仓,就重置系统状态。之后读取线性分类器给出的预测结果。
回顾一下:我们的分类器会同时输出两个预测值。第一个预测值代表移动平均线未来预期运行方向;第二个预测值反映价格相对移动平均线的位置关系。我们要求这两组预测结果保持一致。如果预测均线将会下行,同时希望价格进一步跌到均线下方;如果预测均线将会上行,则希望价格上涨至均线上方。
这两组判定条件构成本交易策略的核心。做多条件:两个预测值同时大于 0.5。做空条件:两个预测值同时小于 0.5。其他情况:如果已有持仓,则持续监控持仓状态与线性分类器的预期。若当前持有空单,但模型预判均线即将上行,则平仓。做多持仓的平仓逻辑与之相反。
其余场景,若行情条件没有触发平仓,我们会把止损调整到更有利的位置,继续等待后续行情。
//+------------------------------------------------------------------+ //| Find a trading setup for our linear classifier model | //+------------------------------------------------------------------+ void find_setup(void) { double c = iClose(Symbol(),TIME_FRAME,0); CopyBuffer(atr_handler,0,0,1,atr); CopyBuffer(ma_close_handler,0,0,1,ma_close); if(PositionsTotal() == 0) { state = 0; if((prediction[0,0] > 0.5) && (prediction[1,0] > 0.5)) { Trade.Buy(TradeInformation.MinVolume(),Symbol(),TradeInformation.GetAsk(),(TradeInformation.GetBid() - (sl_size * atr[0])),0); state = 1; } if((prediction[0,0] < 0.5) && (prediction[1,0] < 0.5)) { Trade.Sell(TradeInformation.MinVolume(),Symbol(),TradeInformation.GetBid(),(TradeInformation.GetAsk() + (sl_size * atr[0])),0); state = -1; } } if(PositionsTotal() > 0) { if(((state == -1) && (prediction[0,0] > 0.5)) || ((state == 1)&&(prediction[0,0] < 0.5))) Trade.PositionClose(Symbol()); if(PositionSelect(Symbol())) { double current_sl = PositionGetDouble(POSITION_SL); if((state == 1) && ((ma_close[0] - (2 * atr[0]))>current_sl)) { Trade.PositionModify(Symbol(),(ma_close[0] - (2 * atr[0])),0); } else if((state == -1) && ((ma_close[0] + (2 * atr[0]))<current_sl)) { Trade.PositionModify(Symbol(),(ma_close[0] + (2 * atr[0])),0); } } } }
我们编写了专用方法,用来训练拟合线性分类器。如下所示的 fit 方法,用于拟合数据集,将历史价格输入映射到我们需要跟踪的两个目标输出。请记住,我们调用 OpenBLAS 库中的奇异值分解(SVD)算法,快速对模型做矩阵分解,以此求解最优系数,并将系数保存到变量 b 中。
//+------------------------------------------------------------------+ //| Fir our classification model | //+------------------------------------------------------------------+ void fit(void) { //--- Fit the model matrix OB_U,OB_VT,OB_SIGMA; vector OB_S; X.SingularValueDecompositionDC(SVDZ_S,OB_S,OB_U,OB_VT); OB_SIGMA.Diag(OB_S); b = y.MatMul(OB_VT.Transpose().MatMul(OB_SIGMA.Inv()).MatMul(OB_U.Transpose())); }
一如既往,数据读取与存储是所有机器学习模型的重要环节。我们会对每一项输入数据做标准化处理:减去均值,再除以标准差。
举例来说,我们需要提取开盘价、最高价、最低价以及技术指标数值。所有这些数据,在存入数据矩阵 X 之前,都要执行减均值、除以标准差的标准化操作。
在重塑输出矩阵 Y 之后,我们把输出标签设置为 0 或 1:0 代表价格下跌,1 代表价格上涨。通过这种方式,我们构建分类模型。
//+------------------------------------------------------------------+ //| Prepare the data needed for our classifier | //+------------------------------------------------------------------+ void fetch_data(void) { //--- Reshape the matrix X = matrix::Ones(TOTAL_INPUTS,bars); //--- Store the Z-scores temp.CopyRates(Symbol(),TIME_FRAME,COPY_RATES_OPEN,horizon,bars); Z1[0] = temp.Mean(); Z2[0] = temp.Std(); temp = ((temp - Z1[0]) / Z2[0]); X.Row(temp,1); //--- Store the Z-scores temp.CopyRates(Symbol(),TIME_FRAME,COPY_RATES_HIGH,horizon,bars); Z1[1] = temp.Mean(); Z2[1] = temp.Std(); temp = ((temp - Z1[1]) / Z2[1]); X.Row(temp,2); //--- Store the Z-scores temp.CopyRates(Symbol(),TIME_FRAME,COPY_RATES_LOW,horizon,bars); Z1[2] = temp.Mean(); Z2[2] = temp.Std(); temp = ((temp - Z1[2]) / Z2[2]); X.Row(temp,3); //--- Store the Z-scores temp.CopyRates(Symbol(),TIME_FRAME,COPY_RATES_CLOSE,horizon,bars); Z1[3] = temp.Mean(); Z2[3] = temp.Std(); temp = ((temp - Z1[3]) / Z2[3]); X.Row(temp,4); //--- Store the Z-scores temp.CopyIndicatorBuffer(ma_close_handler,0,horizon,bars); Z1[4] = temp.Mean(); Z2[4] = temp.Std(); temp = ((temp - Z1[4]) / Z2[4]); X.Row(temp,5); //--- Reshape the output target y.Reshape(2,bars); vector temp_2,temp_3,temp_4; //--- Prepare to label the target accordingly temp.CopyRates(Symbol(),TIME_FRAME,COPY_RATES_CLOSE,horizon,bars); temp_4.CopyRates(Symbol(),TIME_FRAME,COPY_RATES_CLOSE,0,bars); temp_2.CopyRates(Symbol(),TIME_FRAME,COPY_RATES_CLOSE,0,bars); temp_3.CopyIndicatorBuffer(ma_close_handler,0,0,bars); for(int i=0;i<bars;i++) { //--- Record if price levels appreciated or depreciated if(temp[i] > temp_4[i]) y[0,i] = 0; else if(temp[i] < temp_4[i]) y[0,i] = 1; //--- Record if price levels remained above the moving average indicator, or fell beneath it. if(temp_2[i] < temp_3[i]) y[1,i] = 0; if(temp_2[i] > temp_3[i]) y[1,i] = 1; } Print("Training Input Data: "); Print(X); Print("Training Target"); Print(y); }
完成数据准备后,我们就可以使用分类模型来生成预测。想要从分类模型获取预测结果,需要输入数据矩阵 X 的最后一行。也可以理解为读取当前市场行情、当前市场状态。我们把当前行情特征,乘以从历史数据训练得到的系数,就可以得到预测值。
//+------------------------------------------------------------------+ //| Obtain a prediction from our classification model | //+------------------------------------------------------------------+ void predict(void) { //--- Prepare to get a prediction //--- Reshape the data X = matrix::Ones(TOTAL_INPUTS,1); //--- Get a prediction temp.CopyRates(Symbol(),TIME_FRAME,COPY_RATES_OPEN,0,1); temp = ((temp - Z1[0]) / Z2[0]); X.Row(temp,1); temp.CopyRates(Symbol(),TIME_FRAME,COPY_RATES_HIGH,0,1); temp = ((temp - Z1[1]) / Z2[1]); X.Row(temp,2); temp.CopyRates(Symbol(),TIME_FRAME,COPY_RATES_LOW,0,1); temp = ((temp - Z1[2]) / Z2[2]); X.Row(temp,3); temp.CopyRates(Symbol(),TIME_FRAME,COPY_RATES_CLOSE,0,1); temp = ((temp - Z1[3]) / Z2[3]); X.Row(temp,4); temp.CopyIndicatorBuffer(ma_close_handler,0,0,1); temp = ((temp - Z1[4]) / Z2[4]); X.Row(temp,5); Print("Prediction Inputs: "); Print(X); //--- Get a prediction prediction = b.MatMul(X); Print("Prediction"); Print(prediction); }
如果逐个调用上述函数会比较繁琐。因此我们设计了专用的 setup 函数,按照预定顺序依次调用这三个方法。第一步读取数据,第二步用数据拟合模型,最后一步生成预测。
这个简洁的 setup 函数可以按正确顺序一次性完成这三步操作。
//+------------------------------------------------------------------+ //| Obtain a prediction from our model | //+------------------------------------------------------------------+ void setup(void) { fetch_data(); fit(); predict(); } //+------------------------------------------------------------------+
最后,我们需要取消程序开头定义的系统常量。
#undef TOTAL_INPUTS 回测从 2020 年 1 月开始,在日线周期上运行 5 年,直到 2025 年。回测品种选用欧元兑美元(EURUSD)。

图 2:选择回测与优化时间区间
除此之外,为了获取最优参数,我们启用随机延迟设置,保证回测环境尽可能贴近未来真实市场环境。

图 3:回测条件的选择同样很重要
我截取了 EA 回测运行时的性能截图。可以看到程序输出了传入模型的预测输入特征。前面提到,第一个输入固定为 1,代表截距项。但我们重点关注模型输出的实际预测结果。

图 4:线性分类器算法运行输出结果
如前文所述,模型会输出两组预测值。第一组预测均线未来位置的变化;第二组预测价格相对均线的变化。我们要求这两组变化方向保持一致。
我们还可以查看本交易策略生成的资金曲线。可以看到资金曲线整体向上,但稳定性不足。
读者需要留意,图中曲线包含两条线:蓝色线是账户余额曲线,绿色线是账户净值曲线。可以观察到多处净值(绿色线)大幅高于余额(蓝色线)。
以作者本人的经验来看,这种现象代表策略错失了部分利润、漏掉了交易信号。行情信号出现,但策略没有捕捉到,因此没能兑现这部分盈利。这说明策略仍有改进空间。

图 5:资金曲线表明还有部分行情信号未能被模型学习并捕捉
最后,我们来看完整的绩效统计数据。可以看到,平均盈利交易的收益大于平均亏损交易的损失。策略产生的最大盈利也高于最大亏损,这一点很鼓舞人心。
但观察盈利交易占比,胜率仅 43%,不足一半。这印证了前面看资金曲线时发现的问题:存在大量净值冲高但未能落袋的浮动盈利行情。
这进一步说明系统仍有优化空间,还有很多信息特征没有被模型提取。虽然目前成果是不错的起点,但仍有更多行情信号等待挖掘学习。

图 6:详细绩效统计佐证了前面的结论:系统尚有优化空间
结论
总而言之,本文向读者展示了矩阵分解在算法交易、MQL5 程序中的多种应用方式。在系列文章开篇,我们把矩阵分解当作回归建模工具。而本文,我们将其用于分类任务。
在这里我只向读者做出一个承诺:还有大量内容有待讲解。以上仅仅是基础内容。在作者看来,这些都是简单应用,可以快速展示矩阵分解的价值。如果你一直在跟进本系列文章,读完本篇,你已经掌握如何用矩阵分解搭建回归模型、分类模型,或是二者组合模型;这类模型不再局限于单次单目标预测,可以同时预测多个目标。
但我向读者保证,矩阵分解还有更多强大的特性没有介绍。在探索更多高级用法之前,我们必须先夯实基础。同时你也能看到,依托 MQL5 API 内置函数,把这套强大的矩阵算法集成到交易程序中并不复杂。
本文由MetaQuotes Ltd译自英文
原文地址: https://www.mql5.com/en/articles/18987
注意: MetaQuotes Ltd.将保留所有关于这些材料的权利。全部或部分复制或者转载这些材料将被禁止。
本文由网站的一位用户撰写,反映了他们的个人观点。MetaQuotes Ltd 不对所提供信息的准确性负责,也不对因使用所述解决方案、策略或建议而产生的任何后果负责。
决斗者算法
新手在交易中的10个基本错误
市场模拟:MQL5 中的 SQL 入门(一)