神经网络在交易中的应用:时间序列的概率预测(结论)
引言
金融时间序列仍是分析难度最大的数据类型之一。其行为很难称得上稳定:这里没有清晰的模式,而是趋势、周期、噪声和意外结构转变的组合。因此,尽管线性回归或移动平均等传统模型简单且快速,但在大多数情况下仍显得不足。另一方面,具有强大表达能力的现代神经网络解决方案往往存在过拟合、不稳定和缺乏可解释性的问题。
在这种情况下,将经过时间检验的数学结构与神经网络的灵活性相结合的混合方法尤为引人关注。K²VAE 框架正是基于这种平衡而构建的 — 该系统旨在分析和预测时间序列,同时考虑其现实世界特征。该框架的一个关键优势在于,它能够通过学习市场状态序列并在新数据到达时调整其推断,从而捕捉到系统的潜在动态。
该框架基于三个基本想法,每个想法都相互补充,弥补彼此的不足。第一个是 Koopman 表示法,旨在将非线性系统的行为映射到线性子空间。这种转换虽然是近似的,但它为描述和预测提供了一个强大的工具,使我们能够通过重建潜在动态来处理时间序列,而不仅仅是在表面统计的层面上。
第二个想法是使用 Kalman 滤波器,但不是其标准形式,而是采用适合在随机神经网络架构中使用的稳定形式。Kalman 滤波器在这里充当自适应校正器:它根据新的观测结果来优化从 KoopmanNet 模块获得的估计值,同时还提供有关每个预测置信度的信息。当关注的重点不仅仅是预测价格,而是评估某些场景的概率时,这一点尤其有价值。
第三个想法是集成变分自编码器。它使我们能够生成概率预测而不是点预测:我们得到的不是单一的可能情景,而是反映主要趋势和可能偏差的完整分布。
因此, K²VAE 不仅仅是分析工具包中的另一个模型;它是一个能够定量描述不确定性、考虑替代情景并适应新的市场条件的系统。

