您应当知道的 MQL5 向导技术(第 49 部分):搭配近端政策优化的强化学习
概述
我们继续我们的 MQL5 向导系列,最近我们在常见指标和强化学习算法的简单形态之间交替。在上一篇文章中考察了指标形态(比尔·威廉姆斯的短吻鳄)之后,我们现在回到强化学习,这次我们要研究的算法是近端政策优化(PPO)。据报道,该算法于 7 年前首次发布,是 ChatGPT 首选的强化学习算法。故此,围绕这种强化学习方式显然有一些炒作。PPO 算法旨在优化政策(定义参与者操作的函数),通过防止可能令学习过程不稳定的剧烈变化,来提高整体性能。
它并非独立地做到这一点,而是与其它强化学习算法协同工作,其中一些算法我们在本系列中曾研究过,从大义上讲,有两个种类。基于政策的算法、和基于数值的算法。我们已在本系列中考察过每个示例,也许回顾一下,我们看到的基于政策的算法是 Q-学习和 SARSA。我们仅研究了一种基于数值的方法,那就是时态差异。那么,PPO 到底是怎么回事呢?
如上所述,PPO 解决的“问题”是防止政策在更新期间发生太大变化。这背后的论调是,如果不干预管理更新频率和幅度,智服或许会:忘记它的所学,做出不可靠的决策,或者在环境中表现更差。因此,PPO 确保更新虽小、但意义重大。PPO 的工作是从其政策的预定义参数开始。其中政策只是基于奖励和环境状态定义参与者动作的函数。
给定政策,智服会执行与环境的交互,从而收集数据。这种“数据收集”将获取“状态-行动-奖励”配对,以及在该政策下采取的各种动作的概率。这一点确立后,接下来是定义意向函数。正如上面的概述中所提,PPO 是关于调节强化学习中更新的幅度,为此,我们使用 “clipping” 函数来达成这一点。该函数由以下等式定义:
其中:
- r t (θ)=πθ(at∣st)/πθ old (at∣st) 是新政策(参数 θ)、和旧政策(参数 θ old)之间的概率比。
- Â t 是时间 t 的优势估测,它衡量一个动作在给定状态下与平均动作相比的更佳程度。
- ε 是一个超参数(通常为 0.1 或 0.2),即控制剪辑范围,限制政策更新的步长。
优势估测可通过多种途径定义,但在我们的实现中采用方法如下:
![]()
其中:
- Q(s t ,a t ) 是在状态 s t 下采取行动 a t 的 Q-值(预期回报)。
- V(s t ) 是状态 s t 的值函数,表示如果我们从该状态开始遵循政策,会得到的预期回报。
这种量化优势函数的方法强调依赖或使用基于政策的算法、及基于数值的算法,这个我们上面也提到过。一旦我们定义了意向函数,我们就会继续执行政策更新。更新调整政策参数,以便最大化裁剪意向函数。这确保了政策变化是渐进的,且不会过度拟合最近的数据。然后,通过用更新的政策与环境进行交互、重复该过程不断收集数据、并优化政策。
PPO 为何如此流行?好吧,与信任区域政策优化等旧政策优化器相比,它更容易实现,它通过裁剪(在上面我们高亮了其公式)提供了稳定的更新,非常高效,因其可与现代神经网络良好配合工作,且可处理大规模任务。它还具有综合性,即它能在连续和离散空间中都表现良好。考察 PPO 背后直觉的另一种途径是,如果人们想象他们正在学习玩游戏。如果您在每次尝试后大幅改变您的游戏方式,持续不断,您必然会失去您早期或许得到的一些好的动作或战术。PPO 是一种确保您在学习游戏时仅做出微小、渐进、及深思熟虑改变的方式,避免可能令您变得更糟的根本偏转。
在许多方面,这是强化学习旨在解决的探索/利用辩证。争议在于,大多数学习过程启始时,在方式中进行根本性的偏转是必要的,即促进更多的探索胜过利用。显然,在这些初始状况下,PPO 并不是很实用。尽管如此,由于大多数学科和学习领域都能引起争论,支持者更多地处于优调时刻,多过最初的发现,PPO 非常受欢迎。为此,PPO 被广泛用于机器人技术,诸如教机器人行走或操纵物体,或视频游戏,例如训练人工智能玩国际象棋、或 Dota 等复杂游戏。
PPO 在交易者强化学习中的角色
PPO 作为一种与其它核心强化学习算法协同工作的政策算法,没有太多替代方案。少数可用的、值得一提的是我们在早前文章中考察的深度 Q-网络、我们尚未研究的异步优势参与者-评论者,以及我们上面提到的信任区域政策优化。我们研究一下 PPO 如何与这些实现的每一个区分开来。如果我们从 DQN 开始,它采用 Q-学习,并且由于大量政策更新,尤其是在连续动作空间中,它可能会遭遇不稳定问题。连续动作空间是指在 RL 周期中,参与者的选择不是由“买入-卖出-持有”等可枚举选择预定义的,而是由浮点数或双精度数,诸如判定下一笔交易的理想持仓规模等用例。
然而,有论调说 PPO 更稳定、更易于实现,因为它不需要单独的目标网络,甚至不需要经验回放,我们将在以后的文章中探讨这个概念。通过简化的训练管道,PPO 可直接在离散和连续动作空间两者中工作,而 DQN 更适合离散空间。
与异步优势参与者-评论者(A3C)相比,A3C(在本系列中我们尚未研究的政策算法)倾向于利用多个 RL 周期(或智服)在不同时间更新共享政策;而具有多个 RL 周期通常会增加模型的复杂性。另一方面,PPO 依赖于同步更新和政策裁剪来保持稳定的学习过程,无需过于激进的更新,从而带来政策崩溃的风险。
与信任区域政策优化(TRPO)相比,PPO 也存在一些显著差异。其中最主要的是 TRPO 使用复杂的优化过程来限制政策变化,这一过程往往需要解决受约束的优化问题。另一方面,如前所述,PPO 经由裁剪简化这一点,其中通过约束更新,能够获得计算效率,同时仍能实现类似水平的稳定性和性能。
在概述中,还有一些 PPO 的特征值得分享,故我们在处置主体之前先排演一下它们。如上所述,PPO 采用剪辑机制来制定政策更新,其直接预期目标是避免过于剧烈的更新。然而,这大概不是预期的后果,在利用和探索之间提供平衡,这是强化学习的一个关键原则。这对于交易者来说可能是有益的,特别是在高波动性环境中,奖励的过度利用可能是一件傻事,取而代之保持干燥以获得对市场的长期了解是一种更合适的策略。
不过,在需要进行探索的一些情况下,PPO 可以进行熵正则化,这将防止算法针对特定动作变得过于自信,从而减少对政策更新的依赖。我们将在以后的文章中研究熵正则化。
PPO 在处理或处理大型动作空间方面也很有效。这是因为它的“参与者-评论者”框架允许它更好地预测参与者域数值,即使它们是连续的,如上所述;但更重要的是,幸好采用了代用损失函数,它降低了政策更新的方差,即便 RL 运行在高波动环境中(譬如外汇)的情况下,能导致跨交易的行为更加一致。
PPO 还可以很好地扩展,即它不依赖于占用大量资源来去存储大型经验回放缓冲区。可以说,这一优势理论上能适合的用例,譬如多种金融产品的高频交易、甚至复杂的交易规则设置。
PPO 可据有限数据有效地学习。在获取市场数据可能受到抑制或成本高昂的环境中,与同行相比,如此数据采样效率,非常有效。例如,对于许多需要在实际报价基础上、且较长历史周期内测试其策略的交易者来说,这是一个非常令人心酸的场景。虽然 MetaTrader 策略测试器能在没有可用真实即刻报价时生成即刻报价数据,作为规则,往往首选意向交易经纪商的真实即刻报价数据测试策略。
对于许多经纪商来说,罕有足够体量的真实即刻报价数据可用,即使测试周期的必要年份可用的情况下,品质审查也能揭示数据集中存在重大漏洞。对于金融数据来说,这是一种特殊问题,因为如果与其它领域比较,诸如视频游戏开发、或模拟等,大量数据的生成和后续训练通常很简单。甚至,关键信号往往依赖于罕见事件,例如市场崩盘或繁荣,而这些事件出现的频率不足以令模型从中学习。
PPO 通过固有的取样效能来“规避”这些问题,故它能够从有限的数据量中学习。需要大量数据来生成得体的政策并非 PPO 的先决条件。这在一定程度上要归功于优势估测,令其能在更小的区块、及更少的局次中更好地利用可用的市场数据。在尝试针对罕见但重要的事件进行建模时,这就是关键,因为即便面临数据稀缺,PPO 也会自好的、或坏的交易中逐步学习。
对于大多数交易系统来说,来自任何决策的“奖励”,即典型的盈利、或亏损量化值,都可能会大大延迟。这种状况带来了挑战,即为早期采取的特定动作分配功劳成为问题。举例,当在特定时间建立多头持仓时,收益或许在几天、甚至几周后才变现;这显然挑战 RL 算法要学习哪些动作、或环境状态,准确对应催生哪些奖励。
这样的场景会被市场噪音和随机性进一步削弱,这是许多市场价格动作所固有的,故很难辨别积极的结果是由正确的决策、亦或孤立的市场走势而产生。优势函数的方程已在上面分享,通过参考长期权重 V(s t ))、及“状态-行动”对的 Q-值(表示为 Q(s t , a t )),来帮助 PPO 更好地估算特定动作的预期回报,如此这般采取的决策,在两个极端得到更好的平衡。
利用 MQL5 设置 PPO 信号类
故此,为了利用 MQL5 实现这一点,我们将使用 “Cql” 类,该类一直是我们所有强化学习文章的主要来源。我们需要对其进行修改,以便扩展它来适应 PPO,且其第一项是引入处理 PPO 的数据结构。其清单如下所示:
//+------------------------------------------------------------------+ //| PPO | //+------------------------------------------------------------------+ struct Sppo { matrix policy[]; matrix gradient[]; };
在上面的数据结构中,有两个数组,它们的大小会根据强化学习周期中参与者的可用动作数量进行调整。梯度和政策的每个矩阵都是典型的方阵样式,并根据状态数量调整大小。因此,政策矩阵数组可当作我们的 Q-映射等效物,即它记录权重,及每个状态下选择每个动作的可能性。我们坚持使用在这本系列中一直所用的,代表看涨、看跌、和拉锯市场的相同简单环境状态。回顾一下,这 3 个状态在短期和长期时间跨度内都记录下来。
在定义时间跨度时,大多数人会垂青时间帧,举例,寻找给定证券价格走势在日线时间帧内是看涨、亦或看跌,然后在一小时时间帧内重复该过程,以便得出两组量值。我们选择在本系列中一直所用的时间跨度定义,这样简单得多,即我们只用一定数量价格柱线的滞后来区分短期和长期。
该滞后值是一个可调整的输入参数,我们在信号类代码中将其标记为 “Signal_PPO_RL_Scale” 或 m_scale,在 GetOutput 函数中映射捕获两个价格动作趋势的过程,该函数将在本文后面分享。不过,现在,如果我们回到 PPO,在修改 Cql 类时,其实现主要涉及引入 2 个新函数。set-policy 函数和 get-clipping 函数。在判定参与者的下一个动作时,我们两个函数都不调用,事实上,它们也可能是 Cql 类中的受保护函数。
政策的设置是在 Se-On tPolicy 函数和 Set-Off Policy 函数中调用。其清单如下:
//+------------------------------------------------------------------+ //| PPO policy update function | //+------------------------------------------------------------------+ void Cql::SetPolicy() { matrix _policies; _policies.Init(THIS.actions, Q_PPO.policy[acts[0]].Rows()*Q_PPO.policy[acts[0]].Cols()); _policies.Fill(0.0); for(int ii = 0; ii < int(Q_PPO.policy[acts[0]].Rows()); ii++) { for(int iii = 0; iii < int(Q_PPO.policy[acts[0]].Cols()); iii++) { for(int i = 0; i < THIS.actions; i++) { _policies[i][GetMarkov(ii, iii)] += Q_PPO.policy[i][ii][iii]; } } } vector _probabilities; _probabilities.Init(Q_PPO.policy[acts[0]].Rows()*Q_PPO.policy[acts[0]].Cols()); _probabilities.Fill(0.0); for(int ii = 0; ii < int(Q_PPO.policy[acts[0]].Rows()); ii++) { for(int iii = 0; iii < int(Q_PPO.policy[acts[0]].Cols()); iii++) { for(int i = 0; i < THIS.actions; i++) { _policies.Row(i).Activation(_probabilities, AF_SOFTMAX); double _old = _probabilities[states[1]]; double _new = _probabilities[states[0]]; double _advantage = Q_SA[i][ii][iii] - Q_V[ii][iii]; double _clip = GetClipping(_old, _new, _advantage); Q_PPO.gradient[i][ii][iii] = (_new - _old) * _clip; } } } for(int i = 0; i < THIS.actions; i++) { for(int ii = 0; ii < int(Q_PPO.policy[i].Rows()); ii++) { for(int iii = 0; iii < int(Q_PPO.policy[i].Cols()); iii++) { Q_PPO.policy[i][ii][iii] += THIS.alpha * Q_PPO.gradient[i][ii][iii]; } } } }
在该函数中,我们本质上覆盖更新 PPO 结构政策值的 3 个步骤,我们在上面分享了其代码。这些政策值在 Action 函数中指导下一个动作的选择,这是我们在之前文章中引用的旧函数,此处所用是其变体,因为我们对其清单进行了更多修改,如下所示:
//+------------------------------------------------------------------+ //| Choose an action using epsilon-greedy approach | //+------------------------------------------------------------------+ void Cql::Action(vector &E) { int _best_act = 0; if (double((rand() % SHORT_MAX) / SHORT_MAX) < THIS.epsilon) { // Explore: Choose random action _best_act = (rand() % THIS.actions); } else { // Exploit: Choose best action double _best_value = Q_SA[0][e_row[0]][e_col[0]]; for (int i = 1; i < THIS.actions; i++) { if (Q_SA[i][e_row[0]][e_col[0]] > _best_value) { _best_value = Q_SA[i][e_row[0]][e_col[0]]; _best_act = i; } } } //update last action act[1] = act[0]; act[0] = _best_act; //markov decision process e_row[1] = e_row[0]; e_col[1] = e_col[0]; LetMarkov(e_row[1], e_col[1], E); int _next_state = 0; for (int i = 0; i < int(markov.Cols()); i++) { if(markov[int(E[0])][i] > markov[int(E[0])][_next_state]) { _next_state = i; } } //printf(__FUNCSIG__+" next state is: %i, with best act as: %i ",_next_state,_best_act); int _next_row = 0, _next_col = 0; SetMarkov(_next_state, _next_row, _next_col); e_row[0] = _next_row; e_col[0] = _next_col; states[1] = states[0]; states[0] = GetMarkov(_next_row, _next_col); td_value = Q_V[_next_row][_next_col]; td_policies[1][0] = td_policies[0][0]; td_policies[1][1] = td_policies[0][1]; td_policies[1][2] = td_policies[0][2]; td_policies[0][0] = _next_row; td_policies[0][1] = td_value; td_policies[0][2] = _next_col; q_sa_act = 1; q_ppo_act = 1; for (int i = 0; i < THIS.actions; i++) { if(Q_SA[i][_next_row][_next_col] > Q_SA[q_sa_act][_next_row][_next_col]) { q_sa_act = i; } if(Q_PPO.policy[i][_next_row][_next_col] > Q_PPO.policy[q_ppo_act][_next_row][_next_col]) { q_ppo_act = i; } } //update last acts acts[1] = acts[0]; acts[0] = q_ppo_act; }
不过,回到 SetPolicy 函数、及其 3 个步骤,其中第一步量化了所有状态中每个动作的总计政策权重。本质上,它是一种通过调用 GetMarkov 函数得到的横盘环境状态矩阵的形式,该函数从两个索引值(代表短期和长期形态)处返回一个单独索引。一旦我们掌握了矩阵中标记为 “_policies” 的每个动作的累积权重,我们就能继续攻略政策权重的更新梯度。
上述我们引入的 PPO 结构中的梯度矩阵数组中存储的梯度,更新了我们的政策权重,很像神经网络更新其权重。不过,与大多数现代神经网络一样,获取梯度值是一个过程。首先,我们需要定义一个向量 “_probabilities”,其大小与横盘环境状态索引匹配。在这种情况下,这是 3 x 3,使其达到 9。另一处引入、或修改是针对 Cql 类,我们也曾针对 PPO 做过,即引入了尺寸为 2 的状态数组。该数组简单地记录、或缓冲参与者“经历”的最后两个环境状态索引,该记录的目的是帮助更新梯度。
故此,使用 “_policies” 矩阵,对于每个动作、及横盘的状态索引,我们有累积政策权重,我们得到每个动作的所有状态的概率分布。现在,由于政策权重可以为负值,因此我们需要将原始值归一化到 0 – 1 的范围,实现该目的最简单的方法之一是将内置激活函数与 SoftMax 激活一起使用。我们逐行执行这些激活,一旦完成,我们会得到先前状态和当前环境状态的概率。再次,为简洁起见,此处使用横盘的索引。
我们在该阶段需要获得的另一个重要量值是优势。回想一下,如上所述,这一优势有助于我们归一化、或平衡我们的政策权重更新,将基于"状态-动作"的短期权重、与基于价值的长期权重同时考虑在内,这一过程令 PPO 动作选择更好地将短期价格动作、与长期回报配对,论调如上。这种优势是我们在第一篇强化学习文章中讲述的“状态-动作”对矩阵,减去我们在时态差异文章中引入的 Q-值权重矩阵而获得的。两者都已更名,但它们的操作和原理维持不变。
据此优势,我们就可攻略需要剪辑更新的程度。正如上面概述中提到的,PPO 与其它政策管理算法不同,因为它通过确保更新不会太剧烈来调节其更新,且主要针对增量,以便获得长期成功。“_clip” 的判定由 GetClipping 函数完成,其源代码如下:
//+------------------------------------------------------------------+ //| Helper function to compute the clipped PPO objective | //+------------------------------------------------------------------+ double Cql::GetClipping(double OldProbability, double NewProbability, double Advantage) { double _ratio = NewProbability / OldProbability; double _clipped_ratio = fmin(fmax(_ratio, 1 - THIS.epsilon), 1 + THIS.epsilon); return fmin(_ratio * Advantage, _clipped_ratio * Advantage); }
这个函数的代码很简短,旧的概率不应当为零;否则,可在分母中添加一个 ε 值来检查这一点。一旦我们得到 “_clip”,其本质是归一化分数,我们将其乘以两个概率之间的差值。此处值得注意的是,裁剪和概率差之间的优势和乘积可是正值,也可是负值。这意味着更新梯度也可有符号,即负值或正值。
这导致政策权重的实际更新,如上所述,这与神经网络权重更新非常相似,且它们也是基于上述可负、可正的梯度。PPO 政策权重的符号为什么需要我们通过 SoftMax 激活,每个动作的权重总和,会在设定政策的第二阶段高亮概率分布时攻略。一旦政策权重更新后,它们会在如下修改的 Action 函数中使用,其更新清单已在上面分享。
对旧的 Action 函数的调整非常小,因为我们简单地检查政策权重的量级,其中遵照我们上面的 PPO 更新方案,选择权重最高的动作。给定下一个动作,我们现能调用 GetOutput 函数提取它,正如上面已经重申的那样,该函数也定义了环境状态矩阵,下面给出了它的清单。
//+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ void CSignalPPO::GetOutput(Cql *QL, int RewardSign) { vector _in, _in_row, _in_row_old, _in_col, _in_col_old; if ( _in_row.Init(m_scale) && _in_row.CopyRates(m_symbol.Name(), m_period, 8, 0, m_scale) && _in_row.Size() == m_scale && _in_row_old.Init(m_scale) && _in_row_old.CopyRates(m_symbol.Name(), m_period, 8, 1, m_scale) && _in_row_old.Size() == m_scale && _in_col.Init(m_scale) && _in_col.CopyRates(m_symbol.Name(), m_period, 8, 0, m_scale) && _in_col.Size() == m_scale && _in_col_old.Init(m_scale) && _in_col_old.CopyRates(m_symbol.Name(), m_period, 8, m_scale, m_scale) && _in_col_old.Size() == m_scale ) { _in_row -= _in_row_old; _in_col -= _in_col_old; vector _in_e; _in_e.Init(m_scale); QL.Environment(_in_row, _in_col, _in_e); int _row = 0, _col = 0; QL.SetMarkov(int(_in_e[m_scale - 1]), _row, _col); double _reward_float = RewardSign*_in_row[m_scale - 1]; double _reward_max = RewardSign*_in_row.Max(); double _reward_min = RewardSign*_in_row.Min(); double _reward = QL.GetReward(_reward_max, _reward_min, _reward_float, RewardSign); if(m_policy) { QL.SetOnPolicy(_reward, _in_e); } else if(!m_policy) { QL.SetOffPolicy(_reward, _in_e); } } }
它与上面的 Action 函数很像,与我们在强化学习文章中用到的函数非常相似,鉴于我们目前在 PPO 调用的关键函数是隐藏的,修改看似不存在(除了一些关键遗漏);即 SetPolicy 函数和 GetClipping 函数。这看似是我们一直在用的 GetOutput 的注水淡化版本。作为上述内容的回顾,此处所见的 “m_scale” 可作为我们的滞后,其将短期市场趋势与长期趋势分离,同时使用单一时间帧。读者可探索采用不同时间帧替代,但在这种情况下,必须添加替代时间帧作为输入。在自定义信号类中,我们的“重大”修改是在做多和做空条件函数之中,其代码分享如下:
//+------------------------------------------------------------------+ //| "Voting" that price will grow. | //+------------------------------------------------------------------+ int CSignalPPO::LongCondition(void) { int result = 0; GetOutput(RL_BUY, 1); if(RL_BUY.q_ppo_act==0) { result = 100; } return(result); } //+------------------------------------------------------------------+ //| "Voting" that price will fall. | //+------------------------------------------------------------------+ int CSignalPPO::ShortCondition(void) { int result = 0; GetOutput(RL_SELL, -1); if(RL_SELL.q_ppo_act==2) { result = 100; } return(result); }
该清单与我们一直所用的几乎相同,主要区别在于引用了 “q_ppo_act”,于单纯从马尔可夫决策过程中选择动作相异。
策略测试器报告和分析
我们利用 MQL5 向导将该自定义信号类汇编到智能系统当中。对于新读者,这里和这里都有关于如何行事的指南。如果我们从覆盖 2022 年 H4 时间帧 GBPJPY 的优化里提取一些有利设置,它们会给我们呈现以下结果:

