English Русский Español Deutsch 日本語 Português
preview
神经网络在交易中的应用:演员—导演—评论家框架(终篇)

神经网络在交易中的应用:演员—导演—评论家框架(终篇)

MetaTrader 5交易系统 |
176 2
Dmitriy Gizlyk
Dmitriy Gizlyk

概述

上一篇文章中,我们了解了演员-导演-评论家框架的理论方面,它是演员-评论家架构的扩展版本。经典的演员-评论家架构已成为许多成功的强化学习 ( RL ) 算法的基石。在这种设置下,智能体分为两个部分:演员生成动作,而评论家根据从环境中获得的奖励来评估这些动作。这种结合使得策略能够逐步发展,动作会变得更具针对性,评估也愈发准确。然而,尽管这种架构优雅且有效,但在实际应用中,尤其是在交易领域,它遇到了几个重大局限性。

主要问题出现在训练初期。此时,演员尚未学会哪些行为是有益的,而评论家也无法充分评价这些行为,因为它也才刚刚开始学习。这造成了一种盲目试探的状态:智能体在接收无信息或延迟反馈的同时,会执行大量随机、低效、有时甚至有害的动作。在市场环境中,一旦出错可能会付出高昂的代价,因此这种方法会变得风险过高且不稳定。

为了解决这一根本问题,演员-导演-评论家框架的作者引入了一个额外的组件 — 导演 — 这为系统增加了一个额外的评估渠道。与评论家根据环境奖励对动作进行连续值评估不同,导演以二元方式对行动进行分类:它们是否符合给定的策略。这使得智能体能够更快、更有效地在决策空间中进行导航。

需要强调的是,导演不是过滤器,也不会限制智能体的动作自由。相反,它通过提供更明确的反馈来补充评论家的评价。评论家可能对某个行为的质量感到不确定(尤其是在训练的早期阶段),但导演可以立即指出该行为是否符合其所学习的行为模型。这有助于演员避免重复已被认定为错误的行为,从而节省训练资源并加快制定稳健策略。

其结果是三个组成部分之间产生协同作用:演员学会选择动作,评论家学会根据预期回报来评价这些动作,而导演则明确指出应该完全避免的动作。这创建了一个双重反馈系统 — 连续反馈和二元反馈 — 使智能体能够更快地排除无望的方向,并专注于有效的策略。

下图展示了作者给出的演员-导演-评论家框架的示意图。

作者提供的演员-导演-评论家框架示意图

在上一篇文章的实践部分,提供了可训练模型架构的详细描述。值得注意的是,与作者最初实现的框架相比,已经进行了几处重大修改。

首先,我们将提出的概念应用于前面讨论过的 HiSSD 多智能体框架。此外,在我们的实现中,导演评论家都是基于智能体共享技能的潜在表示进行训练的,这些潜在表示是从环境状态编码器的内部层提取的压缩特征,而不是基于原始环境特征本身。这种方法能够形成一种通用的动作模式表征,使得动作能够在全局行为逻辑的背景下进行评估,而非局限于特定的市场情境。因此,该系统能够接收到更稳定且具有战略意义的反馈,这在信息有限且不确定性高的条件下至关重要,而金融市场往往就是这种情况。

在本文中,重点转向了模型训练。与原方法相比,该过程也引入了几项改进。

一个关键的不同之处在于训练被划分为两个阶段。在第一阶段,所有系统组件都会使用预先收集的训练数据集进行离线训练。该阶段允许智能体积累初步经验并建立基准行为策略,而不会影响实时交易操作。每个模型都使用其自身的目标函数进行训练,该目标函数是专门针对其在架构中的角色量身定制的。

第二阶段包括在线微调。此时,智能体开始直接与环境进行交互。演员实时改进其策略,而评论家导演则不断学习,根据更新的数据提高其评估和分类的质量。这一阶段使智能体能够在保持线下训练期间确立的战略方向的同时,适应当前的市场状况。

这种两阶段方法可以在稳定性和适应性之间取得平衡。该智能体首先制定一个可靠的初始策略,然后根据现实世界的条件对其进行改进。因此,我们期望能够获得一个稳健且可高效训练的系统,该系统能够在不牺牲战略一致性的前提下,适应不断变化的市场动态。


离线训练

第一阶段训练(离线训练)的算法实现为 EA “ …\Experts\ADC\Study.mq5 ”。其大部分代码是从之前的项目继承而来的,这并不奇怪。可训练模型的架构设计很大程度上基于我们已经熟悉的 HiSSD 框架内开发的概念。

