交易中的神经网络:摆脱特定数据依赖的时间序列泛化(结论)
引言
金融市场如同一个生命体。市场的节奏由数百万笔成交、数百份经济报告以及源源不断的新闻消息共同塑造。在这样的环境中,能够盈利的交易者不仅要快速响应,更要预判趋势何时发生转向。这正是Mamba4Cast诞生的初衷:这是一套时间序列预测框架,借鉴神经网络架构领域的前沿成果,并针对高频序列的特性做了定制化设计。
我们对该框架的介绍已经到了最后阶段。首先,我们讲解了理论框架以及特征处理的通用思路。在第二部分中,我们深入剖析了底层运行机制。现在,我们把全部内容整合起来,向大家展示:该模型并非只存在于纸面上,在真实的市场环境下同样可以运行。
该框架由一串模块链式搭建而成,每个模块各司其职,完成特定功能。第一个模块负责特征提取。模型在此处处理原始数据:开盘价、收盘价、 最高价、最低价以及成交量。全部数据会经过一个精简网络层,用于识别局部模式。可以把它类比成交易者经过训练的洞察力,在杂乱的图表中捕捉行情形态。
接下来到了卷积层。这些模块充当市场滤波器:提取有效稳定信号,过滤噪声。波动率的脉冲尖峰、逐步衰竭的趋势、刚刚形成的盘整区间,全部都会被识别并处理。这里采用多窗口架构,每一路卷积负责捕捉不同时间尺度的行情。因此,该框架能够同时学习捕捉短期波动与更长周期的震荡循环。
核心模块 —— SSM(状态空间模型),赋予模型长期记忆能力。这点在金融数据场景中尤为关键:行情模式往往不会立刻显现,需要跨越几十根K线的时间跨度才会显露。例如,一连串假突破之后,最终可能爆发一波强劲的趋势行情,模型必须能够应对这类场景。正是依靠SSM,模型能够保留历史上下文,即便市场充满不确定性,依旧维持有据可依的预测能力。
Mamba4Cast框架的突出优势在于它可以在整个预测视界内输出预测结果。这一点与交易者现实面临的难题高度契合。可以将这套机制类比驾驶员的驾驶行为:既要紧盯眼前的道路,同时也要远眺前方,预判车流走向。这种混合模式能够生成更加稳定和完备的决策策略。

