交易中的神经网络:基于 ResNeXt 模型的多任务学习(终篇)
概述
在前一篇文章中,我们熟悉了基于 ResNeXt 架构的多任务学习框架的理论层面,提议的框架出于构建金融市场分析系统。多任务学习(MTL)使用单个编码器处理输入数据,并使用多个专门的“头”(输出),每个都为解决特定任务而设计。该方式提供了许多优势。
其一,共享编码器的运用促进了从数据中提取最健壮、且通用的形态,在跨多种任务中这些都被证明很实用。不同于传统方法,每个模型都在独立的数据子集上训练,多任务架构形成的表示方式能够捕捉更基本的规律性。这令模型更具通用性,并更能抵抗原产数据中的噪声。
其二,多任务的联合训练降低了模型过度拟合的可能性。如果某个子任务遇到低品质、或信息量不足的数据,其它任务通过共享编码器结构来补偿这一影响。这提升了模型的稳定性和可靠性,尤其是在金融市场高度波动的情况下。
其三,该方式在计算资源方面更高效。替代训练多个独立模型执行相关功能,多任务学习能用单一编码器,减少计算冗余,并加快训练进程。这在算法交易中尤为重要,其中模型延迟是及时制定交易决策的高危症结。
在金融市场背景下,MTL 能同时分析多个市场因素,从而提供了额外益处。例如,模型能同时预测波动性、识别市场趋势、评估风险,并协同新闻背景。这些层面的相互依赖令多任务学习成为一款针对复杂市场系统建模、及更精准预测价格动态的强力工具。
多任务学习的一个关键优势是具备在不同子任务之间动态转移优先级的能力。这意味着模型能够适应市场环境的变化,更多专注于对当前价格波动影响最大的方面。
由框架作者选择的 ResNeXt 架构作为编码器的基础,其特征是模块化和高效。它使用了分组卷积,显著提升了模型性能,同时不会显著增加计算复杂度。这对于实时处理大量市场数据尤为重要。架构的灵活性还允许针对特定任务定制模型参数:可调节网络深度、卷积模块配置、及数据归一化方法,令系统能够适应不同的操作条件。
多任务学习与 ResNeXt 架构的结合,造就了强大的分析工具,具备高效整合和处理综合信息源的能力。该方式不仅提升了预测准确性,还令系统能够快速适应市场变化,揭示隐藏的依赖性和形态。自动提取重要特征令模型对非常态更具韧性,有助于把随机市场噪声的影响最小化。
在上一篇文章的实践部分,我们实证了实现 MQL5 版本 ResNeXt 架构关键组件的详情。在工作期间,创建了一个含有残差连接的分组卷积模块,已作为 CNeuronResNeXtBlock 对象实现。该方式确保了系统在处理金融数据时的高度灵活性、可扩展性、及效率。
在本次工作中,我们在创建编码器时不再作为一个单体对象。取而代之,用户可利用已实现的构建模块,自行搭建编码器架构。这不仅提供了更大的灵活性,还拓展了系统应对适配各种金融数据和交易策略的能力。如今,主要关注点将放在多任务学习框架内模型的开发和训练。
模型架构
在继续技术实现之前,有必要定义模型方案的关键任务。其一将扮演智代角色,负责生成交易操作的参数。它将产生类似于早前讨论过的架构交易参数。该方式有助于避免计算过度重复,提高预测的一致性,并奠定统一的决策制定策略。
然而,这样的结构并未充分发挥多任务学习的潜力。为达成预期效果,系统将添加一个模型,训练后来预测未来市场趋势。该预测模块将提升预测准确性,强化模型对突发市场变化的韧性。在市场高度波动条件下,该机制令模型能够快速适应新信息,制定更精准的交易决策。
将多个任务集成为单一模型,将创建一个综合的分析系统,具备实时参考众多市场因素、并与之互动的能力。该方式预计能提供更高的知识普适度,提升预测准确性,并将错误交易决策的风险最小化。
训练模型的架构在 CreateDescriptions 方法中定义。方法参数包括两个指向动态数组对象的指针,模型架构将写入其中。
bool CreateDescriptions(CArrayObj *&actor, CArrayObj *&probability) { //--- CLayerDescription *descr; //--- if(!actor) { actor = new CArrayObj(); if(!actor) return false; } if(!probability) { probability = new CArrayObj(); if(!probability) return false; }
一个关键的实现特点是创建了两个专门的模型:参与者模型和一个预测模型,负责估算即将到来的价格走势方向的概率。环境状态编码器直接集成到参与者架构当中,令其能形成丰富的市场数据表示,并捕捉复杂的依赖关系。第二个模型则从参与者潜在空间接收输入,利用其学到的表示生成更准确的预测。该方式不仅提升了预测效率,还降低了计算负荷,确保两个模型在统一系统内的协调运行。
在方法主体中,我们首先验证所接收指针的有效性,并在必要时创建动态数组对象的新实例。
接下来,我们继续构建参与者架构,从环境编码器开始。第一个组件是一个记录原产输入数据的基础神经层。该层的大小则据所分析数据的体量判定。
//--- Actor actor.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; int prev_count = descr.count = (HistoryBars * BarDescr); descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
未应用激活函数,因为本质上该层的输出缓冲区直接存储从环境中获得的原始数据。在我们的情况中,这些数据直接从终端接收,从而保留了它们的原始结构。然而,该方式存在一个明显瑕疵:缺乏预处理可能会对模型的可训练性产生负面影响,在于原产数据包含的异构值在尺度和分布上存在差异。
为缓解该问题,第一层后立即应用批处理归一化机制。它执行初步数据标准化,将输入纳入共用尺度,并提升可比性。这显著强化了训练稳定性,加速模型收敛,并降低梯度膨胀、或消散的风险。如是结果,即使在处理高度波动的市场数据时,模型也能获得形成更准确、更一致表示的能力,这对后续多任务分析至关重要。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBatchNormOCL; descr.count = prev_count; descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
接下来,我们用到一个卷积层来变换特征空间,将其带至标准化的维度。这令创建统一的数据表示成为可能,确保后续处理阶段的一致性。采用泄漏 ReLU(LReLU)激活函数,其有助于降低微小波动和随机噪声的影响,同时保留原始数据的重要特征。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; descr.count = HistoryBars; descr.window = BarDescr; descr.step = BarDescr; descr.window_out = 128; descr.activation = LReLU; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
初步数据预处理完毕后,我们继续设计环境状态编码器的架构,其在分析和解释原产输入数据时扮演关键角色。编码器的主要意图是识别所分析数据集合之中的稳定形态和隐藏结构,从而为决策制定模型后续处理形成富含信息的表示。
我们的编码器由三个顺序的 ResNeXt 架构模块构建,每个模块都使用分组卷积,以变高效提取特征。在每个模块中,应用一个卷积滤波器,窗口大小为所分析多维时间序列的 3 个元素,卷积步长为 2 个元素。这确保了原始序列的维数在每个模块中减半。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronResNeXtBlock; //--- Chanels { int temp[] = {128, 256}; //In, Out if(ArrayCopy(descr.windows, temp) < int(temp.Size())) return false; } //--- Units and Groups { int temp[] = {HistoryBars, 4, 32}; //Units, Group Size, Groups if(ArrayCopy(descr.units, temp) < int(temp.Size())) return false; } descr.window = 3; descr.step = 2; descr.window_out = 1; descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } int units_out = (descr.units[0] - descr.window + descr.step - 1) / descr.step + 1;
根据 ResNeXt 架构的原则,所分析多维时间序列维数降低后,会由特征维度按比例增加所补偿。该方式既保持了数据的信息丰度,又提供了时间序列结构特征的更详细表示。
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronResNeXtBlock; //--- Chanels { int temp[] = {256, 512}; //In, Out if(ArrayCopy(descr.windows, temp) < int(temp.Size())) return false; } //--- Units and Groups { int temp[] = {units_out, 4, 64}; //Units, Group Size, Groups if(ArrayCopy(descr.units, temp) < int(temp.Size())) return false; } descr.window = 3; descr.step = 2; descr.window_out = 1; descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } units_out = (descr.units[0] - descr.window + descr.step - 1) / descr.step + 1;
此外,随着特征空间维度的增加,我们按比例扩展卷积分组的数量,同时保持每个分组的大小不变。这令架构能够高效伸缩,在计算复杂度、与模型从数据中提取复杂形态的能力之间保持平衡。
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronResNeXtBlock; //--- Chanels { int temp[] = {256, 512}; //In, Out if(ArrayCopy(descr.windows, temp) < int(temp.Size())) return false; } //--- Units and Groups { int temp[] = {units_out, 4, 64}; //Units, Group Size, Groups if(ArrayCopy(descr.units, temp) < int(temp.Size())) return false; } descr.window = 3; descr.step = 2; descr.window_out = 1; descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } units_out = (descr.units[0] - descr.window + descr.step - 1) / descr.step + 1;
经过三个 ResNeXt 模块之后,特征维度增加到 1024,且所分析序列长度按比例降低。
//--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronResNeXtBlock; //--- Chanels { int temp[] = {512, 1024}; //In, Out if(ArrayCopy(descr.windows, temp) < int(temp.Size())) return false; } //--- Units and Groups { int temp[] = {units_out, 4, 128}; //Units, Group Size, Groups if(ArrayCopy(descr.units, temp) < int(temp.Size())) return false; } descr.window = 3; descr.step = 2; descr.window_out = 1; descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } units_out = (descr.units[0] - descr.window + descr.step - 1) / descr.step + 1;
接下来,ResNeXt 架构提供了沿时间维度压缩所分析序列,只保留所分析环境状态中最显要的特征。为此,我们首先置换结果数据:
//--- layer 6 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronTransposeOCL; descr.count = units_out; descr.window = 1024; descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
然后,我们使用池化层,降低数据的维度,同时保留最重要的特征。这令模型能够专注于关键特征,剔除不必要的噪音,并提供更紧凑的原始数据表示。
//--- layer 7 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronProofOCL; descr.count = 1024; descr.step = descr.window = units_out; descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
记住这一层的序数。这是环境状态编码器的最后一层,我们会从这一层取得第二个模型的输入数据。
接下来是我们的智代解码器,由两个顺序全连接层组成。
//--- layer 8 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 256; descr.activation = SIGMOID; descr.batch = 1e4; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 9 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = NActions; descr.activation = SIGMOID; descr.batch = 1e4; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
两层均以 sigmoid 函数作为激活函数,并递次把张量维度降低到预定义的智代动作空间。
此处应当注意的是,上述创建的智代仅分析原始环境状态,完全没有风险管理模块。我们添加了一个风险管理智代层来弥补这一限制,其已在 MacroHFT 框架内实现。
//--- layer 10 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMacroHFTvsRiskManager; //--- Windows { int temp[] = {3, 15, NActions, AccountDescr}; //Window, Stack Size, N Actions, Account Description if(ArrayCopy(descr.windows, temp) < int(temp.Size())) return false; } descr.count = 10; descr.window_out = 16; descr.step = 4; // Heads descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
我们还添加了一个带有 sigmoid 激活函数的卷积层,将智代的输出映射到指定的数值空间当中。我们使用大小为 3 的卷积窗口,对应于单笔交易的参数。该方式令获得一致的交易特征成为可能。
//--- layer 11 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; descr.count = NActions / 3; descr.window = 3; descr.step = 3; descr.window_out = 3; descr.activation = SIGMOID; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
在下一阶段,我们转到描述预测即将到来价格走势概率的模型。如上所述,我们的预测模型接收来自智代的潜在状态作为输入数据。为了确保潜态与第二个模型输入层之间的维度一致,我们决定放弃手工架构调整。取而代之,我们从智代架构描述中提取潜态层的描述。
//--- Probability probability.Clear(); //--- Input layer CLayerDescription *latent = actor.At(LatentLayer); if(!latent) return false;
提取出来的潜态描述参数随后被传送到新模型的输入层。
if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = latent.count; descr.activation = latent.activation; descr.optimization = ADAM; if(!probability.Add(descr)) { delete descr; return false; }
使用另一个模型的潜态作为输入数据,令我们能够配合已处理、且相互可比的数据工作。由此,主输入预处理无需应用批处理归一化层。甚至,ResNeXt 模块的输出已归一化。
为了获得即将到来价格走向的预测值,我们用到两个连续的全连接层,伴有有一个 sigmoid 激活函数。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 256; descr.activation = SIGMOID; descr.batch = 1e4; descr.optimization = ADAM; if(!probability.Add(descr)) { delete descr; return false; } //--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = NActions / 3; descr.activation = None; descr.batch = 1e4; descr.optimization = ADAM; if(!probability.Add(descr)) { delete descr; return false; }
然后,全连接层的输出调用 SoftMax 函数映射到一个概率空间之中。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronSoftMaxOCL; prev_count = descr.count = prev_count; descr.step = 1; descr.activation = None; descr.batch = 1e4; descr.optimization = ADAM; if(!probability.Add(descr)) { delete descr; return false; } //--- return true; }
重点要注意,我们的模型只预测价格走势的两个方向:向上和向下。有意不考虑横盘(窄幅震荡)走势的概率,因为即使是横盘行情,也代表一连串幅度大致相等、且方向相反的短期价格波动。该方式令模型能够专注于识别基本的动态市场形态,而无需浪费计算资源去描述复杂、但不那么重要的横盘状态。
模型架构描述完成后,所有剩余的就是将操作的逻辑结果返回给调用程序,并终止方法执行。
模型训练
现在我们已定义了模型架构,能够转入下一阶段 — 训练。为此目的,我们将用到 MacroHFT 框架开发过程中收集的训练数据集。数据集构造过程详见相关文章。提醒一下,这个训练数据集是基于 EURUSD 货币对 2024 年整个 M1 时间帧的历史数据构建的。
然而,为了训练模型,我们需要在位于 ...\MQL5\Experts\ResNeXt\Study.mq5 的智能系统算法引入若干修改。在本文中内,我们将专注于 Train 方法,因为整个训练过程都在其中组织。
void Train(void) { //--- vector<float> probability = vector<float>::Full(Buffer.Size(), 1.0f / Buffer.Size());
在训练方法伊始,我们通常计算概率向量,并基于可盈利性选择不同轨迹。这就能纠正可盈利与无盈利局次之间的不平衡,因为在大多数情况下,亏损序列得数量远多于盈利序列的数量。然而,在本项工作中,模型计划在近乎理想的轨迹上进行训练,其中智代动作序列会根据历史价格走势数据形成。如是结果,概率向量以相等的数值填充,确保整个训练数据集的统一表示。该方式令模型能够学习市场数据的关键特征,而不会人为地以他人为代价,而偏向某些场景。这提升了普适能力和模型的韧性。
接下来,我们声明执行操作期间存储临时数据所需的若干局部变量。
vector<float> result, target, state; matrix<float> fstate = matrix<float>::Zeros(1, NForecast * BarDescr); bool Stop = false; //--- uint ticks = GetTickCount();
准备工作到此完毕。然后我们开始为模型创建训练环路系统。
应当注意的是,ResNeXt 架构本身未用到递归模块。因此,训练时建议从训练数据集中随机抽取状态的一个环路进行训练。不过,我们新增了一个风险管理智代,可采用以往决策的记忆模块,以及执行后账户状态的变化。训练该模型需要保持输入数据的历史序列。
在外环路的主体中,我们从训练数据集中抽取一个小批次历史序列的初始状态。
for(int iter = 0; (iter < Iterations && !IsStopped() && !Stop); iter += Batch) { int tr = SampleTrajectory(probability); int start = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 2 - NForecast - Batch)); if(start <= 0) { iter -= Batch; continue; }
然后我们清除重现模块的记忆。
if(!Actor.Clear()) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
接下来,我们将智代动作之前目标值的向量填入零值,然后遵照历史序列运行嵌套环路遍历小批次状态。
result = vector<float>::Zeros(NActions); for(int i = start; i < MathMin(Buffer[tr].Total, start + Batch); i++) { if(!state.Assign(Buffer[tr].States[i].state) || MathAbs(state).Sum() == 0 || !bState.AssignArray(state)) { iter -= Batch + start - i; break; }
在嵌套环路的主文中,我们首先将来自训练数据集中的环境状态描述传送到相应的缓冲区。之后,我们转到描述账户状态张量的形式。于此我们生成与分析环境状态对应的时间戳谐波。
//--- bTime.Clear(); double time = (double)Buffer[tr].States[i].account[7]; double x = time / (double)(D'2024.01.01' - D'2023.01.01'); bTime.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = time / (double)PeriodSeconds(PERIOD_MN1); bTime.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0)); x = time / (double)PeriodSeconds(PERIOD_W1); bTime.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = time / (double)PeriodSeconds(PERIOD_D1); bTime.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); if(bTime.GetIndex() >= 0) bTime.BufferWrite();
我们从经验回放缓冲区提取余额和净值数据。
//--- Account float PrevBalance = Buffer[tr].States[MathMax(i - 1, 0)].account[0]; float PrevEquity = Buffer[tr].States[MathMax(i - 1, 0)].account[1];
我们还要计算前一根历史柱线上可能获得的最后目标交易操作的盈利能力。
float profit = float(bState[0] / _Point * (result[0] - result[3]));
在准备账户状态描述向量时,我们假设在前一根柱线上,所有现有的持仓都已被平仓,并且在嵌套训练环路的前一次迭代中形成的目标操作的潜在交易已被执行。很容易看出,在该环路的第一次迭代中,目标动作向量被填入零值(即无交易操作)。相较之,余额变动系数等于 “1”,净值指标则基于早前计算出的最后一根柱线的潜在盈利形成。
bAccount.Clear(); bAccount.Add(1); bAccount.Add((PrevEquity + profit) / PrevEquity); bAccount.Add(profit / PrevEquity); bAccount.Add(MathMax(result[0] - result[3], 0)); bAccount.Add(MathMax(result[3] - result[0], 0)); bAccount.Add((bAccount[3] > 0 ? profit / PrevEquity : 0)); bAccount.Add((bAccount[4] > 0 ? profit / PrevEquity : 0)); bAccount.Add(0); bAccount.AddArray(GetPointer(bTime)); if(bAccount.GetIndex() >= 0) bAccount.BufferWrite();
相应地,基于目标交易操作,还会形成有关持仓的信息。
输入数据形成之后,我们执行已训练模型的前馈通验。首先,我们调用智代的前向通验方法,把上述准备好的输入数据传递给它。
//--- Feed Forward if(!Actor.feedForward((CBufferFloat*)GetPointer(bState), 1, false, GetPointer(bAccount))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
然后,我们调用预测模型的相应方法,预测即将发生价格走动的概率,并以智代的潜在状态作为输入数据。
if(!Probability.feedForward(GetPointer(Actor), LatentLayer, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
下一步是生成训练模型的目标值。如上所述,我们计划在“近乎完美轨迹”下训练模型。因此,目标值是采用来自训练数据集中的数据,加以“展望未来”来形成的。为此,我们从训练数据集中,按给定规划横向范围内提取实际历史环境状态数据,并将其传送到矩阵之中,每根柱线由单独的行表示。
//--- Look for target target = vector<float>::Zeros(NActions); bActions.AssignArray(target); if(!state.Assign(Buffer[tr].States[i + NForecast].state) || !state.Resize(NForecast * BarDescr) || MathAbs(state).Sum() == 0) { iter -= Batch + start - i; break; } if(!fstate.Resize(1, NForecast * BarDescr) || !fstate.Row(state, 0) || !fstate.Reshape(NForecast, BarDescr)) { iter -= Batch + start - i; break; }
应当注意的是,提取的数据按历史顺序排列。因此,我们组织一个环路来为该矩阵的行重新排序。
for(int j = 0; j < NForecast / 2; j++) { if(!fstate.SwapRows(j, NForecast - j - 1)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; } }
现在,得到即将到来的价格走势数据,我们开始形成目标交易操作向量。在该阶段,算法会根据之前的交易操作分支。换言之,前一次的交易操作会改变智代现阶段的目标。这很合乎逻辑。当有一笔持仓,我们寻找离场点;若无持仓,我们寻找入场点。
如果在前一次迭代中目标操作是开立多头持仓,我们会检查可预见的未来是否达到止损位。
target = fstate.Col(0).CumSum(); if(result[0] > result[3]) { float tp = 0; float sl = 0; float cur_sl = float(-(result[2] > 0 ? result[2] : 1) * MaxSL * Point()); int pos = 0; for(int j = 0; j < NForecast; j++) { tp = MathMax(tp, target[j] + fstate[j, 1] - fstate[j, 0]); pos = j; if(cur_sl >= target[j] + fstate[j, 2] - fstate[j, 0]) break; sl = MathMin(sl, target[j] + fstate[j, 2] - fstate[j, 0]); }
在该情况下,达到止损位的最高价格作为目标获利了结价位。
获得的数值作为目标买入操作的参数被传送,同时卖出操作的参数被重置为零。
if(tp > 0) { sl = float(MathMin(MathAbs(sl) / (MaxSL * Point()), 1)); tp = float(MathMin(tp / (MaxTP * Point()), 1)); result[0] = MathMax(result[0] - result[3], 0.011f); result[1] = tp; result[2] = sl; for(int j = 3; j < NActions; j++) result[j] = 0; bActions.AssignArray(result); } }
在寻找空头持仓的离场点时,也会进行类似操作。
else { if(result[0] < result[3]) { float tp = 0; float sl = 0; float cur_sl = float((result[5] > 0 ? result[5] : 1) * MaxSL * Point()); int pos = 0; for(int j = 0; j < NForecast; j++) { tp = MathMin(tp, target[j] + fstate[j, 2] - fstate[j, 0]); pos = j; if(cur_sl <= target[j] + fstate[j, 1] - fstate[j, 0]) break; sl = MathMax(sl, target[j] + fstate[j, 1] - fstate[j, 0]); } if(tp < 0) { sl = float(MathMin(MathAbs(sl) / (MaxSL * Point()), 1)); tp = float(MathMin(-tp / (MaxTP * Point()), 1)); result[3] = MathMax(result[3] - result[0], 0.011f); result[4] = tp; result[5] = sl; for(int j = 0; j < 3; j++) result[j] = 0; bActions.AssignArray(result); } }
如果没有持仓,我们会寻找入场点。为此,我们判定即将到来的价格趋势方向。
ulong argmin = target.ArgMin(); ulong argmax = target.ArgMax(); while(argmax > 0 && argmin > 0) { if(argmax < argmin && target[argmax]/2 > MathAbs(target[argmin])) break; if(argmax > argmin && target[argmax] < MathAbs(target[argmin]/2)) break; target.Resize(MathMin(argmax, argmin)); argmin = target.ArgMin(); argmax = target.ArgMax(); }
在预期价格走势上涨的情况下,我们定义买入交易的参数。交易操作参数的判定,类似于搜索离场点。止损设在最大数值的价位。
if(argmin == 0 || (argmax < argmin && argmax > 0)) { float tp = 0; float sl = 0; float cur_sl = - float(MaxSL * Point()); ulong pos = 0; for(ulong j = 0; j < argmax; j++) { tp = MathMax(tp, target[j] + fstate[j, 1] - fstate[j, 0]); pos = j; if(cur_sl >= target[j] + fstate[j, 2] - fstate[j, 0]) break; sl = MathMin(sl, target[j] + fstate[j, 2] - fstate[j, 0]); } if(tp > 0) { sl = (float)MathMax(MathMin(MathAbs(sl) / (MaxSL * Point()), 1), 0.01); tp = (float)MathMin(tp / (MaxTP * Point()), 1); result[0] = float(MathMax(Buffer[tr].States[i].account[0]/100*0.01, 0.011)); result[1] = tp; result[2] = sl; for(int j = 3; j < NActions; j++) result[j] = 0; bActions.AssignArray(result); } }
类似地,我们判定价格走势下跌时卖出交易的参数。
else { if(argmax == 0 || argmax > argmin) { float tp = 0; float sl = 0; float cur_sl = float(MaxSL * Point()); ulong pos = 0; for(ulong j = 0; j < argmin; j++) { tp = MathMin(tp, target[j] + fstate[j, 2] - fstate[j, 0]); pos = j; if(cur_sl <= target[j] + fstate[j, 1] - fstate[j, 0]) break; sl = MathMax(sl, target[j] + fstate[j, 1] - fstate[j, 0]); } if(tp < 0) { sl = (float)MathMax(MathMin(MathAbs(sl) / (MaxSL * Point()), 1), 0.01); tp = (float)MathMin(-tp / (MaxTP * Point()), 1); result[3] = float(MathMax(Buffer[tr].States[i].account[0]/100*0.01,0.011)); result[4] = tp; result[5] = sl; for(int j = 0; j < 3; j++) result[j] = 0; bActions.AssignArray(result); } } } } }
交易操作的目标张量形成后,我们就能执行智代的反向传播操作,以便生成的交易决策与目标决策的偏差最小化。
//--- Actor Policy if(!Actor.backProp(GetPointer(bActions), (CBufferFloat*)GetPointer(bAccount), GetPointer(bGradient))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
接下来,我们需要判定预测模型的目标值。我认为很明显,买入交易对应上升趋势,卖出交易对应下跌趋势。由于这些交易是基于历史数据分析形成的,我们对即将到来的趋势有 100% 的信心。因此,对应趋势的目标值为 1,而对立方为 0。
target = vector<float>::Zeros(NActions / 3); for(int a = 0; a < NActions; a += 3) target[a / 3] = float(result[a] > 0);
现在我们就能运行预测模型的反向传播操作。在如此行事中,我们调整环境状态编码器的参数,这与多任务学习方式一致。
if(!Result.AssignArray(target) || !Probability.backProp(Result, GetPointer(Actor), LatentLayer) || !Actor.backPropGradient((CBufferFloat*)NULL, (CBufferFloat*)NULL, LatentLayer)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
现在我们只需通知用户学习过程的进展,然后转到环路系统的下一次迭代。
if(GetTickCount() - ticks > 500) { double percent = double(iter + i - start) * 100.0 / (Iterations); string str = StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Actor", percent, Actor.getRecentAverageError()); str += StringFormat("%-13s %6.2f%% -> Error %15.8f\n", "Probability", percent, Probability.getRecentAverageError()); Comment(str); ticks = GetTickCount(); } } }
指定次数的训练迭代成功完成之后,我们清除向用户报告模型训练进展情况的图表注释。
Comment(""); //--- PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Actor", Actor.getRecentAverageError()); PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Probability", Probability.getRecentAverageError()); ExpertRemove(); //--- }
我们将训练结果输出到日志,并初始化训练程序终止流程。您可在附件中找到模型训练程序的完整代码。
下一步是直接进入模型训练过程。为此目的,我们切换到 MetaTrader 5 终端,并启动实时模式创建智能系统。EA 不执行任何交易操作,故其操作对账户余额没有风险。
应当注意的是,我们同时执行两个模型的训练。然而,智代的操作中有一处细微差别。如上所述,该模型架构中增加了风险管理模块,会用到账户状态和以往决策的记忆模块。同时,智代潜在表征中的信息存储在之前动作的记忆模块当中。
然而,如果我们参考上述训练代码,能见到账户状态描述向量是基于目标值形成的。这造成了不平衡:风险管理模块在完全不同的行为政策背景下评估余额变化。为了把这种影响最小化,我决定分两阶段进行训练。
在第一阶段,我们设定小批次规模等于单一状态。

这样的参数配置实际上在第一训练阶段禁用了记忆模块。这当然不是我们模型的目标操作模式,但在该模式下,我们能带领智代的行为政策尽可能接近目标,从而把预测交易操作与目标交易操作之间的间隙最小化。
在第二训练阶段,我们增加小批次的规模,令其至少略大于记忆模块的容量。这允许我们优调模型,包括控制所选政策对账户状态影响的风险管理组件的运作。
模型测试
模型训练后,我们转到测试成品智代行为政策。此处有必要简要提及测试程序算法中引入的变化。这些调整是局部性的。故此我们不会复查整段代码,您可以在附件中独立实证。我们仅想注明,我们在程序逻辑里加入了即将到来的价格走势概率预测模型。只有当智代的交易方向与最可能的趋势重合时,交易操作才会被执行。
我们在 MetaTrader 5 策略测试器中依据 2025 年 1 月的历史数据测试已训练政策,同时完全保留汇编训练数据集时所用的所有其它参数。测试区间未包括在训练数据集当中。这令测试条件尽可能接近现实世界中针对未见数据的操作。
测试结果呈现如下。

测试期间,模型执行了 60 笔交易操作,平均每个交易日约 3 笔交易。超过 43% 的开仓获利了结。由于平均和最大盈利交易几乎是相应亏损交易指标的两倍这一事实,测试结论伴以积极的财务成果。盈利因子为 1.52,恢复因子为 1.14。
结束语
本文讨论的基于 ResNeXt 架构的多任务学习框架为金融市场分析开辟了新机遇。得益于共享编码器和专用“头”,该模型能够有效识别数据中的稳定形态,适应不断变化的市场条件,并提供更准确的预测。多任务学习的应用有助于最大限度地减少过度拟合风险,在于模型会同时训练多个任务,这有助于形成更普适的市场表征。
此外,ResNeXt 架构的高度模块化,令模型参数能够根据特定操作条件进行调优,令其成为算法交易的多功能工具。
所呈现 MQL5 版本实现,是按我们对拟议方法的诠释,其已在时间序列分析、及市场趋势预测中展现出有效性。新增的市场趋势预测模块显著强化了模型的分析能力,令其对意外价格变化更具韧性。
总体而言,该系统在自动化交易和金融数据算法分析中展现出显著应用潜力。然而,在真实市场条件下部署该模型之前,必须先在更具代表性的训练数据集上进行训练,随后进行全面测试。
参考
文章中所用程序
| # | 名称 | 类型 | 说明 |
|---|---|---|---|
| 1 | Research.mq5 | 智能系统 | 收集样本的智能系统 |
| 2 | ResearchRealORL.mq5 | 智能系统 | 利用 Real-ORL 方法收集样本的智能系统 |
| 3 | Study.mq5 | 智能系统 | 模型训练智能系统 |
| 4 | Test.mq5 | 智能系统 | 模型测试智能系统 |
| 模型测试智能系统 | Trajectory.mqh | 类库 | 系统状态和模型架构描述结构 |
| 6 | NeuroNet.mqh | 类库 | 创建神经网络的类库 |
| 7 | NeuroNet.cl | 代码库 | OpenCL 程序代码 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/17157
注意: MetaQuotes Ltd.将保留所有关于这些材料的权利。全部或部分复制或者转载这些材料将被禁止。
本文由网站的一位用户撰写,反映了他们的个人观点。MetaQuotes Ltd 不对所提供信息的准确性负责,也不对因使用所述解决方案、策略或建议而产生的任何后果负责。
MQL5 简介(第 17 部分):构建趋势反转 EA 交易
斐波那契(Fibonacci)数列在外汇交易中的应用(第一部分):探究价格与时间的关系
新手在交易中的10个基本错误
交易中的神经网络:基于 ResNeXt 模型的多任务学习