这种连续性使我们能够利用经过验证的解决方案,而无需从头开始。然而,一些修改是不可避免的。

新增的两个组件(评论家导演)极大地扩展了系统的功能。它们的整合需要对模型训练逻辑进行更改。在本文中,我们将只关注 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();

准备阶段完成后,我们将进入训练过程本身,该过程被组织成一个嵌套循环系统。外层循环对小批量数据进行指定次数的训练迭代。

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()
   || !cTask.Clear()
   || !cActor.Clear()
   || !cProbability.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(!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]));
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(!cEncoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)NULL))
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   Stop = true;
   break;
  }
if(!cTask.feedForward((CBufferFloat*)GetPointer(bState), 1, false, GetPointer(cEncoder), LatentLayer))
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   Stop = true;
   break;
  }
if(!cActor.feedForward((CBufferFloat*)GetPointer(bAccount), 1, false, GetPointer(cTask), -1))
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   Stop = true;
   break;
  }
if(!cProbability.feedForward(GetPointer(cEncoder), 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;
     }
  }

这些数据作为我们环境状态编码器的目标值。因此,我们可以通过该模型进行反向传播,以优化其参数并减小预测值与目标值之间的差异。

//--- State Encoder
Result.AssignArray(fstate);
if(!cEncoder.backProp(Result, (CBufferFloat*)NULL, NULL))
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   Stop = true;
   break;
  } 

关于未来环境状态的信息也为构建“近乎完美”的交易操作张量奠定了基础。重要的是,每项新操作的生成都会考虑到之前的操作,这使我们能够构建一个连贯的交易策略,而非孤立的冲动行为。因此,创建了一个上下文关联的交易序列,其中每个后续动作都由前一个动作的逻辑决定。在离线训练期间,这一点尤为重要,因为在此期间智能体无法从环境中获得实时反馈,而必须从已记录的数据中提取模式。

这种方法有助于减轻市场噪声的影响,市场噪声在现实世界的数据中表现为随机波动。在“近乎完美”的轨迹中,智能体的行为变得更加流畅、理性,且更具策略一致性。这有可能在加速模型训练的同时,培养出一个稳健的交易逻辑,该逻辑在后续的在线操作中也可进行适配。

前一篇文章已经详细描述了构建“近乎完美”的交易操作的过程,这里无需赘述。

由此得到的“近乎完美”的交易操作的张量,将作为训练我们高级演员模块的目标值。

//--- Actor Policy
if(!cActor.backProp(GetPointer(bActions), (CNet*)GetPointer(cTask), -1))
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   Stop = true;
   break;
  }

在这个阶段,我们继续进行训练架构中最重要的组成部分 — 训练演员的动作评估模型,即评论家导演。这些组件负责生成反馈信号,这些信号指导智能体的策略,帮助其区分有效行动和无效行动。

然而,在这里我们遇到了一个根本性的挑战:在任何给定的环境状态下,可能采取的动作空间都非常大。在合理的计算资源范围内,评估演员可能做出的所有决策实际上是不切实际的。此外,这些动作中有很多实际上永远不会由智能体执行,因此几乎没有或根本没有训练价值。

因此,评论家训练通常遵循本地化评估策略。我们并不试图覆盖整个动作空间,而是专注于智能体在当前时间步做出的实际决策周围的邻域。正是在这个局部子空间内寻找优化方向 — 向量位移,这些位移有可能增加目标函数,从而提高盈利能力。

这就是“近乎完美轨迹”方法具有显著优势的地方。由于我们能够通过有限地预测未来状态来生成参考行为,因此我们可以将评估的重点从智能体实际执行的行为转移到其本应执行的行为上。换言之,我们训练评论家的目的不仅是区分好动作与坏动作,还要使其评价倾向于那些接近“近乎完美”轨迹所代表的战略理想的动作。

在这种范式下,我们通过评论家进行前向传播,以评估“近乎完美”的交易操作。

//--- Critic
if(!cCritic.feedForward(GetPointer(bActions), 1, false, (CNet*)GetPointer(cEncoder), LatentLayer))
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   Stop = true;
   break;
  }

然后立即执行反向传播过程,以最大限度地减少评论家的评估与根据实际历史市场数据计算出的值之间的偏差。