在本文中,您将会看到模型的最终形态:网络架构、训练流程以及真实市场环境下的结果。我们将展示理论如何结合实践,抽象的模型如何转变为用于市场分析的实用工具。
模型架构
今天,我们将开始搭建一个可训练模型的架构:一套功能完备的交易智能体(Agent),能够实时做出决策并执行交易。就像交易者会仔细研判当前市场环境、评估价格走势、成交量与市场情绪,之后才决定是否开仓一样;我们的智能体也必须能够感知、理解市场,而非盲目跟随信号。我们并不局限于只预测下一个价格点,目标更为宏大:构建一个能够识别市场行为模式、响应快速变化的环境,并适配市场周期不同阶段的模型。
在这套体系中,Mamba4Cast框架作为整体系统里的核心组件之一 —— 环境状态编码器(Environment State Encoder)。模型对市场的感知就始于此模块,它把一组数字转化为对市场正在发生什么的有意义表征。编码器相当于智能体的“交易感知之眼”,经过训练后,能够在行情信号于图表上确认之前,就识别出重要波动、隐藏形态与潜在入场机会。
本文继续采用Actor-Director-Critic(行动者–调度器–评估器)学习框架。我们训练的这套系统包含四大核心模型,每个模型负责交易决策中特定环节:
- 环境状态编码器 —— 智能体的“眼睛”,生成市场状态嵌入向量;
- 行动者(Actor) —— 根据收到的嵌入向量,输出具体的交易动作;
- 调度器(Director) —— 针对Actor输出的动作进行好坏分类,以此引导学习过程,规避错误决策;
- 评估器(Critic) —— 结合市场状态评估Actor动作的价值,并生成反馈信号用于策略优化。
所有模型的架构均通过CreateDescriptions方法定义。该方法的入参接收四个动态数组指针。上述编码器到Critic各个模型的网络层描述与参数,会依次写入这些数组中。这种设计可以灵活管控网络结构,便于根据新需求对框架进行调整。
bool CreateDescriptions(CArrayObj *&encoder, CArrayObj *&actor, CArrayObj *&director, CArrayObj *&critic ) { //--- CLayerDescription *descr; //--- if(!encoder) { encoder = new CArrayObj(); if(!encoder) 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; }
在CreateDescriptions方法体内部,首先会校验传入的四个动态数组指针是否有效。如有需要,程序会新建对象,确保后续能够正确写入网络架构描述信息,规避内存冲突风险。
接下来,我们开始定义环境状态编码器的网络架构。使用一个足够大的全连接层来接收输入数据。我们直接把交易终端输出的原始数据送入该层,不做任何预处理:开盘价、收盘价、最高价、最低价、成交量以及各类技术指标数值。
由于这些数据具备不同的统计特性与量纲,需要对齐它们的数据分布,以此稳定模型训练过程。批归一化层负责承担这项工作。它对输入向量做变换,使每个特征的均值趋近于0、方差趋近于1,从而加快收敛速度,提升训练稳定性。本文并未采用标准实现,而是使用改进版本:带噪声的归一化层。该方法通过人为增加训练数据多样性,从而提升模型的泛化能力。
经过这一组层之后,编码器得到的是已标准化、可供后续处理的特征表示。//--- Encoder encoder.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(!encoder.Add(descr)) { delete descr; return false; } //--- Layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBatchNormWithNoise; descr.count = prev_count; descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
接下来,我们通过CMamba4CastEmbedding模块生成时间步嵌入向量。特征向量将在此处融入两个关键时间周期的谐波信息:H1(1小时周期)与D1(日线周期)。通过添加正弦与余弦分量,模型可以获取典型小时级波动以及日复现节律的相关信息。这使得智能体能够识别典型的市场周期 —— 清晨时段的市场预热阶段、日间趋势行情以及晚间交投清淡期。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defMamba4CastEmbeding; prev_count = descr.count = HistoryBars; descr.window = BarDescr; int prev_out = descr.window_out = NSkills; { int temp[] = {PeriodSeconds(PERIOD_H1), PeriodSeconds(PERIOD_D1)}; if(ArrayCopy(descr.windows, temp) < (int)temp.Size()) return false; } descr.batch = 1e4; descr.optimization = ADAM; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; }
采用包含3个卷积窗口的多窗口卷积块,能够丰富嵌入向量的信息。需要重点说明的是,该卷积运算并非沿时间轴进行,而是在单根 K 线内横向执行,用来挖掘各个特征之间的关联关系。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMultiWindowsConvWPadOCL; descr.step = 3; descr.count = (prev_out + descr.step - 1) / descr.step; descr.window_out = 5; { int temp[] = {3, 5, 7}; if(ArrayCopy(descr.windows, temp) < (int)temp.Size()) return false; } descr.layers = prev_count; descr.batch = 1e4; descr.optimization = ADAM; descr.activation = SoftPlus; if(!encoder.Add(descr)) { delete descr; return false; } prev_out = int(descr.count * descr.window_out * descr.windows.Size());
在输入信号编码的最后阶段,我们会增加一层归一化层。其作用是消除特征分布的偏态,让待分析数据更加均匀,保证模型在训练过程中稳定运行。该步骤有助于避免梯度偏差,加快收敛速度,且不会损失模型性能。
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBatchNormOCL; descr.count = prev_count*prev_out; descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
接下来,我们直接开始搭建编码器架构。这里,我们计划对各个特征的单变量时间序列进行处理。因此,首先需要对特征张量做转置操作。
//--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronTransposeOCL; descr.count = prev_count; prev_count = descr.window = prev_out; prev_out = descr.count; descr.batch = 1e4; descr.optimization = ADAM; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; }
然而,需要注意的是,我们当前处理的特征,已经不再是之前从交易终端获取的原始特征。在这一阶段,已经生成了一套完全不同的增强特征集合,每一个特征都代表从交易终端获取的K线信息中划分出的一个特定片段。
Mamba4Cast编码器模块由卷积层堆叠结构与SSM(状态空间模型)模块组成。模块之间设置归一化层,用于对齐特征分布。卷积堆叠部分采用多窗口卷积模块。这里,每个卷积滤波器专注于各自的时间窗口,识别对应的市场形态。为保留数据维度,每个多窗口卷积模块之后都会接入最大池化层。该层提取每个窗口内滤波器输出的最大值,在不改变特征深度的前提下压缩空间维度。
//--- layer 6 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMultiWindowsConvWPadOCL; descr.step = 3; descr.count = (prev_out + descr.step - 1) / descr.step; int filt=descr.window_out = 5; { int temp[] = {3, 5, 7}; if(ArrayCopy(descr.windows, temp) < (int)temp.Size()) return false; } descr.layers = prev_count; descr.batch = 1e4; descr.optimization = ADAM; descr.activation = SoftPlus; if(!encoder.Add(descr)) { delete descr; return false; } prev_out = int(descr.count * descr.windows.Size()); //--- layer 7 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronProofOCL; descr.count = prev_count * prev_out; descr.window = filt; descr.step = filt; descr.batch = 1e4; descr.optimization = ADAM; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; } //--- layer 8 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMultiWindowsConvWPadOCL; descr.step = 3; descr.count = (prev_out + descr.step - 1) / descr.step; filt=descr.window_out = 5; { int temp[] = {3, 5, 7}; if(ArrayCopy(descr.windows, temp) < (int)temp.Size()) return false; } descr.layers = prev_count; descr.batch = 1e4; descr.optimization = ADAM; descr.activation = SoftPlus; if(!encoder.Add(descr)) { delete descr; return false; } prev_out = int(descr.count * descr.windows.Size()); //--- layer 9 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronProofOCL; descr.count = prev_count * prev_out; descr.window = filt; descr.step = filt; descr.batch = 1e4; descr.optimization = ADAM; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; } //--- layer 10 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBatchNormOCL; descr.count = prev_count*prev_out; descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
在SSM部分,我们没有采用 Mamba4Cast框架作者所提出的原版Mamba2,而是选用了Chimera模块。该模块可在二维平面上开展数据分析,能够捕捉时间分量与空间分量之间的交叉依赖关系。
//--- layer 11 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronChimera; //--- Window { int temp[] = {prev_out, prev_out/2}; //In, Out if(ArrayCopy(descr.windows, temp) < int(temp.Size())) return false; } //--- Units { int temp[] = {prev_count, prev_count*2}; //In, Out if(ArrayCopy(descr.units, temp) < int(temp.Size())) return false; } descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; } prev_out=descr.windows[1]; prev_count=descr.units[1]; //--- layer 12 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBatchNormOCL; descr.count = prev_count*prev_out; descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
在该模块的末端设置一层批归一化层。上文我们已经介绍过这种设计的优势。
我们的编码器架构包含两个串行模块组;每组均由多窗口卷积堆叠层、最大池化层以及基于Chimera的SSM模块构成,以此实现逐步丰富特征信息并保留上下文信息。
//--- layer 13 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMultiWindowsConvWPadOCL; descr.step = 3; descr.count = (prev_out + descr.step - 1) / descr.step; filt=descr.window_out = 5; { int temp[] = {3, 5, 7}; if(ArrayCopy(descr.windows, temp) < (int)temp.Size()) return false; } descr.layers = prev_count; descr.batch = 1e4; descr.optimization = ADAM; descr.activation = SoftPlus; if(!encoder.Add(descr)) { delete descr; return false; } prev_out = int(descr.count * descr.windows.Size()); //--- layer 14 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronProofOCL; descr.count = prev_count * prev_out; descr.window = filt; descr.step = filt; descr.batch = 1e4; descr.optimization = ADAM; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; } //--- layer 15 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMultiWindowsConvWPadOCL; descr.step = 3; descr.count = (prev_out + descr.step - 1) / descr.step; filt=descr.window_out = 5; { int temp[] = {3, 5, 7}; if(ArrayCopy(descr.windows, temp) < (int)temp.Size()) return false; } descr.layers = prev_count; descr.batch = 1e4; descr.optimization = ADAM; descr.activation = SoftPlus; if(!encoder.Add(descr)) { delete descr; return false; } prev_out = int(descr.count * descr.windows.Size()); //--- layer 16 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronProofOCL; descr.count = prev_count * prev_out; descr.window = filt; descr.step = filt; descr.batch = 1e4; descr.optimization = ADAM; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; } //--- layer 17 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBatchNormOCL; descr.count = prev_count*prev_out; descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; } //--- layer 18 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronChimera; //--- Window { int temp[] = {prev_out, prev_out/2}; //In, Out if(ArrayCopy(descr.windows, temp) < int(temp.Size())) return false; } //--- Units { int temp[] = {prev_count, prev_count*2}; //In, Out if(ArrayCopy(descr.units, temp) < int(temp.Size())) return false; } descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; } prev_out=descr.windows[1]; prev_count=descr.units[1]; //--- layer 19 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBatchNormOCL; descr.count = prev_count*prev_out; descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
我们采用两层连续卷积层作为解码器,用于在完整预测周期内分别对各单变量时间序列进行预测。在两层卷积之间使用SoftPlus激活函数,提供所需的非线性表达能力。解码器输出端采用双曲正切函数(tanh)。该函数的值域与归一化后的数据尺度相匹配,有助于保持模型输入与输出之间的一致性。
//--- layer 20 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; descr.count = 1; descr.window = prev_out; descr.step = prev_out; prev_out = descr.window_out = 4 * NForecast; descr.layers = prev_count; descr.activation = SoftPlus; if(!encoder.Add(descr)) { delete descr; return false; } //--- layer 21 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; descr.count = 1; descr.window = prev_out; descr.step = prev_out; prev_out = descr.window_out = NForecast; descr.layers = prev_count; descr.activation = TANH; if(!encoder.Add(descr)) { delete descr; return false; }
然而,需要注意的是,为了切换至单变量时间序列处理模式,我们此前已经对待分析特征张量执行了转置操作。因此,在将解码器输出结果继续向后传递之前,需要通过逆转置运算,把张量恢复为原始形态。
//--- layer 22 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronTransposeOCL; descr.count = prev_count; prev_count = descr.window = prev_out; prev_out = descr.count; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; }
恢复数据结构的下一步,是降维处理,可消除在生成嵌入向量阶段所扩充的维度。该操作用于将结果张量转换为与原始数据相兼容的格式。
//--- layer 23 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; descr.count = prev_count; descr.window = prev_out; descr.step = prev_out; prev_out = descr.window_out = BarDescr; descr.layers = 1; descr.activation = TANH; if(!encoder.Add(descr)) { delete descr; return false; }
编码器运行的最后一步是反归一化。在该步骤中,经过全部变换后得到的数值将还原至原始数据的量纲范围,从而可以对模型输出结果进行正确解读。
//--- layer 24 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronRevInDenormOCL; descr.count = prev_count * prev_out; descr.layers = 1; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; }
接下来,我们开始介绍Actor的架构。它的核心目标是结合已分析的市场环境状态,评估当前账户状态与持仓情况。Actor基于接收的信息生成交易决策,也就是一笔有望在风险最低的前提下获取最大收益的交易。
在这套机制里,输入Actor的张量代表当前账户状态。该张量汇总了账户余额、持仓量、交易方向以及其他反映交易智能体财务状况的关键参数。
CLayerDescription *latent = encoder.At(LatentLayer-1); //--- Actor 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 = 1e4; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
接下来,会接入一层交叉注意力层,用于将当前账户状态与市场行情进行匹配。在此过程中,编码器先前生成的环境隐向量表征将作为上下文信息使用。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronCrossDMHAttention; { int temp[] = {AccountDescr, // Inputs window latent.windows[1] // Cross window }; if(ArrayCopy(descr.windows, temp) < (int)temp.Size()) return false; } { int temp[] = {1, // Inputs units latent.units[1] // Cross units }; if(ArrayCopy(descr.units, temp) < (int)temp.Size()) return false; } descr.step = 4; // Heads descr.window_out = 32; 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 = 1e4; 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 = SoftPlus; descr.batch = 1e4; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = NActions; descr.activation = SIGMOID; descr.batch = 1e4; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
Director与Critic模型的架构较为相似:二者会结合当前市场环境,对Actor输出的动作张量进行分析。这两个模型会给出对应的评估结果 —— 从策略与风险的角度,判定Actor提出的交易动作是通过还是否决。
相关架构细节建议您自行查看和研究。所有待训练模型(包含Director与Critic)的完整架构源码,都在附件中提供。
模型训练
介绍完模型架构后,我们直接进入训练阶段。需要注意的是,Mamba4Cast框架的作者采用合成时间序列来测试和训练模型。该方法具备多项优势,尤其适合深度学习架构的开发与调试。
第一,合成数据能够完全控制数据参数:您可以预先设定振幅、频率、趋势、季节性以及噪声水平,甚至可以植入罕见异常事件。这能够针对性测试模型对各类时序特征的响应效果,并在严格受控环境下定位模型短板。
第二,人工生成的时间序列不存在脏数据或缺失数据带来的干扰,这在训练初期尤为关键。不同于真实市场数据,合成数据不存在数据缺口、采集伪影以及各类会掩盖模型真实误差的数据畸变。
第三是可扩展性优势。生成合成数据省去了历史数据采集与存储的成本,可以快速生成任意复杂度任务所需规模的训练数据集。对于资源消耗大且需要丰富均衡训练环境的模型而言,这点尤为重要。
最后,合成数据是压力测试的可靠工具。我们可以模拟极端市场情景,无需等待现实中这类行情真实发生。借助这类场景,能够测试模型应对突发剧烈波动的鲁棒性。
但另一方面,真实市场并非无菌实验室,而是变幻莫测的惊涛大海,规则往往随时变化。正因如此,尽管合成数据优点众多,但仅依靠合成数据完成模型训练与验证,是片面且存在潜在风险的方案。
第一,真实市场数据永远自带噪声、数据缺口、不易察觉的相关性以及脏数据,这些都是人工构造环境中不存在的要素。未曾在这类数据上训练过的模型,一旦投入实盘可能会表现不佳,尤其是在低流动性标的或高波动行情下。
第二,市场存在突发效应:新闻、制裁、企业并购、地缘政治、大型机构交易行为等因素都会影响价格,而这些几乎无法用合成数据可靠建模。这就要求模型必须能够在信息不完备的条件下自适应运行。
第三,市场参与者的行为模式(从恐慌到贪婪)会形成独特的市场动态,很难通过数据生成器复现。没有接触过这类模式的模型,容易在干净的合成环境上过拟合,在真实交易场景中无法识别关键信号。
因此,混合方案被认为是最高效的:训练前期使用合成数据,用于架构调参、超参数筛选以及训练流程调试。随后引入真实数据,使模型在真实环境中学习:经历错误、完成适应,并在不稳定环境下做出决策。
目前,我们还没有一套完备的金融合成序列生成器。然而,俗话说的好,如果大山不向穆罕默德走来,那穆罕默德就走向大山。
在训练第一阶段,我们将对真实历史数据做预处理,以此近似模拟合成数据的数据特性。如前文所述,合成序列一般不存在数据伪影、缺口以及其他市场噪声。为了让真实数据达到相近的干净程度,我们对每一项待分析特征使用短窗口简单移动平均。这样做可以:
- 平滑局部异常值与尖峰脉冲;
- 降低孤立异常点带来的影响;
- 提升模型在训练阶段的鲁棒性。
需要注意的是,我们刻意选用较小的平均窗口,以保留信号本身的动态特征与形态。我们的目的并非要把所有数据平滑成一条直线,而只是削弱那些会误导模型的噪声。我们会在EA的"…\MQL5\Experts\Mamba4Cast\StudyMA.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); matrix<float> hstate = matrix<float>::Zeros(1, HistoryBars * BarDescr); bool Stop = false; int average = 5; //--- uint ticks = GetTickCount();
完成准备工作后,我们开始搭建模型训练循环结构。外层循环用于控制训练的总迭代次数。
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( !cEncoder.Clear() || !cActor.Clear() || !cDirector.Clear() || !cCritic.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(!hstate.Assign(Buffer[tr].States[i].state) || MathAbs(hstate).Sum() == 0 || !hstate.Reshape(HistoryBars, BarDescr)) { iter -= Batch + start - i; break; }
在嵌套循环体内,我们从经验回放缓冲区读取用于描述环境状态的历史数据,并开启循环,借助移动平均对数据做平滑处理。
for(int h = HistoryBars - 1; h > 0; h--) { state = vector<float>::Zeros(BarDescr); for(int a = MathMax(h - average + 1, 0); a <= h; a++) state += hstate.Row(a); if(!hstate.Row(state / MathMin(average, h + 1), h)) { iter -= Batch + start - i; break; } }
我们将经过平滑处理后的值存入用于描述待分析环境状态的数据缓冲区。
if(!hstate.Reshape(1, HistoryBars * BarDescr) || !bState.AssignArray(hstate.Row(0))) { iter -= Batch + start - i; break; }
接下来请注意:如果要让Mamba4Cast框架正常运行,我们需要获取每根K线对应的时间戳。然而,在我们先前搭建的经验回放缓冲区结构中,每个环境状态仅保留一个时间戳,对应最后一根K线。为构建所需的时间戳缓冲区,我们从经验回放缓冲区中的当前状态开始,沿着环境状态反向遍历至指定分析深度,以此收集全部时间戳。
bTime.Clear(); bTime.Reserve(HistoryBars); double time = (double)Buffer[tr].States[i].account[7]; for(int t = i; t >= MathMax(0, i - HistoryBars + 1); t--) if(!bTime.Add((float)(double)Buffer[tr].States[t].account[7])) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; } if(bTime.Total() < HistoryBars) { float period = MathMin(Buffer[tr].States[i + 1].account[7] - Buffer[tr].States[i].account[7], Buffer[tr].States[i + 2].account[7] - Buffer[tr].States[i + 1].account[7]); do { if(!bTime.Add(bTime[-1] - period)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; } } while(bTime.Total() < HistoryBars); } if(bTime.GetIndex() >= 0) if(!bTime.BufferWrite()) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
我们将直接复用同类程序中用于填充账户状态描述缓冲区的算法,不做任何修改。
//--- 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])); 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); double x = time / (double)(D'2024.01.01' - D'2023.01.01'); bAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = time / (double)PeriodSeconds(PERIOD_MN1); bAccount.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0)); x = time / (double)PeriodSeconds(PERIOD_W1); bAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = time / (double)PeriodSeconds(PERIOD_D1); bAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); if(bAccount.GetIndex() >= 0) if(!bAccount.BufferWrite()) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
准备好所需输入数据后,我们对所有模型执行前向传播。首先执行前向传播的是环境状态编码器。它使用经过平滑处理的市场状态描述数据以及时间戳缓冲区作为输入。
//--- Feed Forward if(!cEncoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)GetPointer(bTime))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
接下来执行Actor。它读取账户状态缓冲区以及编码器隐状态输出的环境上下文信息,并对此开展分析。
if(!cActor.feedForward((CBufferFloat*)GetPointer(bAccount), 1, false, GetPointer(cEncoder), LatentLayer)) { 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; } }
生成目标值之后,我们便可调用反向传播方法来更新编码器的参数。
//--- State Encoder Result.AssignArray(fstate); if(!cEncoder.backProp(Result, (CBufferFloat*)NULL, NULL)) { 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(pos > 0 && tp > 0) { sl = (float)MathMax(MathMin(MathAbs(sl) / (MaxSL * Point()), 1), 0.01); tp = float(MathMin(tp / (MaxTP * Point()), 1)); result[0] = MathMax(result[0] - result[3], 0.011f); result[5] = result[1] = tp; result[4] = result[2] = sl; result[3] = 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(pos > 0 && tp < 0) { sl = (float)MathMax(MathMin(MathAbs(sl) / (MaxSL * Point()), 1), 0.01); tp = float(MathMin(-tp / (MaxTP * Point()), 1)); result[3] = MathMax(result[3] - result[0], 0.011f); result[2] = result[4] = tp; result[1] = result[5] = sl; result[0] = 0; bActions.AssignArray(result); } } else { ulong argmin = target.ArgMin(); ulong argmax = target.ArgMax(); float max_sl = float(MaxSL * Point()); while(argmax > 0 && argmin > 0) { if(argmax < argmin && target[argmax] / 2 > MathAbs(target[argmin]) && MathAbs(target[argmin]) < max_sl) break; if(argmax > argmin && target[argmax] < MathAbs(target[argmin] / 2) && target[argmax] < max_sl) 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(pos > 0 && 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[5] = result[1] = tp; result[4] = result[2] = sl; result[3] = 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(pos > 0 && 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[2] = result[4] = tp; result[1] = result[5] = sl; result[0] = 0; bActions.AssignArray(result); } } } } }
需要注意的是,该交易决策是基于上一步执行的交易生成的。智能体并非在脱离环境的真空中处理孤立信号,而是构建一连串动作序列。后续每一项决策都建立在已经完成的交易之上。依靠这种方式,最终得到的不是一堆互不关联的订单,而是一套完整的策略:每一个决策在逻辑上都承接前一个决策。我们正是使用这些“近乎完美”的交易样本来训练Actor。
//--- Actor Policy bActions.GetData(result); if(!cActor.backProp(GetPointer(bActions), (CNet*)GetPointer(cEncoder), LatentLayer)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
我们同样利用这些交易样本训练Critic。目标是构建一个动作评估函数,使其能够紧密拟合Actor的实际策略。我们向此Critic输入同一组“近乎完美”的交易序列,并根据下一根K线的价格变动计算奖励值。
//--- Critic if(!cCritic.feedForward(GetPointer(bActions), 1, false, (CNet*)GetPointer(cEncoder), LatentLayer)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; } float reward = float((bActions[0] - bActions[3]) * fstate[0, 0] / Point()); Result.Clear(); if(!Result.Add(reward) || !cCritic.backProp(Result, (CNet*)GetPointer(cEncoder), LatentLayer) || !cEncoder.backPropGradient((CBufferFloat*)NULL, (CBufferFloat*)NULL, LatentLayer, true) ) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
通过这种方式,Critic能够基于真实价格变化,精准评估Actor的动作,辅助训练出更精确且鲁棒的策略。
而训练Director时,思路会有所不同。不能只向它输入正样本,否则模型永远无法区分动作的优劣。因此,我们每一步都随机选择训练样本的类型:
- 正样本:输入根据真实数据计算得到“近乎完美”的动作,并打上标签“1”(代表有效/成功)。
- 负样本:生成与动作空间维度相同的随机值向量,并打上标签“0”(代表无效/失败)。
之后,调用Director的前向传播与反向传播方法。
//--- Director Result.Clear(); if((MathRand() / 32767.0) > 0.5) Result.Add(1); else { target = vector<float>::Zeros(NActions); for(int i = 0; i < NActions; i++) target[i] = float(MathRand() / 32767.0); bActions.AssignArray(target); Result.Add(0); } if(!cDirector.feedForward(GetPointer(bActions), 1, false, (CNet*)GetPointer(cEncoder), LatentLayer) || !cDirector.backProp(Result, (CNet*)GetPointer(cEncoder), LatentLayer) ) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
该方法确保Director不仅学会认可优质决策,还能识别劣质决策中的缺陷,帮助Actor规避无效动作。
剩下的工作就是向用户输出训练进度信息,然后进入循环的下一次迭代。
if(GetTickCount() - ticks > 500) { double percent = double(iter + i - start) * 100.0 / (Iterations); string str = StringFormat("%-12s %6.2f%% -> Error %15.8f\n", "Encoder", percent, cEncoder.getRecentAverageError()); str += StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Actor", percent, cActor.getRecentAverageError()); str += StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Director", percent, cDirector.getRecentAverageError()); str += StringFormat("%-16s %6.2f%% -> Error %15.8f\n", "Critic", percent, cCritic.getRecentAverageError()); Comment(str); ticks = GetTickCount(); } } }
模型训练流程结束后,我们记录结果并启动EA的关闭流程。
Comment(""); //--- PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Encoder", cEncoder.getRecentAverageError()); PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Actor", cActor.getRecentAverageError()); PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Director", cDirector.getRecentAverageError()); PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Critic", cCritic.getRecentAverageError()); ExpertRemove(); //--- }
针对基于真实历史数据的离线与在线模型训练程序,我们仅做了少量针对性修改,改动集中在时间戳缓冲区的构建部分。此处不对这些改动展开详细探讨。完整代码已放在附件中,您可以自行研读。环境交互程序同样一并提供。
本系统的训练分为三个阶段,每个阶段逐步让模型适配真实市场环境。
第一阶段:采用前文所述的平滑方法,在真实历史数据上开展初始离线训练。本阶段训练集不做更新。我们在环境状态编码器中使用带噪声的批量归一化层,该机制能够对输入数据做充分的数据增强,从模型视角极大扩充训练样本集。
想象一下:每一根K线、每一个指标都会经过一个滤波器,产生轻微畸变。由此生成大量同一市场场景的变体样本,避免模型单纯反复记忆固定模式。最终,编码器学会在存在微小扰动时,依然识别出行情走势的本质特征。
第二阶段仍为离线训练,使用未经平滑处理的原始历史数据:模型直面市场的真实面貌 —— 剧烈脉冲、行情跳水与嘈杂波动。从理想化市场过渡到原始数据,能够帮助智能体适应真实市场波动,维持预测稳定性,避免被突发异常行情干扰。我们密切监控预测误差的变化趋势,一旦该指标连续多轮迭代稳定在较小值附近,就停止训练;这代表模型已经完成对数据的适配。
最后进入第三阶段,智能体在策略测试器中进行在线学习。这里,我们重点观察资金曲线的表现。如果连续多轮测试后,资金曲线停滞且达不到预期的增长,则温和回退至离线训练:利用“近乎最优”的轨迹调整Actor策略,再次启动微调。
该分阶段训练方案,可同时保证较高的预测精度与任意市场环境下交易决策的鲁棒性。
测试
为适配并实现Mamba4Cast框架作者提出的方法,我们开展了大量工作。现在,到了关键的验证环节 —— 在真实数据上检验这套实现方案的有效性。
我们采用2024全年的EURUSD(欧元兑美元)1分钟报价数据作为训练集。为保证实验严谨性,最终测试选用2025年1月至3月的历史数据,该时间段的数据并未参与模型训练。其余所有参数保持不变,以此确保策略评估客观且公平。
测试结果呈现如下。