如常,此处呈现的结果旨在展示自定义信号的潜力。该报告所用输入设置未经交叉验证,因此不会分享。诚邀读者参与其中,根据自己的期望进行定制。
我对此的理念是,任何智能系统,无论是全自动、亦或支持手工交易系统,对整个“交易系统”的贡献都不能超过 50%。人类的情感永远占另一半。故此,即使您向不熟悉其复杂性、或其运作方式的人展示一个“圣杯”,他也必然会变得浮躁,并对其众多关键交易决策进行事后猜疑。故此,通过呈现没有“圣杯”设置的自定义信号,读者不仅可以理解为什么智能系统会在文章讲解的短暂优化周期内表现良好,还能理解为什么它在不同的测试期间表现或许不相似,且这两个信息应当有助于揭示在更长时间内有效设置的过程。
我相信交易者开发自己的设置、或将不同的自定义信号组合成可行的智能系统的过程,就是他们弥补 50% 的过程。
结束语
我们已研究了另一种强化学习算法,即近端政策优化,它是一种非常流行、有效的方法,归因于它在强化学习事件期间会调节政策更新。
PPO 算法提出了一种开创性的强化学习方式,融合了政策稳定性和适应性,这对于交易等现世的应用至关重要。其量身定制的裁剪策略可适应离散和连续动作,并提供可扩展的效能,无需依赖大量资源,这对于常遇各种市场条件的复杂系统来说具有无与伦比的价值。
| 文件名 | 描述 |
|---|---|
| Cql.mqh | 强化学习源类 |
| SignalWZ_49.mqh | 自定义信号类文件 |
| wz_49.mqh | 向导汇编的智能系统,其头部作为显示所用的文件 |
本文由MetaQuotes Ltd译自英文
原文地址: https://www.mql5.com/en/articles/16448
注意: MetaQuotes Ltd.将保留所有关于这些材料的权利。全部或部分复制或者转载这些材料将被禁止。
本文由网站的一位用户撰写,反映了他们的个人观点。MetaQuotes Ltd 不对所提供信息的准确性负责,也不对因使用所述解决方案、策略或建议而产生的任何后果负责。
开发多币种 EA 交易(第 19 部分):创建用 Python 实现的阶段
价格行为分析工具包开发(第五部分):波动率导航智能交易系统(Volatility Navigator EA)
构建K线趋势约束模型(第九部分):多策略智能交易系统(EA)(三)