float reward = float((result[0] - result[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;
  }

导演的训练过程在概念上与评论家的训练过程相似,但也存在一些重要的区别。与任何分类器一样, 导演需要一组平衡的正例和反例,从中可以学习“好”和“坏”智能体动作之间的界限。

正例相对比较简单明了。这些交易操作是在“近乎完美”的轨迹框架内产生的。此类动作体现了基于对未来环境状态分析而做出的具有战略眼光的明智决策,因此应归为有利类别。

然而,仅凭正例无法训练出一个可靠的分类器。它还需要一个具有代表性的负面示例集合,这些示例展现出的行为与我们的目标不一致。这就带来了一个方法论上的挑战:训练数据集本质上并没有涵盖所有可能的动作空间。它只包含智能体在过去实际做出的决策。

为了解决这个问题,我们采用了一种简单而有效的启发式方法。作为反例,我们使用在智能体动作空间允许范围内生成的随机值集。这些随机行为极有可能与模型的战略目标不符,因此可被视为行为噪声或错误。

值得注意的是,训练过程中正例和反例的交替也是随机的。这可以防止模型对任一类别过拟合,并有助于建立更稳健的分类边界。这种方法使导演在发出更清晰、更自信的信号时,更具灵活性和普适性 — 在金融市场高度不确定的环境下,这是一种尤为宝贵的特质。

因此,导演成为一个强大的二元判别器,能够果断地拒绝无益的动作,并将智能体引导到战略决策空间中更有前景的区域。

//--- 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)
   || !cEncoder.backPropGradient((CBufferFloat*)NULL, (CBufferFloat*)NULL, LatentLayer, true)
  )
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   Stop = true;
   break;
  }

接下来,我们将训练低级控制器和预测模型,该模型负责估计未来价格变动方向的概率。这些代码块是从上一个项目中直接移植过来的,未做任何修改,上一个项目中对它们进行了详细描述。因此,建议读者自行参阅。离线模型训练 EA 的完整源代码见附件 。



在线训练

我们工作的下一阶段是在线环境中对可训练模型进行微调,在此过程中我们会遇到完全不同类型的挑战和约束。离线阶段强调基于历史数据的深入、泛化学习以及“近乎完美轨迹”的方法,而在线阶段则专注于适应实时条件。

在线学习的关键优势之一是能直接获得来自环境的反馈。演员做出决定,动作执行,其结果几乎立即为人所知。这样一来,智能体的行为就能得到及时调整,强化成功的策略,同时摒弃无效的策略。

然而,这种方法并非没有明显的局限性。其中最重要的是,无法像离线训练时构建“近乎完美的轨迹”那样,预见未来的状态。在在线环境中,智能体必须仅根据当前状态及其现有策略做出决策,而无法获取任何“未来知识”。这从根本上改变了学习条件,并要求向更经典的强化学习范式过渡。

因此,学习过程依赖试错机制,而决策的质量只能通过事后回顾来评估。因此,评价模型 — 评论家导演 — 的重要性显著增加。这些组件作为演员的内部咨询系统,指导其行为。然而,与离线阶段不同的是,它们现在会在实时交易活动中不断进行微调,实时适应不断变化的市场状况。

让我们一步一步地来分析它的实现过程。在线训练算法在 EA “ ...\Experts\ADC\StudyOnline.mq5 ”中实现。鉴于本文篇幅有限,我们将只重点详细探讨 OnTick 方法。此方法处理传入的 tick 事件。这也是我们将实施主要训练算法的地方。