在之前的文章中,我们已经详细探讨了该框架的关键组成部分。我们描述了 KoopmanNet 的工作原理、潜在表示的形成方式、Kalman 滤波器如何集成到架构中以及如何联合训练所有元素。特别关注了计算稳定性以及模型内部信息传播的机制。
模型架构
在逐步构建构成 K²VAE 框架架构基础的各个组件之后,我们将进入下一个合乎逻辑的阶段 — 将所有元素集成到一个单一的功能系统中。
需要强调的是,与之前的工作一样,我们的目标并非构建一个传统孤立形式的预测模型。这里的预测只是副产品,相较于为当前环境状态创建具有表现力和稳定性的潜在表征这一更根本的任务而言,它只是次要的。这种表征不仅总结了输入的信息,而且在可训练的 Agent(代理)做出交易决策时,还作为其关键输入。
因此,我们认为 K²VAE 框架并非一个独立的预测模型,而是作为环境状态的高级 Encoder(编码器),被集成到之前基于 Actor–Director–Critic(演员-导演-评论家) 方法实现的架构中。在此, VAE 组件用作生成未来场景概率描述的机制, KoopmanNet 负责潜在空间中的线性动态,而 Kalman 滤波器则根据传入的观测值进行校正和改进。所有这些都有一个共同的目的 — 即为 Agent 提供外部环境的表示,该表示包含尽可能多的有用信息,以便做出深思熟虑且具有统计合理性的交易决策。
将 K²VAE 组件集成到演员-导演-评论家架构中,为系统提供了必要的深度和稳定性。因此,Encoder 形成的潜在状态不仅包含有关当前状况的信息,还包含有关其可能发展轨迹的信息。这从质上改善了 Agent 的行为:它不仅对当前信号做出反应,而且还考虑到可能的情况采取行动,这在高度波动和不可预测的市场条件下尤为重要。
这时就需要用到 CreateDescriptions 方法了。它负责整合所有架构描述 — 从 Encoder 和预测模块到决策组件。该方法通过确定我们系统的每个组件内将使用哪些层以及使用顺序,为未来的训练奠定了基础。
bool CreateDescriptions(CArrayObj *&encoder, CArrayObj *&forecast1, CArrayObj *&forecast2, CArrayObj *&forecast3, CArrayObj *&actor, CArrayObj *&director, CArrayObj *&critic ) { //--- CLayerDescription *descr; //--- if(!encoder) { encoder = new CArrayObj(); if(!encoder) return false; } if(!forecast1) { forecast1 = new CArrayObj(); if(!forecast1) return false; } if(!forecast2) { forecast2 = new CArrayObj(); if(!forecast2) return false; } if(!forecast3) { forecast3 = new CArrayObj(); if(!forecast3) return false; } if(!actor) { actor = new CArrayObj(); if(!actor) return false; } if(!director) { director = new CArrayObj(); if(!director) return false; } if(!critic) { critic = new CArrayObj(); if(!critic) return false; }
在方法参数中,我们接收指向多个动态数组的指针 — 每个模型对应一个。这些数组是容器,用于存放神经网络块的架构描述。在填充这些数组之前,我们会执行一项强制性检查,以验证接收到的指针是否正确。如果缺少所需的对象,我们不会尝试处理悬空内存;相反,我们会谨慎地创建一个新的数组实例。这一步看似只是技术上的形式,但实际上正是为后续所有工作的稳定性奠定基础。
之后,主要部分开始了 — 为每个数组填充适当的层和连接,以形成神经网络的完整配置。让我们从系统的核心组件 — 环境状态 Encoder 开始。我们将 K²VAE 框架的核心思想融入到这个组件中。提醒一下,Encoder 的任务不仅仅是收集有关市场状况的信息,而是创建一个富有表现力和信息量的潜在表示,作为 Agent 进行交易决策的可靠依据。
我们将直接从终端接收到的原始输入数据传递给全连接层。在此,它充当外部世界与模型内部逻辑之间的接口。
//--- Encoder encoder.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; uint prev_count = descr.count = (HistoryBars * BarDescr); descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; } //--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBatchNormWithNoise; descr.count = prev_count; descr.batch = BatchSize; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
然后将数据发送到预处理阶段 — 批量归一化层。正是在这里,不同规模、范围或物理意义的输入特征被转化为可比形式。这一步骤不仅在数值稳定性方面很重要,而且从训练整个模型的角度来看也很重要:归一化数据使神经网络能够更快地收敛,并更准确地捕捉特征之间的关系。
在此背景下,批量归一化起到过滤器的作用,能够平滑统计噪声,并均衡输入信号中各个成分的分布。这为后续变换创造了一个稳定且可预测的环境,从而最大限度地减少处理初始阶段的失真。
数据一旦完成归一化处理,就可以进行下一个重要步骤 — 空间划分,或分块(patching)。最初的 K²VAE 框架的作者建议将预处理后的输入数据分成互不重叠的块,每个块代表整个数据流的一部分。同时,来自不同通道的值被组合在一个分块中,使模型能够自然地学习识别跨特征依赖关系。
然而,在我们的实现中,我们更进一步,决定不局限于标准的分块方案;相反,我们受之前文章中讨论的一些现代架构启发,实现了一个更复杂的结构。首先,我们通过增加捕获每个通道值逐步偏差的导数特征,进一步丰富了输入特征数组。这使得我们能够考虑短期信号动态,并提高模型对市场结构变化的敏感性,同时保持其对噪声的鲁棒性。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConcatDiff; prev_count = descr.count = HistoryBars; descr.layers = BarDescr; descr.step = 1; descr.batch = BatchSize; descr.optimization = ADAM; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; }
随后,时间戳被添加到数据中 — 这是一个重要元素,它使模型不仅能够考虑特征值,还能考虑它们在时间上的位置。这些时间戳为神经网络提供了关于事件实际发生时间的上下文信息,无论是按分钟、小时、星期几还是其他时间尺度。因此,该模型能够识别金融时间序列特有的重复模式和季节性波动。在处理历史报价时,这种时间信息的纳入尤为重要,因为在制定交易策略时,周期性起着关键作用。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defMamba4CastEmbeding; prev_count = descr.count = HistoryBars; descr.window = 2 * BarDescr; uint prev_out = descr.window_out = NSkills; { uint temp[] = {PeriodSeconds(PERIOD_H1), PeriodSeconds(PERIOD_D1)}; if(ArrayCopy(descr.windows, temp) < (int)temp.Size()) return false; } descr.batch = BatchSize; descr.optimization = ADAM; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; } //--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronTransposeOCL; descr.count = prev_count; prev_count = descr.window = prev_out; descr.batch = BatchSize; descr.optimization = ADAM; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; } prev_out = descr.count;
在此值得强调一点:不同的输入通道可能具有不同的内部节律,即它们可能表现出周期不同的循环。在这种情况下,简单地将所有通道的值机械地合并成一个单一的数据流,可能会掩盖隐藏的季节性依赖关系,并扭曲信号动态。
为了避免丢失这些特征,我们实现了一种自适应卷积机制。其目的是分析每个时间序列的性质,并生成考虑到通道个体特征的分块。因此,每个通道在潜在空间中都有相同数量的表示,同时保留了对其独特周期性的敏感性。
//--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronAdaptConv; descr.count = Segments; descr.window = 2 * prev_out / Segments; descr.variables = prev_count; prev_out = descr.window_out = EmbeddingSize; descr.batch = BatchSize; descr.optimization = ADAM; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; } prev_count = descr.count; uint prev_var = descr.variables;
我们向生成的分块添加了 RoPE 编码(旋转位置编码),该编码为模型提供了时间序列中每个元素相对位置的信息。与经典的位置编码不同,RoPE 通过在潜在空间中旋转特征向量来融入位置信息。这使得在后续的 Transformer 层处理中能够保留数据的时间结构,并能够高效地传达事件之间的距离信息。这种方法在时间序列分析中尤为重要,因为事件之间的顺序和间隔起着至关重要的作用。
//--- layer 6 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronRoPE; descr.count = prev_count; descr.window = prev_out; descr.variables = prev_var; descr.batch = BatchSize; descr.optimization = ADAM; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; }
接下来,我们将由独立通道的分块组成的三维张量转置为分块的时间序列格式。这种转换使我们能够从形式为[Channels × Patches × Features]的结构转变为更适合序列模型处理的形式:[Patches × Channels × Features],其中每个分块现在表示一个时间切片,同时包含所有通道的数据。通过这种方式,我们为后续在 K²VAE Encoder 中的处理准备好了数据,引导模型关注整体时间流中特征之间的关系。
//--- layer 7 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronTransposeRCDOCL; descr.count = prev_var; descr.window = prev_count; descr.step = prev_out; descr.batch = BatchSize; descr.optimization = ADAM; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; }
这种数据表示方法与 K²VAE 框架的作者提出的核心思想完全一致,尤其是将异构信息整合到单一潜在空间的目标。然而,与基线实现不同,我们保留了时间序列结构的重要特性 — 特别是单个通道的周期性 — 这对金融数据至关重要。此外,通过扩展分块、逐步偏差和时间戳,我们丰富了输入表示,增强了模型识别稳定模式和季节性依赖关系的能力,同时又不失单个数据源的细微差别。
生成的分块包含有关当前环境状态的丰富且结构化的信息,这些分块被输入到 K²VAE 编码器中。在此阶段,进行概率编码:每个输入序列在该模块的输出端被转换为嵌入分布。该分布中的每个单独嵌入都代表了一个紧凑而富有表现力的压缩描述,描述了所分析时间序列演化的可能情景之一。因此,该模型不仅生成未来状态的单个点估计,还生成一组反映潜在轨迹多样性的概率假设。鉴于金融市场本身存在的高度不确定性,这一点尤为重要。
//--- layer 8 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronK2VAEEncoder; { uint temp[] = {prev_count, // units in NScenarios, // Scenarios NExperts, // MoE TopK // Top K }; if(ArrayCopy(descr.units, temp) < int(temp.Size())) return false; } descr.step = NHeads; { uint temp[] = {prev_out * prev_var, // window prev_out * prev_var / descr.step, // Key dimension 2 * prev_out*prev_var / NExperts // MoE dimension }; if(ArrayCopy(descr.windows, temp) < int(temp.Size())) return false; } descr.layers = 3; descr.variables = 1; descr.batch = BatchSize; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
在此,需要特别指出的是,正是 K²VAE Encoder 输出端获得的潜在空间中的分布,将被传递给 Agent 以用于后续的交易决策。然而,为了确保这一潜在状态的表现力和实用价值 — 即其包含关于时间序列未来行为最可能情景信息的能力 — 我们需要在模型内部构建一个完整的预测流程。
如前所述,我们的框架目标并非传统意义上的生成准确预测。然而,我们需要组织训练过程,以便误差梯度能从最终结果反向传播到嵌入的概率分布的每个元素。为此,我们增加了一个名为 TimeMoEAttention 的专用层。
这一层执行着重要的功能:它聚合概率嵌入,从中形成统一的表示,从而创建了传统预测模型所必需的功能上的对应物。这种方法使我们能够将潜在空间的概率特性与可训练性和优化的要求相结合,从而能够基于最终结果对分布进行端到端的训练。
//--- layer 9 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronTimeMoEAttention; descr.window_out = EmbeddingSize; { uint temp[] = {prev_out, prev_out, 8, TopK}; //Window Main, Window Cross, Experts dimension, TopK if(ArrayCopy(descr.windows, temp) < ArraySize(temp)) return false; } { uint temp[] = {prev_var, prev_var * NScenarios, NExperts}; //Units Main, Units Cross, Experts if(ArrayCopy(descr.units, temp) < ArraySize(temp)) return false; } descr.layers = 3; descr.step = NHeads; // Attention heads descr.batch = BatchSize; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }//--- CLayerDescription *latent = descr;
接下来,我们将构建三种标准预测模型的架构,每种模型负责其各自预测范围(短期、中期和长期)内的预测。这些模型发挥着重要作用:它们将编码器中形成的潜在表示映射回观测数据空间。因此,每个状态都以反映时间序列未来行为可能情景的现实世界中的可观测量来解释。
值得注意的是,我们之前的文章中已详细讨论了这些预测模型的架构。它们在实践中已证明其有效性,也正因如此,我们才直接沿用了这些架构,未作修改。在本文中,我们将不再赘述其构建的技术细节。
下一步是构建交易 Actor(演员) 的架构 — 这是决策系统的核心要素。其任务是根据现有信息选择最佳交易行动。与传统的预测模型不同,Actor 并不试图直接预测未来的市场行为;相反,它利用 Encoder 提供的可能情景的概率描述来评估每种可能行动的潜在有效性。
演员模型接收两个信息源作为输入。第一个是当前账户状态,包括未平仓位、净值和其他辅助信息。第二个是 K²VAE Encoder 得到的潜在表示的分布,其中每个表示描述了一种可能的未来市场情景。该分布不仅提供了有关最可能轨迹的信息,而且还传达了模型对这些情景的不确定性程度。
与之前一样,作为第一层,我们使用一个全连接层作为模型的外部接口。正是通过这个接口,Actor 接收关于当前账户状态的信息作为输入。
//--- Actor latent = encoder.At(LatentLayer); actor.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = AccountDescr; 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 = AccountDescr; descr.batch = BatchSize; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
输入数据通过批量归一化层被归一化为可比形式。这样,无论数据的原始量级和性质如何,都可以消除特征之间的尺度差异,并确保后续模型层的稳定运行。
在将账户状态数据归一化为可比形式后,这些数据会被传递给交叉注意力模块,该模块会在未来环境状态的概率分布背景下考虑这些信息。
这里需要注意的是,它与经典模型的一个关键区别在于:Actor 并非仅仅对当前情况做出反应,而是在考虑未来情景分布的宽度和形状的同时,评估决策风险。Encoder 对其预测越有信心(表现为分布越窄、越集中),Actor 收到的情景范围就越小。在这种情况下,它可以采取更果断的行动 — 例如,增加仓位规模或使用更激进的交易参数。因此,该策略能够根据不断变化的市场不确定性水平进行适应性调整。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronCrossDMHAttention; { uint temp[] = {AccountDescr, // Inputs window latent.windows[0] // Cross window }; if(ArrayCopy(descr.windows, temp) < (int)temp.Size()) return false; } { uint temp[] = {1, // Input units latent.units[1] // Cross units }; if(ArrayCopy(descr.units, temp) < (int)temp.Size()) return false; } descr.step = NHeads; // Heads descr.window_out = 8; descr.batch = 1e4; descr.layers = 2; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
处理后的信息随后被输入到一个三层全连接神经网络( MLP )中,从而完成处理链并生成最终的交易决策。正是在这个阶段,所有特征都被整合在一起:当前账户状态、未来的概率表示以及通过注意力机制识别的上下文。模型分析这些因素的组合,并确定交易方向及其参数。尽管其表面看似简单,但这种结构在确保整个交易系统的灵活性和适应性方面发挥着至关重要的作用。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.batch = BatchSize; descr.activation = TANH; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = TANH; descr.batch = BatchSize; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 2 * NActions; descr.activation = SIGMOID; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
Actor 输出使用随机头来生成交易决策,通过 CNeuronVAEOCL 层实现。
//--- layer 6 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronVAEOCL; descr.count = NActions; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 7 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; }
乍一看,这种方法似乎风险过高:将未来情景的概率分布与 Actor 头部固有的概率性质相结合,确实会增加决策过程中的随机性程度。在真实的市场环境下,这确实引发了人们的担忧。
然而,这种方法的核心思想是 Actor 的随机性仅在模型训练的早期阶段才能充分体现。在此期间,Actor 积极探索各种可能的行为,从广泛的环境反应中学习。随着经验的积累和结果的重新评估,产出分布越来越集中在最佳策略周围,而分布范围则越来越小。因此,Actor 的行为趋于稳定,获得了在真实市场条件下做出自信决策所需的方向性和专注力。
Director(导演) 和 Critic(评论家) 的评价模型在很大程度上反映了 Actor 的架构,保留了相同的总体结构和数据处理原则。主要区别在于主要输入数据流的来源:它们不是以当前账户状态作为输入,而是以 Actor 生成的动作张量作为输入。这样一来,他们就能对每项行动进行更实质性、更详细的评估。
然而,这里并不存在 Actor 所具有的随机决策能力。Director 和 Critic 不会对每个动作进行概率分布,而是为每个动作生成具体的评价和质量信号。这种详细的反馈有助于 Actor 改进策略,提高交易决策的有效性。
附件中提供了描述所有模型架构设计的完整源代码。
训练
完成所有模型组件的架构设计阶段后,我们将进入下一步 — 训练过程。与之前的工作一样,该过程分为两个阶段。在第一阶段,我们对直接从终端获取的历史数据进行离线训练。这种方法的显著特点是,训练无需预先准备训练数据集。相反,我们将动作评估机制直接融入训练过程中;这极大地扩展了初始训练阶段可用的历史数据范围,并消除了手动标注或创建专用数据集的约束。
在此,值得特别关注在先前实验中出现的一个重要观点。在有限预测状态范围内使用 Actor 行动评估模型时,会观察到持续的不良行为。该模型倾向于设定过于雄心勃勃的止损和止盈水平,而这些水平在指定的评估窗口内无法达到。这导致了系统性的长期死扛亏损仓位,即亏损仓位被持有过长时间,而没有足够的理由进行反转或退出市场。这种行为变得占主导地位,模型实际上失去了应对短期市场变化的能力,从而使策略沦为被动跟随全局趋势。
为了抵消这一影响,我们对训练程序进行了两项关键修改。首先,我们大幅降低了折扣因子(Discount Factor),将重点转向生成快速利润。这一决定使模型能够专注于其行为的即时结果,更加重视短期后果。毕竟,在这种方法下,当前损失对最终奖励的影响大于遥远未来的潜在利润,即使后者看起来更令人印象深刻。因此,模型开始避免基于被动等待市场逆转的策略,并减少了对持有损失的倾向,从而提高了其对真实市场动态的适应性。
第二项改动涉及模型结构,在 CheckAction 方法中实现。在此,我们不再局限于在有限的预测范围内评估行动,而是采用了一种在整个可用历史区间内进行评估的方法。这一步骤显著提高了反馈的准确性,因为在绝大多数情况下,我们都能准确知道在这个延长的历史区间内会达到哪个交易水平(止损或止盈)。这反过来又为每个操作提供了更客观的评估,使学习系统能够更清晰地区分有效和无效的决策。
double CheckAction(CBufferFloat *action, double equity, uint start_position) { if(!action || start_position >= Rates.Size()) return 0;
该方法接受以下参数:
- action — 交易行动张量:成交量、止损/止盈水平;
- equity — 当前净值状态,用于估算最大回撤;
- start_position — Rates市场数据数组中持仓状态索引。
该方法首先检查指向 action 对象的指针是否有效,以及起始位置索引是否超出引用数组的边界。
接下来,检索交易参数。
double buy_lot = MathMax(double(action[0] - action[3]), 0); double sell_lot = MathMax(double(action[3] - action[0]), 0);
因此,该系统支持每个方向的单独交易量输入。例如,如果 action[0] >action[3],则假定开立买入仓位;否则,假定开立卖出仓位。
接下来,根据当前价格计算保证金要求,确定一个点的移动成本( point_cost ),如果成交量不足(低于最小允许值),则该方法根据蜡烛图范围返回预期损失(放弃的利润)。
double marg = 0; if(!OrderCalcMargin(ORDER_TYPE_BUY, Symb.Name(), 1, Symb.Ask(), marg)) return 0; double point_cost = Symb.TickValue() / Symb.TickSize(); if(MathMax(buy_lot, sell_lot) < Symb.LotsMin()) { double loss = -MathMax(Rates[start_position].high - Rates[start_position].open, Rates[start_position].open - Rates[start_position].low) * point_cost * equity / (2 * marg); return loss; } if((marg * MathMax(buy_lot, sell_lot)) >= equity) { double loss = -MathMax(Rates[start_position].high - Rates[start_position].open, Rates[start_position].open - Rates[start_position].low) * point_cost * MathMax(buy_lot, sell_lot); return loss; } point_cost *= MathAbs(buy_lot - sell_lot);
当交易量过大且可用资金不足以覆盖时,也会发生同样的情况(保证金 * 手数 > 权益)。这鼓励模型在可用资金范围内进行交易,同时抑制模型选择不交易的倾向。
接下来,我们将继续检查所提出行动的有效性。首先,我们检查买入仓位。
//--- double tp = 0, sl = 0, profit = 0, reward = 0; int stops = MathMax(Symb.StopsLevel(), 10); int spread = Symb.Spread(); if(buy_lot > 0) { tp = action[1] * MaxTP; sl = action[2] * MaxSL; if(int(tp) < stops || int(sl) < (stops + spread)) { double loss = -MathMax(Rates[start_position].high - Rates[start_position].open, Rates[start_position].open - Rates[start_position].low) * point_cost * buy_lot; return loss; } tp = (tp + spread) * Symb.Point() + Rates[start_position].open; sl = Rates[start_position].open - (sl + spread) * Symb.Point(); reward = profit = -spread * Symb.Point() * point_cost;
止盈和止损水平是从 action 张量的元素中检索出来的。然后对它们进行缩放,并检查是否符合经纪商的止损水平要求。
如果条件成立,则 TP 和 SL 将转换为绝对价格值。否则,我们将计算放弃的利润金额。
接下来,根据一段历史数据模拟价格走势。为此,我们设置了一个循环,按时间顺序遍历历史数据。
需要注意的是, Rates 数组是一个时间序列。因此,为了保持历史顺序,数据是按相反的顺序处理的。
for(uint i = start_position; i >=0; i--) { if(sl >= Rates[i].low) { double p = (Rates[i].open - sl) * point_cost; profit -= p; reward -= p * MathPow(DiscFactor, float(i - start_position)); break; }
循环体采用严格的风险导向方法:检查的优先考虑止损水平。这一决定是基于常识 — 市场损失往往发生得很快,而且速度远快于盈利积累的速度。若达到止损水平,则根据折扣系数,以货币形式确定损失金额。
使用折扣因子可以在即时和延迟结果之间实现灵活的平衡,训练模型选择能带来稳定收益的行动。折扣因子越高,模型就越倾向于长期利益,反之亦然。然而,这种灵活性也有其缺点:使用折扣因子会使得准确评估深度亏损变得困难。在经过大量步骤后发生的亏损会被大幅折扣,并可能被模型视为微不足道。因此,模型倾向于持有亏损仓位,以期出现逆转,而在真实市场条件下,可能导致灾难性的回撤,直至达到强制平仓水平。为了避免这种行为,除了折扣评估外,还应用了绝对亏损控制。
是否已达到止盈水平的检查也是以相同的方式进行的。
if(tp <= Rates[i].high) { double p = (tp - Rates[i].open) * point_cost; profit += p; reward += p * MathPow(DiscFactor, float(i - start_position)); break; }
如果没有任何交易水平达到,则根据下一根 K 线的开盘价记录当前的盈亏。
double p = (Rates[i - 1].open - Rates[i].open) * point_cost; profit += p; reward += p * MathPow(DiscFactor, float(i - start_position));
尤其需要强调的是,我们使用的是下一根 K 线的开盘价,而不是当前 K 线的收盘价。虽然在大多数情况下这些数值相同或只有细微差别,但我们必须考虑到市场动态的具体特征。我们的模型会在新 K 线开盘时做出交易决策,因此在计算未平仓位的结果时,使用该特定价格是正确的。
这种方法有助于保持模拟的真实性,并确保不会忽略价格缺口的可能性 — 价格缺口是市场高度波动或重大新闻发布时的特征。使用下一根 K 线的开盘价还可以引起人们对交易事件顺序的关注,突出在真实市场条件下做出的决策与其执行之间的因果关系。
接下来,我们将累计损失(不考虑折现因素)与建仓时的净值水平进行比较。这种方法可以确保有足够的资金来执行交易。如果损失超过可用资本 — 也就是说,如果发生爆仓 — 我们会增加奖励函数中的惩罚,并立即终止模拟过程。这模拟了资金短缺导致交易活动暂停的真实市场情况,并在模型训练过程中对交易策略进行了更准确、更安全的评估。
if(-profit >= equity) { reward-=1000; break; } } }
我们以同样的方式评估卖出仓位。
if(sell_lot > 0) { tp = action[4] * MaxTP; sl = action[5] * MaxSL; if(int(tp) < stops || int(sl) < (stops + spread)) { double loss = -MathMax(Rates[start_position].high - Rates[start_position].open, Rates[start_position].open - Rates[start_position].low) * point_cost * sell_lot; return loss; } tp = Rates[start_position].open - (tp + spread) * Symb.Point(); sl = Rates[start_position].open + (sl - spread) * Symb.Point(); for(uint i = start_position; i >=0; i--) { if(sl <= Rates[i].high) { double p = (sl - Rates[i].open) * point_cost; profit -= p; reward -= p * MathPow(DiscFactor, float(i - start_position)); break; } if(tp >= Rates[i].low) { double p = (Rates[i].open - tp) * point_cost; profit += p; reward += p * MathPow(DiscFactor, float(i - start_position)); break; } double p = (Rates[i - 1].open - Rates[i].open) * point_cost; profit -= p; reward -= p * MathPow(DiscFactor, float(i - start_position)); if(-profit >= equity) { reward-=1000; break; } } } //--- return reward; }
之后,我们将根据折扣因子调整后的累积奖励返回给调用程序,从而结束该方法。
附件中提供了用于离线模型训练的 EA 的完整代码 “ …\Experts\K2VAE\Study.mq5 ”。该附件还包含了撰写本文所使用的所有程序。
测试
如前所述,该模型分两个连续阶段进行训练。首先,我们使用 H1 时间周期上 15 年的 EURUSD 价格历史进行了离线训练。该数据集涵盖了所有类型的市场状况:从长期横盘整理到急剧趋势,从平静期到波动性飙升。因此,该模型能够从市场行为的多样性中学习。Encoder 学会了识别关键模式,并将市场状态转化为简洁而信息丰富的表示,作为 Agent 决策的基础。与此同时,Actor 利用 Critic 和 Director 的反馈,制定了一套能够在各种条件下有效发挥作用的稳健策略。
接下来是第二阶段 — 使用 2024 年数据在MetaTrader 5 策略测试器中进行的在线训练。该模型以近乎实时的方式运行,逐根分析市场 K 线。它遇到了实时市场特有的噪音、随机波动和失真。这种方法不仅可以对模型进行微调,还可以使其行为适应现实世界的动态变化,改进其策略,并增强其在不确定性面前的鲁棒性。
训练完成后,我们在新数据(2025 年1 月至 3 月的报价)上测试了该模型,同时保持训练期间使用的所有参数不变。测试结果如下所示。


测试结果表明,该模型在选定的历史时期内实现了盈利。初始存款为 $100.0,总净利润达到 $821.90,表明资本有所增长。但值得注意的是,盈利能力比率(利润因子)为 1.06,这表明利润仅略高于亏损。
交易指标显示,盈利交易的数量与亏损交易的数量大致相当 — 分别约为 49% 和 51% — 这表明盈利交易与亏损交易数量大致平衡。
图表显示,尽管出现了明显的回撤和下跌时期,但余额曲线总体上呈上升趋势。1 月份和 2 月上半月账户余额的明显上升趋势尤其值得注意。与此同时,三月份的业绩明显亏损。这可能表明需要使用更长的历史数据段来微调模型。
结论
总之,可以认为,作为我们交易代理的一部分,所提出的 K²VAE 框架已经证明了其可行性,并且已经通过历史数据得到了验证。该模型结合了对潜在市场动态的深刻理解、自适应风险调整以及概率情景的生成,从而实现了资本增长。与此同时,在较长的测试间隔内性能的下降表明需要找到提高模型泛化能力的方法。
参考
本文中使用的程序
| # | 名称 | 类型 | 描述 |
|---|---|---|---|
| 1 | Study.mq5 | EA | 离线模型训练的 EA |
| 2 | StudyOnline.mq5 | EA | 在线模型训练的 EA |
| 3 | Test.mq5 | EA | 模型测试 EA |
| 4 | Trajectory.mqh | 类库 | 用于描述系统状态和模型架构的结构 |
| 5 | NeuroNet.mqh | 类库 | 用于创建神经网络的类库 |
| 6 | NeuroNet.cl | 程序库 | OpenCL程序的代码库 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/18807
注意: MetaQuotes Ltd.将保留所有关于这些材料的权利。全部或部分复制或者转载这些材料将被禁止。
本文由网站的一位用户撰写,反映了他们的个人观点。MetaQuotes Ltd 不对所提供信息的准确性负责,也不对因使用所述解决方案、策略或建议而产生的任何后果负责。
借助PythonAnywhere对接MetaTrader与谷歌表格:安全数据传输指南
新手在交易中的10个基本错误
我最近在使用 VAE.mqh 时遇到了一些问题,发现以下方法能很好地解决这个问题。