
神经网络变得简单(第 68 部分):离线优先引导政策优化
概述
强化学习是一个通用平台,用来探索环境中学习最优行为政策。政策最优性是通过在与环境交互期间,将从环境获得的奖励最大化来达成。但这就是该方式的主要问题之一。创建相应的奖励函数通常需要大量的人力。此外,奖励可能很少和/或不足以表达真正的学习目标。作为解决这个问题的选择之一,作者在论文《超越奖励:离线优先引导政策优化》中建议采用 OPPO 方法(OPPO 代表离线优先引导政策优化)。该方法的作者建议采用探索环境中已完成的两条轨迹之间,人类注释者的优先来代替环境给予的奖励。我们来仔细看看拟议的算法。
1. OPPO 算法
在离线优先引导学习的背景下,一般方式包括两个步骤,通常涉及使用监督学习优化奖励函数模型,然后利用任意离线 RL 算法,譬如依据重定义转换的学习奖励函数来训练政策。不过,奖励函数的单独训练实践也许不会直接指导政策如何按行动优化。优先标签定义了学习任务,因此目标是学习更优先的轨迹,而不是最大化奖励。在复杂问题的情况下,标量奖励会在政策优化中造成信息瓶颈,进而导致个体的行为次优化。此外,离线政策优化能利用不正确的奖励函数中的薄弱之处。这反过来又会导致不需要的行为。
作为这种两步方式的替代方案,离线优先引导政策优化法(OPPO)的作者旨在直接从离线优先引导数据集中学习政策。他们提议了一种一步式算法,可以同时对离线优先进行建模,并学习最优决策政策,而无需单独训练奖励函数。这是通过使用两个目标来达成的:
- 在离线“缺乏”的情况下整理信息;
- 优先建模。
通过迭代优化这些目标,我们得出了上下文政策 π(A|S,Z) 的构造,依据离线数据和优化优先 Z' 的上下文进行建模。OPPO 的重点是探索高维空间 Z,并在该空间里估测政策。与标量收益相比,该高维 Z 空间在手头上捕获了有关任务的更多信息,令其成为政策优化用意的理想选择。此外,依据所学习最优上下文 Z',进行上下文政策条件化建模,获得了最优政策 π(A|S,Z)。
该算法的作者引入了一个假设,即可以通过模型 Iθ 来近似优先函数,这允许我们划定以下目标:
其中 Z=Iθ(τ) 是优先的上下文。这种编码器-解码器结构将类似于离线模拟学习。然而,由于基于优先的学习设置缺乏专业的演示,该算法的作者采用优先标签来提取回顾性信息。
为了实现与标记数据集中历史信息 Iθ(τ) 与优先的一致性,该方法的作者划定了以下优先建模目标:
其中 z+ 和 z- 分别表示优先上下文的(正)轨迹 Iθ(yτj + (1-y)τi),和较差(负)轨迹 Iθ(yτi + (1-y)τj)。这个目标的底层假设是,人们通常会在表达优先之前在两条轨迹之间 (τi,τj) 进行两级比较:
- 轨迹 τi 与假设的最优轨迹 τ*,即轨迹 τj 与假设的最优轨迹 τ* 之间的相似性 l(z*,z+) ,
- 估算这两个相似性 l(z*,z+) 和 l(z*,z-) 之间的差值,并将轨迹设置为最接近优先轨迹的那一个。
因此,目标优化可确保找到与 z+ 更相似,而与 z- 相似度更低的最优上下文。
应该澄清的是,z* 是轨迹 τ* 的相关上下文,而轨迹 τ* 将始终优先于数据集中的任何离线轨迹。
注意,最优上下文 z* 的后验概率和回顾优先信息 Iθ(•) 的提取是逐一更新的,从而确保训练的稳定性。更好地估算最优嵌入有助于编码器提取人们在判定优选时更关注的特征。反过来,更好的回顾性信息编码器可以加快在高级嵌入空间中寻找最优轨迹的过程。因此,编码器的损失函数由两部分组成:
- 与在监督训练风格中来自回顾的信息误差进行比较。
- 误差能够更好地与由标记的优先数据集提供的二元观察值合作。
作者对 OPPO 算法的可视化呈现如下。
2. 利用 MQL5 实现
我们已经研究了算法的理论层面,现在我们转到实践部分,其中我们将研究所拟议算法的实现。我们将从数据存储结构 SState 开始。如上所述,该方法的作者将传统使用的奖励替换为轨迹优先标签。因此,我们不需要在每次过渡到环境的新状态时保存奖励。同时,我们引入了优先轨迹上下文的概念。按照环境状态描述结构中提议的逻辑,我们将 rewards 数组替换为 scheduler 上下文数组的分解。
struct SState { float state[BarDescr * NBarInPattern]; float account[AccountDescr]; float action[NActions]; float scheduler[EmbeddingSize]; //--- SState(void); //--- bool Save(int file_handle); bool Load(int file_handle); //--- void Clear(void) { ArrayInitialize(state, 0); ArrayInitialize(account, 0); ArrayInitialize(action, 0); ArrayInitialize(scheduler, 0); } //--- overloading void operator=(const SState &obj) { ArrayCopy(state, obj.state); ArrayCopy(account, obj.account); ArrayCopy(action, obj.action); ArrayCopy(scheduler, obj.scheduler); } };
请注意,我们不仅更改了数组的名称,还更改了数组的大小。
除了隐藏的上下文外,该算法还引入了轨迹优先的概念。这里需要注意几个层面:
- 为整个轨迹设置优先级,而不是为单个操作和过渡设置优先级(已评估政策)。
- 于离线数据集 [0: 1] 范围内,在所有轨迹之间成对设置优先级。
- 优先级由智能系统设定。
请注意,我们不会为经验回放缓冲区中的所有轨迹手动设置优先级。此外,我们不会制作优先级的棋盘表。
有很多优先级准则可供选择。但在本文的框架内,我只用了一个,即来自轨迹的盈利。我同意我们可以在准则中增加余额和净值方面的最大回撤。此外,我们可以添加盈利因子和其它准则。不过,我建议您独立选择最适合您的准则集,及其数值系数。您选择的准则集肯定会影响政策训练的最终结果,但不会影响拟议算法的实现。
而且由于为整个轨迹设定了优先级,我们只需要保存轨迹结束时收到的盈利金额。我们将它保存在轨迹描述结构 STrajectory 当中。
struct STrajectory { SState States[Buffer_Size]; int Total; double Profit; //--- STrajectory(void); //--- bool Add(SState &state); void ClearFirstN(const int n); //--- bool Save(int file_handle); bool Load(int file_handle); //--- overloading void operator=(const STrajectory &obj) { Total = obj.Total; Profit = obj.Profit; for(int i = 0; i < Buffer_Size; i++) States[i] = obj.States[i]; } };
当然,修改结构的字段会要求修改复制和操控指定结构文件的方法。但这些调整非常具体,我建议您在附件中掌握它们。
2.1模型架构
我们将采用 2 个模型来训练政策。Scheduler 将学习优先项,而 Agent 将学习行为政策。这两个模型都将建立在决策转换器(DT)的原理上,并采用关注度机制。然而,与作者逐个更新模型的解决方案不同,我们将创建 2 个智能系统来训练模型。它们的每一个都只参与一个模型的训练。在测试和操作模型的阶段,我们会将它们组合成一个单一的机制。因此,为了描述模型的架构,我们还将创建 2 个方法:
- CreateSchedulerDescriptions — 描述 Scheduler 架构
- CreateAgentDescriptions — 描述 Agent 架构
我们将以下内容键入到 Scheduler 之中:
- 历史价格走势和指标数据
- 账户状态和持仓的描述
- 时间戳
- Agent 的最后一个操作
bool CreateSchedulerDescriptions(CArrayObj *scheduler) { //--- CLayerDescription *descr; //--- if(!scheduler) { scheduler = new CArrayObj(); if(!scheduler) return false; } //--- Scheduler scheduler.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; int prev_count = descr.count = (BarDescr * NBarInPattern + AccountDescr + TimeDescription + NActions); descr.activation = None; descr.optimization = ADAM; if(!scheduler.Add(descr)) { delete descr; return false; }
正如我们在之前的文章中所见,决策转换器利用 GPT 架构,并将以前收到的数据的嵌入存储在其隐藏状态中,这令您可在整个事件中在单一上下文中制定决策。因此,我们只向模型提供当前状态的简要描述,重点关注最后的变化。换言之,我们只将有关最后收盘烛条的数据输入到模型中。
接收到的数据在常规化层中进行预处理。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBatchNormOCL; descr.count = prev_count; descr.batch = 1000; descr.activation = None; descr.optimization = ADAM; if(!scheduler.Add(descr)) { delete descr; return false; }
之后,它将在 Embedding 层中转换为可比较的形式。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronEmbeddingOCL; prev_count = descr.count = HistoryBars; { int temp[] = {BarDescr * NBarInPattern, AccountDescr, TimeDescription, NActions}; ArrayCopy(descr.windows, temp); } int prev_wout = descr.window_out = EmbeddingSize; if(!scheduler.Add(descr)) { delete descr; return false; }
然后,我们调用 SoftMax 函数对生成的嵌入进行常规化。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronSoftMaxOCL; descr.count = EmbeddingSize; descr.step = prev_count * 4; descr.activation = None; descr.optimization = ADAM; if(!scheduler.Add(descr)) { delete descr; return false; }
以这种方式预处理的数据会被传递到关注度模块。
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMLMHAttentionOCL; prev_count = descr.count = prev_count * 4; descr.window = EmbeddingSize; descr.step = 8; descr.window_out = 32; descr.layers = 4; descr.optimization = ADAM; if(!scheduler.Add(descr)) { delete descr; return false; }
我们再次调用 SoftMax 函数对接收到的数据进行常规化,并将其传递给一个完全连接的决策层模块。
//--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronSoftMaxOCL; descr.count = EmbeddingSize; descr.step = prev_count; descr.activation = None; descr.optimization = ADAM; if(!scheduler.Add(descr)) { delete descr; return false; } //--- layer 6 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.optimization = ADAM; descr.activation = LReLU; if(!scheduler.Add(descr)) { delete descr; return false; } //--- layer 7 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = EmbeddingSize; descr.activation = None; descr.optimization = ADAM; if(!scheduler.Add(descr)) { delete descr; return false; } //--- return true; }
在模型的输出中,我们收到上下文的潜在表示向量,其大小由 EmbeddingSize 常量决定。
我们正在为 Agent 绘制类似的架构。生成的上下文将添加至其源数据当中。
//+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ bool CreateAgentDescriptions(CArrayObj *agent) { //--- CLayerDescription *descr; //--- if(!agent) { agent = new CArrayObj(); if(!agent) return false; } //--- Agent agent.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; int prev_count = descr.count = (BarDescr * NBarInPattern + AccountDescr + TimeDescription + NActions + EmbeddingSize); descr.activation = None; descr.optimization = ADAM; if(!agent.Add(descr)) { delete descr; return false; }
数据还要经由批量常规化和嵌入层进行预处理,并调用 SoftMax 函数进行常规化。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBatchNormOCL; descr.count = prev_count; descr.batch = 1000; descr.activation = None; descr.optimization = ADAM; if(!agent.Add(descr)) { delete descr; return false; } //--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronEmbeddingOCL; prev_count = descr.count = HistoryBars; { int temp[] = {BarDescr * NBarInPattern, AccountDescr, TimeDescription, NActions, EmbeddingSize}; ArrayCopy(descr.windows, temp); } int prev_wout = descr.window_out = EmbeddingSize; if(!agent.Add(descr)) { delete descr; return false; } //--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronSoftMaxOCL; descr.count = EmbeddingSize; descr.step = prev_count * 5; descr.activation = None; descr.optimization = ADAM; if(!agent.Add(descr)) { delete descr; return false; }
我们整个重复关注度模块,然后调用 SoftMax 函数进行常规化。此处,您只需注意所处理张量的大小变化。
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMLMHAttentionOCL; prev_count = descr.count = prev_count * 5; descr.window = EmbeddingSize; descr.step = 8; descr.window_out = 32; descr.layers = 4; descr.optimization = ADAM; if(!agent.Add(descr)) { delete descr; return false; } //--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronSoftMaxOCL; descr.count = EmbeddingSize; descr.step = prev_count; descr.activation = None; descr.optimization = ADAM; if(!agent.Add(descr)) { delete descr; return false; }
接下来,我们利用卷积层降低数据的维度,同时尝试识别其中的稳定形态。
//--- layer 6 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; prev_count = descr.count = prev_count; descr.window = EmbeddingSize; descr.step = EmbeddingSize; prev_wout = descr.window_out = EmbeddingSize; descr.optimization = ADAM; descr.activation = LReLU; if(!agent.Add(descr)) { delete descr; return false; } //--- layer 7 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronSoftMaxOCL; descr.count = prev_count; descr.step = prev_wout; descr.activation = None; descr.optimization = ADAM; if(!agent.Add(descr)) { delete descr; return false; } //--- layer 8 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; prev_count = descr.count = prev_count; descr.window = prev_wout; descr.step = prev_wout; prev_wout = descr.window_out = prev_wout / 2; descr.optimization = ADAM; descr.activation = LReLU; if(!agent.Add(descr)) { delete descr; return false; } //--- layer 9 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronSoftMaxOCL; descr.count = prev_count; descr.step = prev_wout; descr.activation = None; descr.optimization = ADAM; if(!agent.Add(descr)) { delete descr; return false; }
之后,数据传递至 4 个全连接层的决策模块。最后一层的大小等于 Agent 的动作空间。
//--- layer 10 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.optimization = ADAM; descr.activation = LReLU; if(!agent.Add(descr)) { delete descr; return false; } //--- layer 11 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = LatentCount; descr.activation = LReLU; descr.optimization = ADAM; if(!agent.Add(descr)) { delete descr; return false; } //--- layer 12 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = LReLU; descr.optimization = ADAM; if(!agent.Add(descr)) { delete descr; return false; } //--- layer 13 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = NActions; descr.activation = SIGMOID; descr.optimization = ADAM; if(!agent.Add(descr)) { delete descr; return false; } //--- return true; }
2.2收集训练轨迹
在描述了模型的架构之后,我们转至为训练它们而打造智能系统。我们将从构建与环境交互的 EA 开始,以便收集轨迹,并填充经验回放缓冲区,稍后我们将在离线学习过程 “...\OPPO\Research.mq5” 中用到它。
为了探索环境,我们将采用“ɛ-贪婪”策略,并将相应的外部参数添加到 EA 之中。
input double Epsilon = 0.5;
如上所述,在与环境交互的过程中,我们同时用到这两种模型。因此,我们需要为它们声明全局变量。
CNet Agent; CNet Scheduler;
初始化 EA 的方法与我们之前讨论的 EA 的类似方法没有太大区别。故我认为没有必要再次研究它的算法。您可以在附件中检查它。我们转到研究 OnTick 方法,在其主体中构建了与环境和数据收集交互的主要过程。
在方法的主体中,我们检查是否发生了新柱线开立事件。
//+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick() { //--- if(!IsNewBar()) return;
如有必要,我们会下载历史价格走势数据。
int bars = CopyRates(Symb.Name(), TimeFrame, iTime(Symb.Name(), TimeFrame, 1), History, Rates); if(!ArraySetAsSeries(Rates, true)) return;
然后我们更新所分析指标的读数。
RSI.Refresh(); CCI.Refresh(); ATR.Refresh(); MACD.Refresh(); Symb.Refresh(); Symb.RefreshRates();
我们将接收到的数据格式化为当前状态的结构,并将其传输到数据缓冲区,以便随后用作模型的输入数据。
//--- History data float atr = 0; //--- for(int b = 0; b < (int)NBarInPattern; 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; } bState.AssignArray(sState.state);
在下一阶段,我们将取用有关账户余额和持仓信息来填充环境当前状态描述的结构。
//--- Account description 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;
收集到的信息也会添加到源数据缓冲区当中。
bState.Add((float)((sState.account[0] - PrevBalance) / PrevBalance)); bState.Add((float)(sState.account[1] / PrevBalance)); bState.Add((float)((sState.account[1] - PrevEquity) / PrevEquity)); bState.Add(sState.account[2]); bState.Add(sState.account[3]); bState.Add((float)(sState.account[4] / PrevBalance)); bState.Add((float)(sState.account[5] / PrevBalance)); bState.Add((float)(sState.account[6] / PrevBalance));
接下来,我们将时间戳和 Agent 的最后一个动作添加到源数据缓冲区之中。
//--- Time label double x = (double)Rates[0].time / (double)(D'2024.01.01' - D'2023.01.01'); bState.Add((float)MathSin(2.0 * M_PI * x)); x = (double)Rates[0].time / (double)PeriodSeconds(PERIOD_MN1); bState.Add((float)MathCos(2.0 * M_PI * x)); x = (double)Rates[0].time / (double)PeriodSeconds(PERIOD_W1); bState.Add((float)MathSin(2.0 * M_PI * x)); x = (double)Rates[0].time / (double)PeriodSeconds(PERIOD_D1); bState.Add((float)MathSin(2.0 * M_PI * x)); //--- Prev action bState.AddArray(AgentResult);
在这个阶段,我们已经收集了足够的信息来对 Scheduler 进行前馈验算。这将允许我们为 Agent 形成所需的上下文向量。因此,我们运行 Scheduler 的前馈验算。
if(!Scheduler.feedForward(GetPointer(bState), 1, false)) return; Scheduler.getResults(sState.scheduler); bState.AddArray(sState.scheduler);
加载获得的结果,并补充源数据缓冲区。之后,调用 Agent 的前馈验算方法。
if(!Agent.feedForward(GetPointer(bState), 1, false, (CBufferFloat *)NULL)) return;
在此,我想提醒您,需要在每个阶段控制操作的正确执行。
在该阶段,我们完成模型的操控,保存数据以供后续操作使用,并转到与环境直接交互。
PrevBalance = sState.account[0]; PrevEquity = sState.account[1];
从我们的 Agent 收到数据后,如有必要,我们会为其添加噪声。
Agent.getResults(AgentResult); if(Epsilon > (double(MathRand()) / 32767.0)) for(ulong i = 0; i < AgentResult.Size(); i++) { float rnd = ((float)MathRand() / 32767.0f - 0.5f) * 0.03f; float t = AgentResult[i] + rnd; if(t > 1 || t < 0) t = AgentResult[i] - rnd; AgentResult[i] = t; } AgentResult.Clip(0.0f, 1.0f);
从持仓规模中删除重叠的交易量。
double min_lot = Symb.LotsMin(); double step_lot = Symb.LotsStep(); double stops = MathMax(Symb.StopsLevel(), 1) * Symb.Point(); if(AgentResult[0] >= AgentResult[3]) { AgentResult[0] -= AgentResult[3]; AgentResult[3] = 0; } else { AgentResult[3] -= AgentResult[0]; AgentResult[0] = 0; }
之后,我们首先调整多头持仓。
//--- buy control if(AgentResult[0] < 0.9 * min_lot || (AgentResult[1] * MaxTP * Symb.Point()) <= stops || (AgentResult[2] * MaxSL * Symb.Point()) <= stops) { if(buy_value > 0) CloseByDirection(POSITION_TYPE_BUY); } else { double buy_lot = min_lot + MathRound((double(AgentResult[0] + FLT_EPSILON) - min_lot) / step_lot) * step_lot; double buy_tp = Symb.NormalizePrice(Symb.Ask() + AgentResult[1] * MaxTP * Symb.Point()); double buy_sl = Symb.NormalizePrice(Symb.Ask() - AgentResult[2] * MaxSL * Symb.Point()); if(buy_value > 0) TrailPosition(POSITION_TYPE_BUY, buy_sl, buy_tp); if(buy_value != buy_lot) { if(buy_value > buy_lot) ClosePartial(POSITION_TYPE_BUY, buy_value - buy_lot); else Trade.Buy(buy_lot - buy_value, Symb.Name(), Symb.Ask(), buy_sl, buy_tp); } }
然后我们针对空头持仓执行类似的操作。
//--- sell control if(AgentResult[3] < 0.9 * min_lot || (AgentResult[4] * MaxTP * Symb.Point()) <= stops || (AgentResult[5] * MaxSL * Symb.Point()) <= stops) { if(sell_value > 0) CloseByDirection(POSITION_TYPE_SELL); } else { double sell_lot = min_lot + MathRound((double(AgentResult[3] + FLT_EPSILON) - min_lot) / step_lot) * step_lot; double sell_tp = Symb.NormalizePrice(Symb.Bid() - AgentResult[4] * MaxTP * Symb.Point()); double sell_sl = Symb.NormalizePrice(Symb.Bid() + AgentResult[5] * MaxSL * Symb.Point()); if(sell_value > 0) TrailPosition(POSITION_TYPE_SELL, sell_sl, sell_tp); if(sell_value != sell_lot) { if(sell_value > sell_lot) ClosePartial(POSITION_TYPE_SELL, sell_value - sell_lot); else Trade.Sell(sell_lot - sell_value, Symb.Name(), Symb.Bid(), sell_sl, sell_tp); } }
在该阶段,我们通常会形成一个奖励向量。不过,当前算法的框架内并未用到奖励。因此,我们只需传输有关 Agent 已完成动作的数据,并传输数据以保存轨迹。
for(ulong i = 0; i < NActions; i++) sState.action[i] = AgentResult[i]; if(!Base.Add(sState)) ExpertRemove(); }
然后我们转至等待下一根柱线开立。
在这一刻,浮现出以下问题:我们将如何评估优先?
答案很简单:在策略测试器中完成验算后,我们将在 OnTester 方法中添加有关验算有效性的信息。
//+------------------------------------------------------------------+ //| Tester function | //+------------------------------------------------------------------+ double OnTester() { //--- double ret = 0.0; //--- Base.Profit = TesterStatistics(STAT_PROFIT); Frame[0] = Base; if(Base.Profit >= MinProfit) FrameAdd(MQLInfoString(MQL_PROGRAM_NAME), 1, Base.Profit, Frame); //--- return(ret); }
EA 之中与环境交互的其余方法保持不变。您可以在附件中找到它们。我们转到研究模型训练算法。
2.3优先模型训练
首先。我们来看一下优先模型训练 EA “...\OPPO\StudyScheduler.mq5”。EA 架构保持不变,因此我们只需详研训练模型所涉及的方法。
我必须承认,模型训练过程用到了以前文章中的开发内容。在我个人看来,与它们共生应该可以提高学习过程的效率。
在开始学习过程之前,我们生成一个概率分布,根据它们的盈利能力来选择轨迹,正如 CWBC 方法中所提出的。不过,由于缺少奖励向量,前面讲述的 GetProbTrajectories 方法需要进行一些修改。我们先要修改有关轨迹总结果的信息来源。在这种情况下,分解的奖励向量将替换为最终盈利的标量值。因此,我们将矩阵替换为向量。
vector<double> GetProbTrajectories(STrajectory &buffer[], float lanbda) { ulong total = buffer.Size(); vector<double> rewards = vector<double>::Zeros(total); for(ulong i = 0; i < total; i++) rewards[i]=Buffer[i].Profit;
然后我们判定最大盈利能力水平和标准差。
double std = rewards.Std(); double max_profit = rewards.Max();
在下一步中,我们对轨迹结果进行排序,以便正确判定百分位数。
vector<double> sorted = rewards; bool sort = true; while(sort) { sort = false; for(ulong i = 0; i < sorted.Size() - 1; i++) if(sorted[i] > sorted[i + 1]) { double temp = sorted[i]; sorted[i] = sorted[i + 1]; sorted[i + 1] = temp; sort = true; } }
构建概率分布的进一步过程没有变化,并沿用之前所述的形式。
double min = rewards.Min() - 0.1 * std; if(max_profit > min) { double k = sorted.Percentile(90) - max_profit; vector<double> multipl = MathAbs(rewards - max_profit) / (k == 0 ? -std : k); multipl = exp(multipl); rewards = (rewards - min) / (max_profit - min); rewards = rewards / (rewards + lanbda) * multipl; rewards.ReplaceNan(0); } else rewards.Fill(1); rewards = rewards / rewards.Sum(); rewards = rewards.CumSum(); //--- return rewards; }
此时,准备阶段可认为已完结,我们转到研究优先模型训练算法 — Train。
在该方法的主体中,我们首先调用上面讨论的 GetProbTrajectories 方法,从经验回放缓冲区中选择并形成轨迹的概率分布向量。
//+------------------------------------------------------------------+ //| Train function | //+------------------------------------------------------------------+ void Train(void) { vector<double> probability = GetProbTrajectories(Buffer, 0.1f); uint ticks = GetTickCount();
接下来,规划一个模型训练循环系统。外部循环的迭代次数由智能系统的外部参数决定。
bool StopFlag = false; for(int iter = 0; (iter < Iterations && !IsStopped() && !StopFlag); iter ++) { int tr_p = SampleTrajectory(probability); int tr_m = SampleTrajectory(probability); while(tr_p == tr_m) tr_m = SampleTrajectory(probability);
在循环体中,我们对两条轨迹进行采样,作为正负样本。为了遵守最大客观性原则,我们控制从经验回放缓冲区中选择 2 条不同的轨迹。
显然,简单取样并不能保证一次就选出正轨迹,反之亦然。因此,我们检查所选轨迹的盈利能力,并在必要时重新排列指向变量中轨迹的指针。
if(Buffer[tr_p].Profit < Buffer[tr_m].Profit) { int t = tr_p; tr_p = tr_m; tr_m = t; }
此外,OPPO 算法需要在从负轨迹到优先轨迹的方向上训练优先模型。初看,它也许看起来简单而明显。但在实践中,我们面临着几个陷阱。
为了生成所有轨迹,我们用到了一段历史数据。因此,有关价格走势的信息,和所有轨迹的分析指标的数值将是相同的。但其它分析参数的情况有所不同。我说的是账户状态、持仓,当然还有 Agent 的动作。因此,为了确保误差梯度的正确传播,我们需要对两条轨迹中的状态依次运行前馈验算。
但这又引出了了下一个问题。在我们的模型中,我们采用 GPT 架构,该架构对输入数据的顺序很敏感。那么,我们如何在一个模型中保存两条不同轨迹的序列呢?显而易见的答案是并行使用 2 个模型,并定期合并权重系数,类似于 TD3 和 SAC 方法中目标模型的软更新。但这里也有困难。在上述方法中,目标模型并未经过训练。我们用它们的矩缓冲区作为软学习过程的一部分。不过,在这种情况下,模型是经过训练的。故此,矩缓冲区用于其预期目的。取有关权重系数软更新的信息来补充它们可能会令学习过程失真。我们不会跳过详细的分析,并寻找建设性的解决方案。
在我看来,最可接受的选择是按顺序训练一个模型,首先在一条轨迹的数据上,然后采用误差梯度的倒数值在第二条轨迹的数据上。因为对于优先轨迹,我们最小化距离,而对于负轨迹,我们将其最大化。
按照这个逻辑,我们在优先轨迹上对初始状态进行取样。
//--- Positive int i = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * MathMax(Buffer[tr_p].Total - 2 * HistoryBars - NBarInPattern, MathMin(Buffer[tr_p].Total, 20))); if(i < 0) { iter--; continue; }
清除模型堆栈,并在优先轨迹的框架内规划学习过程。
Scheduler.Clear(); for(int state = i; state < MathMin(Buffer[tr_p].Total - 1 - NBarInPattern, i + HistoryBars * 2); state++) { //--- History data State.AssignArray(Buffer[tr_p].States[state].state);
在循环主体中,我们用历史价格走势值,和来自轨迹训练样本的指标值填充初始数据缓冲区。
添加有关帐户状态和持仓的信息。
//--- Account description float PrevBalance = (state == 0 ? Buffer[tr_p].States[state].account[0] : Buffer[tr_p].States[state - 1].account[0]); float PrevEquity = (state == 0 ? Buffer[tr_p].States[state].account[1] : Buffer[tr_p].States[state - 1].account[1]); State.Add((Buffer[tr_p].States[state].account[0] - PrevBalance) / PrevBalance); State.Add(Buffer[tr_p].States[state].account[1] / PrevBalance); State.Add((Buffer[tr_p].States[state].account[1] - PrevEquity) / PrevEquity); State.Add(Buffer[tr_p].States[state].account[2]); State.Add(Buffer[tr_p].States[state].account[3]); State.Add(Buffer[tr_p].States[state].account[4] / PrevBalance); State.Add(Buffer[tr_p].States[state].account[5] / PrevBalance); State.Add(Buffer[tr_p].States[state].account[6] / PrevBalance);
我们添加时间戳的谐波,和 Agent 最后动作的向量。
//--- Time label double x = (double)Buffer[tr_p].States[state].account[7] / (double)(D'2024.01.01' - D'2023.01.01'); State.Add((float)MathSin(2.0 * M_PI * x)); x = (double)Buffer[tr_p].States[state].account[7] / (double)PeriodSeconds(PERIOD_MN1); State.Add((float)MathCos(2.0 * M_PI * x)); x = (double)Buffer[tr_p].States[state].account[7] / (double)PeriodSeconds(PERIOD_W1); State.Add((float)MathSin(2.0 * M_PI * x)); x = (double)Buffer[tr_p].States[state].account[7] / (double)PeriodSeconds(PERIOD_D1); State.Add((float)MathSin(2.0 * M_PI * x)); //--- Prev action if(state > 0) State.AddArray(Buffer[tr_p].States[state - 1].action); else State.AddArray(vector<float>::Zeros(NActions));
成功收集到所有必要的数据之后,我们对经过训练的模型执行前馈验算。
//--- Feed Forward if(!Scheduler.feedForward(GetPointer(State), 1, false, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; break; }
该模型的训练方式与监督式学习方法类似,旨在最大限度地减少预测上下文值与经验回放缓冲区中相应优先轨迹数据的偏差。
//--- Study Result.AssignArray(Buffer[tr_p].States[state].scheduler); if(!Scheduler.backProp(Result, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; break; }
接下来,我们告知用户训练过程的进度,并转到下一次迭代,据优先轨迹训练模型。
if(GetTickCount() - ticks > 500) { string str = StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Scheeduler", iter * 100.0 / (double)(Iterations), Scheduler.getRecentAverageError()); Comment(str); ticks = GetTickCount(); } }
在优先轨迹内成功完成循环迭代后,我们转到第二个的工作。
理论上,我们可用类似的区间,并使用为正轨迹采样的初始状态。在一个历史时期,我们在所有轨迹中的步数相同。不过,这是一个特殊情况。但是,如果我们考虑更常见的情况,则轨迹中可能会有不同的变体、和不同的步数。例如,当长线操作、或本金相当少时,我们可能会损失这笔本金,并爆仓。因此,我决定对工作轨迹内的初始状态进行采样。
//--- Negotive i = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * MathMax(Buffer[tr_m].Total - 2 * HistoryBars - NBarInPattern, MathMin(Buffer[tr_m].Total, 20))); if(i < 0) { iter--; continue; }
接下来,我们清除模型堆栈,并规划一个训练循环,类似于上面在优先轨迹框架内所做的工作。
Scheduler.Clear(); for(int state = i; state < MathMin(Buffer[tr_m].Total - 1 - NBarInPattern, i + HistoryBars * 2); state++) { //--- History data State.AssignArray(Buffer[tr_m].States[state].state); //--- Account description float PrevBalance = (state == 0 ? Buffer[tr_m].States[state].account[0] : Buffer[tr_m].States[state - 1].account[0]); float PrevEquity = (state == 0 ? Buffer[tr_m].States[state].account[1] : Buffer[tr_m].States[state - 1].account[1]); State.Add((Buffer[tr_m].States[state].account[0] - PrevBalance) / PrevBalance); State.Add(Buffer[tr_m].States[state].account[1] / PrevBalance); State.Add((Buffer[tr_m].States[state].account[1] - PrevEquity) / PrevEquity); State.Add(Buffer[tr_m].States[state].account[2]); State.Add(Buffer[tr_m].States[state].account[3]); State.Add(Buffer[tr_m].States[state].account[4] / PrevBalance); State.Add(Buffer[tr_m].States[state].account[5] / PrevBalance); State.Add(Buffer[tr_m].States[state].account[6] / PrevBalance); //--- Time label double x = (double)Buffer[tr_m].States[state].account[7] / (double)(D'2024.01.01' - D'2023.01.01'); State.Add((float)MathSin(2.0 * M_PI * x)); x = (double)Buffer[tr_m].States[state].account[7] / (double)PeriodSeconds(PERIOD_MN1); State.Add((float)MathCos(2.0 * M_PI * x)); x = (double)Buffer[tr_m].States[state].account[7] / (double)PeriodSeconds(PERIOD_W1); State.Add((float)MathSin(2.0 * M_PI * x)); x = (double)Buffer[tr_m].States[state].account[7] / (double)PeriodSeconds(PERIOD_D1); State.Add((float)MathSin(2.0 * M_PI * x)); //--- Prev action if(state > 0) State.AddArray(Buffer[tr_m].States[state - 1].action); else State.AddArray(vector<float>::Zeros(NActions)); //--- Feed Forward if(!Scheduler.feedForward(GetPointer(State), 1, false, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; break; }
但是设定目标有一个细节。我们正在研究 2 个选项。首先,作为特殊情况,当优先轨迹和第二条轨迹的盈利相同时(本质上,两条轨迹都可取),我们使用类似于优先轨迹的方法。
//--- Study if(Buffer[tr_p].Profit == Buffer[tr_m].Profit) Result.AssignArray(Buffer[tr_m].States[state].scheduler);
第二种情况更常见,当第二条轨迹的盈利较低时,我们必须据其向相反的方向反弹。为此,我们加载预测值,并从经验回放缓冲区中找到它与负轨迹上下文的偏差。但在这里,我们必须朝着相反的方向移动。因此,我们不添加,而是从预测值中减去结果偏差。为了提高向优先轨迹移动的优先级,在计算目标值时,我将结果偏差减少 2 倍。
else { vector<float> target, forecast; target.Assign(Buffer[tr_m].States[state].scheduler); Scheduler.getResults(forecast); target = forecast - (target - forecast) / 2; Result.AssignArray(target); }
现在,我们调用可用的方法执行模型反向传播验算,以便将所调整目标的偏差最小化。
if(!Scheduler.backProp(Result, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; break; }
我们告知用户学习过程的进度,并转到循环系统的下一次迭代。
if(GetTickCount() - ticks > 500) { string str = StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Scheeduler", (iter + 0.5) * 100.0 / (double)(Iterations), Scheduler.getRecentAverageError()); Comment(str); ticks = GetTickCount(); } } }
完成学习循环系统的所有迭代之后,我们清除图表上的注释字段。打印记录训练过程的结果,并启动强制 EA 关闭的过程。
Comment(""); //--- PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Scheduler", Scheduler.getRecentAverageError()); ExpertRemove(); //--- }
我们已经完成了训练优先模型 “...\OPPO\StudyScheduler.mq5” 智能系统方法的研究。您可以在附件中找到其所有方法和函数的完整代码。
2.4Agent 政策训练
下一步是构建 Agent 政策训练 EA “...\OPPO\StudyAgent.mq5”。EA 的架构与上面讨论过的 EA 几乎相同。训练模型的方法仅略微存在一些差异。我们来详细研究一下。
和以前一样,在方法主体中,我们首先调用 GetProbTrajectories 方法来判定所选轨迹的概率。
vector<double> probability = GetProbTrajectories(Buffer, 0.1f); uint ticks = GetTickCount();
接下来,我们规划一个嵌套模型训练循环系统。
bool StopFlag = false; for(int iter = 0; (iter < Iterations && !IsStopped() && !StopFlag); iter ++) { int tr = SampleTrajectory(probability); int i = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * MathMax(Buffer[tr].Total - 2 * HistoryBars - NBarInPattern, MathMin(Buffer[tr].Total, 20))); if(i < 0) { iter--; continue; }
这次我们只针对外循环主体中的一条轨迹进行采样。在该阶段,我们必须学习 Agent 的政策,它能够将潜在上下文与特定动作相匹配。这将令 Agent 的动作更加可预测和可控。因此,我们不会将轨迹分为优先轨迹和非优先轨迹。
接下来,我们清除模型堆栈,并在采样子轨迹的连续状态中规划一个嵌套的模型训练循环。
Agent.Clear(); for(int state = i; state < MathMin(Buffer[tr].Total - 1 - NBarInPattern, i + HistoryBars * 2); state++) { //--- History data State.AssignArray(Buffer[tr].States[state].state);
在循环的主体中,我们用价格走势的历史数据,和训练集中分析指标的指标填充初始数据缓冲区。用账户状态和持仓数据来补充它们。
//--- Account description float PrevBalance = (state == 0 ? Buffer[tr].States[state].account[0] : Buffer[tr].States[state - 1].account[0]); float PrevEquity = (state == 0 ? Buffer[tr].States[state].account[1] : Buffer[tr].States[state - 1].account[1]); State.Add((Buffer[tr].States[state].account[0] - PrevBalance) / PrevBalance); State.Add(Buffer[tr].States[state].account[1] / PrevBalance); State.Add((Buffer[tr].States[state].account[1] - PrevEquity) / PrevEquity); State.Add(Buffer[tr].States[state].account[2]); State.Add(Buffer[tr].States[state].account[3]); State.Add(Buffer[tr].States[state].account[4] / PrevBalance); State.Add(Buffer[tr].States[state].account[5] / PrevBalance); State.Add(Buffer[tr].States[state].account[6] / PrevBalance);
添加时间戳的谐波,和 Agent 最后动作的向量。
//--- Time label double x = (double)Buffer[tr].States[state].account[7] / (double)(D'2024.01.01' - D'2023.01.01'); State.Add((float)MathSin(2.0 * M_PI * x)); x = (double)Buffer[tr].States[state].account[7] / (double)PeriodSeconds(PERIOD_MN1); State.Add((float)MathCos(2.0 * M_PI * x)); x = (double)Buffer[tr].States[state].account[7] / (double)PeriodSeconds(PERIOD_W1); State.Add((float)MathSin(2.0 * M_PI * x)); x = (double)Buffer[tr].States[state].account[7] / (double)PeriodSeconds(PERIOD_D1); State.Add((float)MathSin(2.0 * M_PI * x)); //--- Prev action if(state > 0) State.AddArray(Buffer[tr].States[state - 1].action); else State.AddArray(vector<float>::Zeros(NActions));
与优先模型不同,Agent 需要上下文。我们从经验回放缓冲区中获取它。
//--- Scheduler
State.AddArray(Buffer[tr].States[state].scheduler);
收集的数据足以用于 Agent 模型的前馈验算。故此,我们调用相关的方法。
//--- Feed Forward if(!Agent.feedForward(GetPointer(State), 1, false, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; break; }
如上所述,我们训练 Actor 政策,以便在潜在上下文和正在执行的动作之间建立依赖关系。这与 DT 的目标完全一致。在 DT 中,我们在目标和行动之间建立了依赖关系。潜在上下文可以被认为是目标的某种嵌入。虽然形式发生了变化,但本质是一样的。因此,学习过程将是相似的。我们将预测和实际行动之间的误差降至最低。
//--- Policy study Result.AssignArray(Buffer[tr].States[state].action); if(!Agent.backProp(Result, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; break; }
接下来,我们所要做的就是通知用户学习过程的进度,并转到下一次迭代。
//--- if(GetTickCount() - ticks > 500) { string str = StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Agent", iter * 100.0 / (double)(Iterations), Agent.getRecentAverageError()); Comment(str); ticks = GetTickCount(); } } }
训练过程完成后,我们清除图表上的注释字段。将模型训练结果输出到日志中,并启动 EA 的完结。
Comment(""); //--- PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Agent", Agent.getRecentAverageError()); ExpertRemove(); //--- }
在此,我们以本文中所用的程序的算法结束。您可以在附件中找到完整的代码。附件还包含测试训练模型的智能系统代码 “...\OPPO\Test.mq5”,它几乎完全重复了智能系统的算法,以便与环境交互。我只排除了向 Agent 的动作添加噪音。这令我们能够消除随机性因素,并全面评估所学习政策。
3. 测试
我们在实现离线优先引导策略优化(OPPO)算法方面做了大量工作。我再次提请您注意这样一个事实,即该作品展示了个人对实现的愿景,并添加了方法作者描述的原始算法中缺少的一些操作。我绝不想因我为 OPPO 方法作者所作所为,而受到任何方式的赞扬。另一方面,我不想将原始思路的任何缺陷或误解归咎于他们。
与往常一样,该模型是依据 EURUSD 金融产品、2023 年前 7 个月的 H1 时间帧的历史数据进行训练。训练后的模型采用 2023 年 8 月的历史数据进行了测试。
由于这项工作中轨迹保存结构的变化,我们不能再用以前工作所收集的样本轨迹。因此,在训练数据集中收集了全新的轨迹。
在此,我必须承认,从使用随机权重初始化的新模型中收集 500 条轨迹需要在我的笔记本电脑上连续工作 3 天。事实证明,这是相当出乎意料的。
收集训练数据集之后,我启动了模型的并行训练,这是通过将训练过程划分给 2 个独立的智能系统来做到的。
与往常一样,考虑到模型更新,如果不迭代选择训练数据集,学习过程就不完整。正如您将看到的,学习过程非常稳定和有方向。即使训练数据集有亏损的验算,也会发现该方法可以改进策略。
根据我个人的观察,要为 Agent 的行为构建可盈利的策略,训练数据集必须有正验算。只有通过探索环境同时收集额外的轨迹,才能实现此类验算的存在。也可以使用 EA 轨迹或复制信号交易,正如我们在上一篇文章中看到的那样。此外,添加可盈利验算可显著加快模型训练过程。
在训练过程中,我们获得了一个能够在训练和测试样本上产生盈利的模型。测试时间间隔的模型性能结果如下所示。
正如您所提供屏幕截图中看到的那样,余额线既有急剧上升、也有急剧下降。余额图很难说是稳定的,但总体的上升趋势得以保留。根据月度测试的结果,我们实现了盈利。
在测试期间,EA 总共执行了 180 笔交易。其中近 49% 的交易以盈利了结。我们可以称之为盈利和亏损交易的对等。然而,由于平均盈利交易比平均亏损交易高出 30%,因此我们的余额总体上增加了。此测试历史期间的盈利因子为 1.25。
结束语
在本文中,我们介绍了另一种比较有趣的模型训练方法:离线优先引导政策优化(OPPO)。该方法的主要特点是从模型训练过程中消除了奖励函数。这显著扩大了其使用范围。因为有时制定和指定某个学习目标可能非常困难。评估每个单独的行动对最终结果的影响变得更加困难,尤其是在环境响应稀疏的情况下。或者当这样的响应会有一些延迟才能到来。代之,所提出的 OPPO 方法将整条轨迹评估为由单个政策产生的单一整体。因此,我们评估的不是 Agent 的动作,而是它在特定环境中的政策。我们决定继承这一政策,或者相反,朝着相反的方向前进,以找到更理想的解决方案。
在本文的实践部分,我们利用 MQL5 实现了 OPPO 方法,尽管与原始方法有一些偏差。尽管如此,我们还是设法训练了一个政策,能够在训练历史期间、和训练数据集之外的测试期间产生盈利。
模型训练和测试结果证明了采用所提议的方法构建真实交易策略的可能性。
不过,我想再次提醒您,本文中介绍的所有程序仅用于演示该技术,并不准备在现实世界的金融交易中使用。
参考
文中所用程序
# | 名称 | 类型 | 说明 |
---|---|---|---|
1 | Research.mq5 | EA | 样本收集 EA |
2 | StudyAgent.mq5 | EA | 智能体训练 EA |
3 | StudyScheduler.mq5 | EA | 偏好模型训练智能系统 |
4 | Test.mq5 | EA | 模型测试 EA |
5 | Trajectory.mqh | 类库 | 系统状态定义结构 |
6 | NeuroNet.mqh | 类库 | 创建神经网络的类库 |
7 | NeuroNet.cl | 代码库 | OpenCL 程序代码库 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/13912