void OnTick()
  {
//---
   if(!IsNewBar())
      return;

首先,需要指出的是,我们的模型仅分析收盘蜡烛图上的历史数据,并非设计为对每一个新到来的 tick 事件都作出响应。因此,在下一根蜡烛图收盘之前,没有必要重复分析环境状态,因为模型输出结果不会改变。因此,为了最大限度地减少不必要的计算,该方法首先检查是否有新的 K 线收盘。否则,我们就等待下一个 tick。

一旦新的 K 线收盘,我们就向终端请求指定深度内的历史数据,并构建输入数据缓冲区。

   int bars = CopyRates(Symb.Name(), TimeFrame, iTime(Symb.Name(), TimeFrame, 1), HistoryBars, Rates);
   if(!ArraySetAsSeries(Rates, true))
      return;
//---
   RSI.Refresh();
   CCI.Refresh();
   ATR.Refresh();
   MACD.Refresh();
   Symb.Refresh();
   Symb.RefreshRates();
//---
   float atr = 0;
   for(int b = 0; b < (int)HistoryBars; b++)
     {
      float open = (float)Rates[b].open;
      float rsi = (float)RSI.Main(b);
      float cci = (float)CCI.Main(b);
      atr = (float)ATR.Main(b);
      float macd = (float)MACD.Main(b);
      float sign = (float)MACD.Signal(b);
      if(rsi == EMPTY_VALUE || cci == EMPTY_VALUE || atr == EMPTY_VALUE ||
         macd == EMPTY_VALUE || sign == EMPTY_VALUE)
         continue;
      //---
      int shift = b * BarDescr;
      sState.state[shift] = (float)(Rates[b].close - open);
      sState.state[shift + 1] = (float)(Rates[b].high - open);
      sState.state[shift + 2] = (float)(Rates[b].low - open);
      sState.state[shift + 3] = (float)(Rates[b].tick_volume / 1000.0f);
      sState.state[shift + 4] = rsi;
      sState.state[shift + 5] = cci;
      sState.state[shift + 6] = atr;
      sState.state[shift + 7] = macd;
      sState.state[shift + 8] = sign;
     }
//---

然后我们加载账户状态和当前未平仓位的信息。

   sState.account[0] = (float)AccountInfoDouble(ACCOUNT_BALANCE);
   sState.account[1] = (float)AccountInfoDouble(ACCOUNT_EQUITY);
//---
   double buy_value = 0, sell_value = 0, buy_profit = 0, sell_profit = 0;
   double position_discount = 0;
   double multiplyer = 1.0 / (60.0 * 60.0 * 10.0);
   int total = PositionsTotal();
   datetime current = TimeCurrent();
   for(int i = 0; i < total; i++)
     {
      if(PositionGetSymbol(i) != Symb.Name())
         continue;
      double profit = PositionGetDouble(POSITION_PROFIT);
      switch((int)PositionGetInteger(POSITION_TYPE))
        {
         case POSITION_TYPE_BUY:
            buy_value += PositionGetDouble(POSITION_VOLUME);
            buy_profit += profit;
            break;
         case POSITION_TYPE_SELL:
            sell_value += PositionGetDouble(POSITION_VOLUME);
            sell_profit += profit;
            break;
        }
      position_discount += profit - (current - PositionGetInteger(POSITION_TIME)) * multiplyer * MathAbs(profit);
     }
   sState.account[2] = (float)buy_value;
   sState.account[3] = (float)sell_value;
   sState.account[4] = (float)buy_profit;
   sState.account[5] = (float)sell_profit;
   sState.account[6] = (float)position_discount;
   sState.account[7] = (float)Rates[0].time;

接下来,我们生成时间戳谐波。

   bTime.Clear();
   double time = (double)Rates[0].time;
   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();
//---
   bAccount.Clear();
   bAccount.Add((float)((sState.account[0] - PrevBalance) / PrevBalance));
   bAccount.Add((float)(sState.account[1] / PrevBalance));
   bAccount.Add((float)((sState.account[1] - PrevEquity) / PrevEquity));
   bAccount.Add(sState.account[2]);
   bAccount.Add(sState.account[3]);
   bAccount.Add((float)(sState.account[4] / PrevBalance));
   bAccount.Add((float)(sState.account[5] / PrevBalance));
   bAccount.Add((float)(sState.account[6] / PrevBalance));
   bAccount.AddArray(GetPointer(bTime));
//---
   if(bAccount.GetIndex() >= 0)
      if(!bAccount.BufferWrite())
         return;
//---
   bState.AssignArray(sState.state);

值得注意的是,当前环境状态的表示结果将用于两个不同的目的。当然,它将作为前馈传递的输入,通过智能体进行传递,从而生成新的交易动作。但是,只有在下一个 K 线形成后,这些动作才能获得环境奖励。这会造成时间上的缺口。

另一方面,在这个阶段,我们可以评估智能体在前一个时间步所采取的行动的有效性。在更新模型状态之前进行此评估符合我们的利益,因为模型状态中仍包含对先前环境状态的分析结果。

因此,我们首先将新构建的环境状态表示输入到目标模型中,并在假设智能体遵循其当前行为策略的前提下,生成对状态的预测估计。

if(!bFirstRun)
  {
   //--- Target Nets
   if(!cEncoder[1].feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)NULL)
      || !cTask[1].feedForward((CBufferFloat*)GetPointer(bState), 1, false,
                                                    (CNet*)GetPointer(cEncoder[1]), LatentLayer)
      || !cActor[1].feedForward((CBufferFloat*)GetPointer(bAccount), 1, false,
                                                                        GetPointer(cTask[1]), -1)
      || !cCritic[2].feedForward(GetPointer(cActor[1]), -1, GetPointer(cEncoder[1]), LatentLayer)
      || !cCritic[3].feedForward(GetPointer(cActor[1]), -1, GetPointer(cEncoder[1]), LatentLayer)
      || !cCritic[4].feedForward(GetPointer(cActor[1]), -1, GetPointer(cEncoder[1]), LatentLayer)
      || !cCritic[5].feedForward(GetPointer(cActor[1]), -1, GetPointer(cEncoder[1]), LatentLayer)
     )
     {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      return;
     }