必须承认,该模型的交易频率相当高。平均持仓时长仅略高于3分钟。在整个测试周期内,模型一共执行了2677笔交易,其中1240笔为盈利平仓。尽管亏损交易的数量略多,但模型在测试时段内整体实现盈利,资金曲线呈现出较为平稳的增长。这部分归因于模型采用较小止损进场,并对持仓进行动态管理。平均亏损交易与最大亏损交易之间差距较小,也印证了这一点。与此同时,单笔最大盈利交易的收益,接近单笔平均盈利的7倍。
结论
我们完整地覆盖了全流程:从Mamba4Cast框架的设计思路与模型架构,到实际实现、模型训练,再到基于真实历史数据的严谨测试。我们训练编码器感知市场,训练Actor在考量风险的前提下做出交易决策,训练Director筛选最优与劣质信号,训练Critic依据实际结果评估动作。
基于2025年1至3月EURUSD1分钟(M1)行情的测试结果表明:Mamba4Cast不仅具备预测能力,还能够抵御噪声干扰、应对突发行情,并在较长周期内保持盈利。
然而,本文提供的所有程序仅作演示之用,目的是展示Mamba4Cast框架的能力。在实盘交易中使用这套方案前,必须使用具备充分代表性的数据集训练模型,并开展全面测试。只有这样,才能保障交易策略的可靠性与安全性。
链接
文中所用的程序
| # | 名称 | 类型 | 描述 |
|---|---|---|---|
| 1 | Research.mq5 | EA | EA示例集合 |
| 2 | ResearchRealORL.mq5 | EA | 使用Real-ORL方法的EA示例集合 |
| 3 | Study.mq5 | EA | 用于离线模型训练的EA |
| 4 | StudyMA.mq5 | EA | 基于均值化数据进行模型离线训练的EA |
| 5 | StudyOnline.mq5 | EA | 用于在线模型训练的EA |
| 6 | Test.mq5 | EA | 用于模型测试的EA |
| 7 | Trajectory.mqh | 类库 | 用于描述系统状态与模型架构的结构 |
| 8 | NeuroNet.mqh | 类库 | 用于创建神经网络的类库 |
| 9 | NeuroNet.cl | 库 | OpenCL程序代码库 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/18219
注意: MetaQuotes Ltd.将保留所有关于这些材料的权利。全部或部分复制或者转载这些材料将被禁止。
本文由网站的一位用户撰写,反映了他们的个人观点。MetaQuotes Ltd 不对所提供信息的准确性负责,也不对因使用所述解决方案、策略或建议而产生的任何后果负责。
在 MQL5 中实现来自其他语言的实用模块(第 03 部分):移植 Python 的 Schedule 模块,打造增强版 OnTimer
新手在交易中的10个基本错误
从基础到进阶:图表对象(I)