目标模型的使用在保持行为策略连贯稳定的同时,最大限度地减少了市场噪声的影响,发挥着重要作用。这使得智能体不仅可以考虑眼前的回报,还可以考虑预期的未来收益。

接下来,我们开始训练评论家。值得一提的是,“演员导演评论家”框架的作者提出使用两个并行运作的评论家,每个评论家都有自己的一对目标模型。首先,我们构建用于评估智能体最近动作的目标值,其中考虑了当前阶段第一个评论家获得的奖励,并对模型进行前馈和反向传播。

//--- Critic 1
cCritic[2].getResults(Result);
float reward = Result[0];
cCritic[4].getResults(Result);
reward = (reward + Result[0]) / 2 * DiscFactor + float(sState.account[1] - PrevEquity);
Result.Clear();
if(!Result.Add(reward)
   || !cCritic[0].backProp(Result, (CNet*)GetPointer(cEncoder[0]), LatentLayer))
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   return;
  }

需要注意的是,原始框架建议根据所执行动作的最小估计值来传播误差梯度。然而,在我们的实现过程中,我们采用了一种略有不同的方法。我们将从平均评估误差最低的模型中传播误差梯度。因此,我们从最低估计转变为更准确的估计。

在线训练的第二个重要方面是及时更新演员策略。最明显且技术上最简单的解决方案是使用一个固定的迭代计数器。经过预定步骤数后,智能体的策略将被更新。在真正的在线学习环境中,这种方法是完全合理的,因为每个环境状态都是独一无二的,无法重现。

但是,我们打算利用MetaTrader 5 策略测试器的强大模拟功能。这使我们能够多次重放相同的事件序列,从而有效地模拟在线流程,同时允许进行多次训练迭代。

这会引入一个潜在问题。如果我们采用简单的固定迭代更新计划,那么每次训练运行都会在完全相同的环境状态下更新演员的策略。这显著降低了训练过程的可变性,引入了人为偏差,并阻碍了智能体学习稳健模式的能力。

为了避免这种影响,我们采用了一种随机触发策略更新的方法。我们没有使用确定性计数器,而是生成一个随机整数,并且仅当该值能被一个预定义数字整除时才执行策略更新。这种机制在保持优化所需规律性的同时,使更新在时间上具有弱可预测性。因此,它有助于防止过度拟合特定数据段。

if(cCritic[0].getRecentAverageError() <= cCritic[1].getRecentAverageError() &&
   (MathRand() % ActorUpdate) == 0)
   if(!cActor[0].backPropGradient((CNet*)GetPointer(cTask[0]), -1, -1, false)
      || !cTask[0].backPropGradient((CNet*)GetPointer(cEncoder[0]), LatentLayer, -1, true)
     )
     {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      return;
     }

对第二个评论家重复相同的步骤。

//--- Critic 2
cCritic[3].getResults(Result);
reward = Result[0];
cCritic[5].getResults(Result);
reward = (reward + Result[0]) / 2 * DiscFactor + float(sState.account[1] - PrevEquity);
Result.Clear();
if(!Result.Add(reward)
   || !cCritic[1].backProp(Result, (CNet*)GetPointer(cEncoder[0]), LatentLayer))
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   return;
  }
if(cCritic[0].getRecentAverageError() > cCritic[1].getRecentAverageError() &&
   (MathRand() % ActorUpdate) == 0)
   if(!cActor[0].backPropGradient((CNet*)GetPointer(cTask[0]), -1, -1, false)
      || !cTask[0].backPropGradient((CNet*)GetPointer(cEncoder[0]), LatentLayer, -1, true)
     )
     {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      return;
     }

导演的训练过程要简单得多。盈利的交易操作被视为正例,而所有其他交易操作都被视为反例。

//--- Director
Result.Clear();
if((sState.account[1] - PrevEquity) > 0)
   Result.Add(1);
else
   Result.Add(0);
if(!cDirector.backProp(Result, (CNet*)GetPointer(cEncoder[0]), LatentLayer)
   || !cActor[0].backPropGradient((CNet*)GetPointer(cTask[0]), -1, -1, false)
   || !cTask[0].backPropGradient((CNet*)GetPointer(cEncoder[0]), LatentLayer, -1, true)
  )
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   return;
  }

在这里,我们还根据最近收盘的蜡烛图的方向更新预测模型的参数。

 //--- Probability
 vector<float> target = vector<float>::Zeros(NActions / 3);
 if(sState.state[0] > 0)
    target[0] = 1;
 else
    if(sState.state[0] < 0)
       target[1] = 1;
 if(!Result.AssignArray(target)
    || !cProbability.backProp(Result, (CNet*)GetPointer(cEncoder[0]), LatentLayer)
    || !cEncoder[0].backPropGradient((CBufferFloat*)NULL, (CBufferFloat*)NULL, LatentLayer)
   )
   {
    PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
    return;
   }
}

模型优化迭代完成后,我们将开始生成新的交易操作。先前构建的当前环境状态表示现在传递给可训练模型,包括评估演员动作的模型。

//--- New state
   if(!cEncoder[0].feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)NULL)
      || !cTask[0].feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CNet*)GetPointer(cEncoder[0]),
                                                                                                   LatentLayer)
      || !cActor[0].feedForward((CBufferFloat*)GetPointer(bAccount), 1, false, (CNet*)GetPointer(cTask[0]), -1)
      || !cProbability.feedForward((CNet*)GetPointer(cEncoder[0]), LatentLayer, (CBufferFloat*)NULL)
      || !cDirector.feedForward((CNet*)GetPointer(cActor[0]), -1, (CNet*)GetPointer(cEncoder[0]), LatentLayer)
      || !cCritic[0].feedForward((CNet*)GetPointer(cActor[0]), -1, (CNet*)GetPointer(cEncoder[0]), LatentLayer)
      || !cCritic[1].feedForward((CNet*)GetPointer(cActor[0]), -1, (CNet*)GetPointer(cEncoder[0]), LatentLayer)
     )
     {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      return;
     }

然后,我们将处理下一个已收盘蜡烛图所需的信息存储在全局变量中。

PrevBalance = sState.account[0];
PrevEquity = sState.account[1];

之后,我们将开始进行交易操作。首先,我们得到演员产生的输出向量。

   vector<float> temp;
   cActor[0].getResults(temp);
//---
   if(temp.Size() < NActions)
      temp = vector<float>::Zeros(NActions);

从所得张量中移除相互偏移的仓位交易量。

double min_lot = Symb.LotsMin();
double step_lot = Symb.LotsStep();
double stops = (MathMax(Symb.StopsLevel(), 1) + Symb.Spread()) * Symb.Point();
if(temp[0] >= temp[3])
  {
   temp[0] -= temp[3];
   temp[3] = 0;
  }
else
  {
   temp[3] -= temp[0];
   temp[0] = 0;
  }

接下来,我们对演员的输出进行解码。如果其中不包含任何多头仓位,则之前可能已开立的任何现有仓位都将被平仓。

//--- buy control
   if(temp[0] < min_lot || (temp[1] * MaxTP * Symb.Point()) <= 2 * stops ||
                                 (temp[2] * MaxSL * Symb.Point()) <= stops)
     {
      if(buy_value > 0)
         CloseByDirection(POSITION_TYPE_BUY);
     }

如果我们需要开仓或持有多头仓位,生成的值将转换为实际的交易量和价格水平。

else
  {
   double buy_lot = min_lot + MathRound((double)(temp[0] - min_lot) / step_lot) * step_lot;
   double buy_tp = NormalizeDouble(Symb.Ask() + temp[1] * MaxTP * Symb.Point(), Symb.Digits());
   double buy_sl = NormalizeDouble(Symb.Ask() - temp[2] * MaxSL * Symb.Point(), Symb.Digits());

如果之前已有开仓位,我们会对相关交易价位执行跟踪调整。

if(buy_value > 0)
   TrailPosition(POSITION_TYPE_BUY, buy_sl, buy_tp);

然后通过部分平仓或增加所需数量来调整当前持仓的交易量。后一种情况也包括开立新仓位。

 if(buy_value != buy_lot)
   {
    if((buy_value - buy_lot) >= min_lot)
       ClosePartial(POSITION_TYPE_BUY, buy_value - buy_lot);
    else
       if((buy_lot - buy_value) >= min_lot)
          if(!Trade.Buy(buy_lot - buy_value, Symb.Name(), Symb.Ask(), buy_sl, buy_tp))
             if(Trade.CheckResultRetcode() == 10019)
               {
                Result.Clear();
                Result.Add(0);
                if(!cDirector.backProp(Result, (CNet*)GetPointer(cEncoder[0]), LatentLayer)
                   || !cActor[0].backPropGradient((CNet*)GetPointer(cTask[0]), -1, -1, false)
                   || !cTask[0].backPropGradient((CNet*)GetPointer(cEncoder[0]), LatentLayer, -1, true)
                  )
                  {
                   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
                   return;
                  }
               }
   }
}

值得注意的是,如果在开立新仓位或加仓时出现“资金不足”错误,导演会立即提供反馈,表明该决定应被视为否定。

空头仓位的调整方式类似。

//--- sell control
   if(temp[3] < min_lot || (temp[4] * MaxTP * Symb.Point()) <= 2 * stops ||
                                  (temp[5] * MaxSL * Symb.Point()) <= stops)
     {
      if(sell_value > 0)
         CloseByDirection(POSITION_TYPE_SELL);
     }
   else
     {
      double sell_lot = min_lot + MathRound((double)(temp[3] - min_lot) / step_lot) * step_lot;;
      double sell_tp = NormalizeDouble(Symb.Bid() - temp[4] * MaxTP * Symb.Point(), Symb.Digits());
      double sell_sl = NormalizeDouble(Symb.Bid() + temp[5] * MaxSL * Symb.Point(), Symb.Digits());
      if(sell_value > 0)
         TrailPosition(POSITION_TYPE_SELL, sell_sl, sell_tp);
      if(sell_value != sell_lot)
        {
         if((sell_value - sell_lot) >= min_lot)
            ClosePartial(POSITION_TYPE_SELL, sell_value - sell_lot);
         else
            if((sell_lot - sell_value) >= min_lot)
               if(!Trade.Sell(sell_lot - sell_value, Symb.Name(), Symb.Bid(), sell_sl, sell_tp))
                  if(Trade.CheckResultRetcode() == 10019)
                    {
                     Result.Clear();
                     Result.Add(0);
                     if(!cDirector.backProp(Result, (CNet*)GetPointer(cEncoder[0]), LatentLayer)
                        || !cActor[0].backPropGradient((CNet*)GetPointer(cTask[0]), -1, -1, false)
                        || !cTask[0].backPropGradient((CNet*)GetPointer(cEncoder[0]), LatentLayer, -1, true)
                       )
                       {
                        PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
                        return;
                       }
                    }
        }
     }

最后,该方法会检查是否到了更新目标模型的时候。如有必要,会调用软更新程序,将可训练模型中的参数复制到其对应的目标模型中。

   bFirstRun = false;
//---
   if((int(Rates[0].time / PeriodSeconds(TimeFrame)) % TragetUpdate) == 0)
     {
      if(MathRand() / 32767.0 > 0.5)
         cCritic[2].WeightsUpdate(GetPointer(cCritic[0]), tau);
      else
         cCritic[4].WeightsUpdate(GetPointer(cCritic[0]), tau);
      if(MathRand() / 32767.0 > 0.5)
         cCritic[3].WeightsUpdate(GetPointer(cCritic[1]), tau);
      else
         cCritic[5].WeightsUpdate(GetPointer(cCritic[1]), tau);
      cEncoder[1].WeightsUpdate(GetPointer(cEncoder[0]), tau);
      cTask[1].WeightsUpdate(GetPointer(cTask[0]), tau);
      cActor[1].WeightsUpdate(GetPointer(cActor[0]), tau);
     }
   if(PrevBalance < 50)
      ExpertRemove();
  }

该方法随后结束,并等待下一根 K 线关闭。

在线模型训练 EA 的完整源代码已在附件中提供。



测试

我们投入了大量精力来适应和实现 MQL5 中的演员-导演-评论家框架的核心概念,并将其组件集成到可训练模型的架构中。演员导演评论家之间的交互逻辑经过精心设计,并实施了几种原创的智能体训练方法。现在到了最后阶段,或许也是最令人焦虑的阶段:使用真实的历史市场数据来评估所提出解决方案的有效性。

该框架是在与真实世界交易极为相似的条件下,基于历史数据进行评估的。这使得我们能够客观评估所选的架构和算法解决方案是否能成功应对金融市场的动态变化和不确定性。此外,此类测试不仅能揭示当前实现方案的优缺点,还有助于勾勒出进一步改进和优化的方向。

训练数据集是使用 MetaTrader 5 策略测试器中的随机代理运行生成的,这使我们能够收集各种行为场景。该数据集包含 2024 年全年的 EURUSD M1 历史数据。

初始模型训练以离线方式进行,没有更新训练数据集,直到预测误差稳定下来。然后我们切换到 MetaTrader 5 策略测试器,并继续微调模型参数,直到达到稳定的性能。

只有通过在训练样本之外的数据上评估训练好的模型,才能对所学交易策略进行客观评估。为了测试性能,我们使用了 2025 年1 月3 月的历史数据。由于在训练过程中未使用这一时段,因此消除了过拟合的风险,从而使结果具有真正的实际意义。

所有其他参数,包括市场环境、时间周期、执行模拟模型和终端设置,均保持不变。这样就确保了对所学习策略本身进行客观评估,不受外部因素干扰。

下面展示了测试结果,并清晰地说明了智能体的行为模型。

在测试期间,该模型执行了 684 笔交易。其中 268 笔交易盈利,胜率略高于 39%。尽管如此,该模型在测试期间仍实现了总体盈利,因为平均盈利交易的规模几乎是平均亏损交易规模的两倍。



结论

在这项工作中,我们探索了演员-导演-评论家框架的理论基础,并使用 MQL5 实现了我们对所提出概念的解读。该框架已完全融入现有的多智能体架构中,从而生成了一个模块化、灵活且可高效训练的智能体,该智能体不仅能够通过评论家进行局部动作评估,还能通过导演考虑更广泛的行为逻辑战略背景。这种方法为演员提供了更准确、更可靠的反馈,使智能体能够更快地放弃无效的动作,并更有效地探索策略空间中的有效区域。

进行的测试证实了所提出方法的可行性,并表明即使在市场不确定的情况下,演员-导演-评论家框架也能够做出更平衡的决策,同时表现出自信的行为。

然而,需要指出的是,本文中展示的程序仅作为演示示例,旨在展示该框架的功能。在将提出的解决方案应用于实时交易环境之前,应先在更具代表性的数据集上对模型进行训练,并进行全面的测试和验证。


参考


本文中用到的程序

# 名称 类型 描述
1 Research.mq5 EA 样本采集 EA
2 ResearchRealORL.mq5
EA
使用 Real-ORL 方法采集样本的 EA
3 Study.mq5 EA 离线模型训练的 EA
4 StudyOnline.mq5
EA
在线模型训练的 EA
4 Test.mq5 EA 模型测试 EA
5 Trajectory.mqh 类库 系统状态和模型架构描述结构
6 NeuroNet.mqh 类库 用于创建神经网络的类库
7 NeuroNet.cl 代码库 OpenCL 程序代码

本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/17819

最近评论 | 前往讨论 (2)
Sergey Chalyshev
Sergey Chalyshev | 21 4月 2025 在 17:58
Dmitriy Gizlyk
Dmitriy Gizlyk | 24 4月 2025 在 13:52
Sergey Chalyshev #:
无知滋生无知

https://nukadeti.ru/basni/krylov-kvartet

能请教一下该怎么写吗?

您应该了解的MQL5向导技巧(第七十三部分):使用一目均衡表形态与ADX-Wilder形态 您应该了解的MQL5向导技巧(第七十三部分):使用一目均衡表形态与ADX-Wilder形态
一目均衡表(Ichimoku-Kinko-Hyo)指标与韦尔德平均趋向指数(ADX-Wilder)震荡指标,可在MQL5智能交易系统(EA)中形成互补搭配。一目均衡表本身功能多元,但在本文中,我们主要利用其识别支撑与阻力位的特性。同时,我们也使用ADX来判断趋势强弱。我们依旧通过MQL5向导来构建并测试这一组合的潜在效果。
数据科学与机器学习(第43篇):使用潜在高斯混合模型(LGMM)识别指标数据中的隐藏模式 数据科学与机器学习(第43篇):使用潜在高斯混合模型(LGMM)识别指标数据中的隐藏模式
你是否曾盯着行情图,产生过一种奇妙的感觉…… 仿佛表面之下就藏着某种模式?一套秘密代码 —— 只要能破解它,就能揭示价格的走向?来认识一下 LGMM 吧 —— 市场隐藏模式探测器。这是一种机器学习模型,帮助识别市场中的那些隐藏模式。
神经网络在交易中的应用:用于多元时间序列预测的 LSTM 优化(DA-CG-LSTM) 神经网络在交易中的应用:用于多元时间序列预测的 LSTM 优化(DA-CG-LSTM)
本文介绍了 DA-CG-LSTM 算法,该算法为时间序列分析和预测提供了新的方法。它解释了创新的注意力机制和模型灵活性如何提高预测准确性。
基于季节性因素的MetaTrader 5外汇价差交易质量评估 基于季节性因素的MetaTrader 5外汇价差交易质量评估
本文在日线周期上,针对单一品种及价差组合,检验季节性交易方法的质量。研究重点在于识别周期性月度规律,以及在当年交易中应用这些规律的可行性。