
神经网络变得简单(第 67 部分):按照过去的经验解决新任务
概述
强化学习建立在与环境互动过程时从环境中获得的最大奖励之上。显然,学习过程需要与环境不断互动。不过,状况有所不同。在解决某些任务时,我们与环境交互时可能会遇到各种限制。对于这种状况,一个可能的解决方案是使用离线强化学习算法。它们允许您依据与环境初步交互期间收集的有限轨迹存档(当它可用时)上训练模型。
当然,离线强化学习也有一些瑕疵。特别是,当我们与有限的训练样本打交道时,研究环境的问题变得更加尖锐,它无法适应环境的所有多变性。在复杂的随机环境中尤其如此。在上一篇文章中,我们讨论过解决该任务的选项之一(ExORL 方法)。
不过,有时与环境的交互限制可能至关重要。环境探索的过程可以伴随着积极和消极的回报。负面奖励可能是高度不受待见,且可能伴随着经济损失、或其它一些您无法接受的不必要损失。但任务很少是“凭空诞生的”。大多数情况下,我们会优化现有流程。在我们这个信息技术时代,我们几乎总能从以往解决类似上述任务的过程中,找到正在探索环境的互动经验。可以使用来自实际的与环境交互的数据,这些数据可在某种程度上覆盖所需的动作和状态空间。在《依据真实数据源的真实世界离线强化学习》一文中讲述了使用此类经验解决真实机器人应对新任务的实验。该论文的作者提出了一个训练模型的新框架:真实-ORL。
1. 真实-ORL框架
离线强化学习(ORL)针对马尔可夫(Markov)决策环境进行建模。假定访问提前生成的数据集,其形式是使用单个或混合行为政策收集的轨迹。ORL 的目标是利用离线数据集来训练一个几乎最优的政策 π。一般来说,由于探索不足和训练数据集有限,学习不可能得到最优政策 π*。在这种情况下,我们的目标是基于可用数据集进行训练,找到最优政策。
大多数离线强化学习算法都包含某种形式的正则化或保守主义。可以采取以下形式,但不限于:
- 政策梯度的正则化
- 近似动态规划
- 利用环境模型进行学习
真实-ORL 框架的作者没有提供新的模型训练算法。在它们的工作中,它们会探索以前代表性 ORL 算法的范围,并在实际用例中评估它们在物理机器人上的性能。该框架的作者提请注意,本文中分析的学习算法主要把模拟作为重点,使用理想的数据集、独立且同期的数据集。然而,这种方式在真实的随机世界中并不正确,这在于动作会伴随着操作延迟。这就在物理机器人上限制了使用经过训练的政策。目前尚不清楚来自模拟基准测试、或有限设备评估的结果是否能够普适真实世界的过程。论文《依据真实数据源的真实世界离线强化学习》旨在填补这一空白。它阐述了应用于真实世界学习任务的几种离线强化学习算法的实证研究,强调训练集区域之外的普适性。
下一个,模仿学习是机器人学习控制政策的另一种方式。与通过优化奖励来训练政策的 RL 不同,模拟学习旨在复制专家的示范。在大多数情况下,它使用监督学习方法,将奖励函数排除在学习过程之外。强化学习和模仿学习的结合同样有趣。
在他们的论文中,真实-ORL 框架的作者使用了一个离线数据集,由来自启发式手工政策的轨迹组成。这些轨迹是在专家的监督下收集的,代表了一个高品质的数据集。该方法的作者在实证研究中将离线模仿学习(特别是行为克隆)作为基本算法。
为了最大限度地提高估测学习方法的客观性,本文研究了四个经典的操纵任务,它们代表了一组常见的操纵挑战。每个任务的建模,都作为具有唯一奖励函数的 MDP。每种所分析学习方法都用于解决所有 4 个任务,这把所有算法置于绝对相等的条件下。
如上所述,训练数据是采用专家监督下开发的政策收集的。基本上,收集了所有四个任务的成功轨迹。该框架的作者认为,收集含有各种噪声的次优轨迹、或扭曲专家轨迹对于机器人技术来说是不可接受的,因为扭曲或随机行为是不安全的,且有损于设备的技术条件。同时,使用从各种任务中收集的数据,为在真实机器人上应用离线强化学习提供了更真实的环境,原因有三:
- 自主收集真实机器人的“随机/探索性”数据,需要广泛的安全限制、监督和专家指导。
- 使用智能系统大量记录此类随机数据,比用它来收集真实世界的重大轨迹更有意义。
- 基于如此强大的数据集开发特定任务的策略,并对 ORL 能力进行压力测试,比使用受损数据集更切实。
真实-ORL 框架的作者为了避免任务(或算法)的偏见带来的乖离,提前冻结了数据集。
为了在所有任务中训练智能体的政策,真实-ORL 的作者将每个任务分解为更简单的阶段,并标记为子目标。智能体朝子目标迈出一小步,直到满足某些特定任务的准则。由于控制器的噪声和跟踪误差,以这种方式训练的政策并未达成理论上的最大可能结果。不过,它们以很高的成功率完成任务,且其性能可与人类示范相比。
真实-ORL 的作者进行的实验包括 3000 多个训练轨迹、3500 多个估测轨迹、以及 270 多个人工时。通过广泛的研究,他们发现:
- 对于区域内任务,强化学习算法可以普适至数据稀缺问题区域、以及动态问题。
- 使用异构数据之后,ORL 性能的变化往往会因智能体、任务设计、和数据特征而异。
- 某些异构的、与任务无关的轨迹可以提供重叠的数据支持,并能更好的学习,从而令 ORL 智能体提高其性能。
- 每个任务的最佳智能体即可以是 ORL 算法,亦或 ORL 和 BC 之间的等同物。论文中提出的估测表明,即使在区域外数据模式下,对于真实世界来说,离线强化学习也是一种更现实有效的方式。
以下是作者提供的真实-ORL 框架的可视化。
2. 利用 MQL5 实现
论文《依据真实数据源的真实世界离线强化学习》实证确认了离线强化学习方法在解决真实世界任务方面的有效性。但引起我注意的是,利用解决类似任务的数据来构建智能体政策。此处数据的唯一准则是环境。也就是说,收集的数据集必须来自我们正在分析的环境交互的结果。
我们如何从中受益?至少,我们收到了有关环境探索的广泛信息,而在我们的案例中是金融市场。我们已经多次讨论过强化学习的主要任务之一,即环境探索。同时,我们总有大量并没有用到的信息。我说的是信号。在下面的屏幕截图中,我特意删除了作者和信号名称。在我们的实验中,信号的唯一准则是所选金融工具在训练区间的历史段是否存在交易。
我们依据 EURUSD 金融产品 2023 年前 7 个月的时间区间训练模型。这些准则将用于选择信号。这些信号既可以是付费信号,也可以是免费信号。请注意,在付费信号中,部分历史被隐藏了。不过,通常只有最后的成交是隐藏的。但我们对开仓历史感兴趣。
在“帐户”选项卡上,我们检查感兴趣区间的操作。
在“统计”选项卡上,我们检查金融产品的操作。但我们并非正在寻找只针对感兴趣的金融产品有效的信号。我们稍后将排除不必要的成交。
我同意这是一个相当近似且间接的分析。它不保证在所需的历史区间内,所分析的金融产品存在成交。但有成交的可能性相当高。这种分析非常简单、且易于执行。
当我们找到合适的信号时,我们转到信号的“交易历史”选项卡,并下载包含操作历史的 csv 文件。
请注意,下载的文件必须保存在 MetaTrader 5 的公共文件夹 “...\AppData\Roaming\MetaQuotes\Terminal\Common\Files\” 当中。为了便于使用,我创建了一个子目录 “Signals”,并将所有信号的文件重命名为 “SignalX.csv”,其中 X 是保存的信号历史的序列号。
此处应当注意的是,正在研究的 “真实-ORL” 框架涉及使用选定轨迹,作为与环境交互的经验。这并不意味着它承诺完全克隆轨迹。因此,在选择轨迹时,我们不会检查成交与我们所用指标的相关性(或任何其它统计分析)。出于同样的原因,您不应当指望一个经过训练的模型完全重复最有利可图的动作、或任何其它所用的信号。
使用这种方法,我选择了 20 个信号。不过,我们不能使用结果的 csv 文件来训练我们的模型。我们需要把成交映射到成交时刻的历史价格走势数据和指标读数,并收集每个用到的信号轨迹。我们将在 EA “...\RealORL\ResearchRealORL.mq5” 中执行此功能,但首先我们将做一点准备工作。
为了记录信号交易历史中的每笔交易业务,我们将创建一个 CDeal 类。该类仅供内部使用。为了剔除不必要的操作,我们将省略访问类变量的包装器。所有变量都声明为公开。
class CDeal : public CObject { public: datetime OpenTime; datetime CloseTime; ENUM_POSITION_TYPE Type; double Volume; double OpenPrice; double StopLos; double TakeProfit; double point; //--- CDeal(void); ~CDeal(void) {}; //--- vector<float> Action(datetime current, double ask, double bid, int period_seconds); };
类变量比照 MetaTrader 5 中的 DEAL 字段。我们只省略了品种名称的变量,因为我们假设只工作于一个金融品种。不过,如果您要构建多币种模型,您应自行添加品种名称。
另请注意,在成交中,我们以价格的形式指定止损和止盈,而模型在生成智能体动作时则按相对单位。为了能够转换数据,我们将一个品种的点数大小存储在 point 变量之中。
在类的构造函数中,我们将用初始值填充变量。类的析构函数保持为空。
void CDeal::CDeal(void) : OpenTime(0), CloseTime(0), Type(POSITION_TYPE_BUY), Volume(0), OpenPrice(0), StopLos(0), TakeProfit(0), point(1e-5) { }
为了把成交转换为智能体动作的向量,我们将创建 Action 方法。在其参数中,我们将传递当前柱线的开盘日期和时间、买入价和卖出价,以及所分析时间帧的间隔(以秒为单位)。我们总是在每根柱线开盘时进行市场分析和所有交易操作。
注意,我们收集在历史记录中的交易操作时刻的信号,也许与我们所用时间帧内的柱线开盘时间不同。如果我们能在柱线内以止损或止盈平仓,那么我们只能在柱线开盘时开仓。因此,此处我们做一个假设,并对开仓价格和时间进行小幅调整:如果在历史记录中,开仓信号早于柱线收盘时间,则我们在柱线开盘时开仓。
遵循这个逻辑,在方法代码中,如果当前时间小于柱线开盘时间或大于柱线收盘时间,则该方法将返回一个智能体动作的零向量。
vector<float> CDeal::Action(datetime current, double ask, double bid, int period_seconds) { vector<float> result = vector<float>::Zeros(NActions); if((OpenTime - period_seconds) > current || CloseTime <= current) return result;
请注意,我们首先创建一个空的结果向量,然后再实现时间控制,并返回结果。这种方式允许我们依据生成的零结果向量进一步操作。因此,如果有必要填充动作向量,我们只填充非零元素。
取决于仓位类型,动作向量在 switch 分支选择语句的主体之中填充。在多头持仓的情况下,我们将操作量记录在索引为 0 的元素当中。然后我们检查止盈和止损,看看它们是否不同于 0,并在必要时将价格转换为相对值。将结果值分别写入索引为 1 和 2 的元素当中。
switch(Type) { case POSITION_TYPE_BUY: result[0] = float(Volume); if(TakeProfit > 0) result[1] = float((TakeProfit - ask) / (MaxTP * point)); if(StopLos > 0) result[2] = float((ask - StopLos) / (MaxSL * point)); break;
对空头持仓执行类似的操作,但向量元素的索引要偏移 3。
case POSITION_TYPE_SELL: result[3] = float(Volume); if(TakeProfit > 0) result[4] = float((bid - TakeProfit) / (MaxTP * point)); if(StopLos > 0) result[5] = float((StopLos - bid) / (MaxSL * point)); break; }
生成的向量将返回给调用方。
//--- return result; }
我们将在 CDeals 类中合并一个信号的所有成交。该类将包含一个对象的动态数组,我们将往其中添加上述创建的 CDeal 类实例,和 2 个方法:
- LoadDeals 方法从 csv 历史记录文件里加载成交;
- Action 方法生成一个智能体动作的向量。
class CDeals { protected: CArrayObj Deals; public: CDeals(void) { Deals.Clear(); } ~CDeals(void) { Deals.Clear(); } //--- bool LoadDeals(string file_name, string symbol, double point); vector<float> Action(datetime current, double ask, double bid, int period_seconds); };
在类的构造函数和析构函数之中,我们清除成交的动态数组。
我提议从 csv 文件里加载交易历史记录的 LoadDeals 方法开始研究该类的方法。在方法参数中,我们传递文件名、预分析的金融产品名称、和点数大小。我特意在参数中包含了品种名称,因为不同经纪商的金融产品名称经常存在差异。因此,即使 EA 在所分析金融产品的图表上运行,其名称也可能与来自信号历史文件中的统称不同。
bool CDeals::LoadDeals(string file_name, string symbol, double point) { if(file_name == NULL || !FileIsExist(file_name, FILE_COMMON)) { PrintFormat("File %s not exist", file_name); return false; }
在方法的主体中,我们首先检查文件名、及其在终端公共文件夹中是否存在。如果未找到所需的文件,通知用户,并以 false 为结果结束该方法。
bool CDeals::LoadDeals(string file_name, string symbol, double point) { if(file_name == NULL || !FileIsExist(file_name, FILE_COMMON)) { PrintFormat("File %s not exist", file_name); return false; }
下一步是检查指定金融品种的名称。如果未找到该名称,则用运行 EA 的图表的品种名称替代。
if(symbol == NULL) { symbol = _Symbol; point = _Point; }
控制模块成功通过后,打开方法参数中指定的文件,立即使用接收到的句柄值检查操作结果。如果由于某种原因无法打开文件,通知用户有关发生的错误,并以负值结果终结该方法。
ResetLastError(); int handle = FileOpen(file_name, FILE_READ | FILE_ANSI | FILE_CSV | FILE_COMMON, short(';'), CP_ACP); if(handle == INVALID_HANDLE) { PrintFormat("Error of open file %s: %d", file_name, GetLastError()); return false; }
此刻,准备工作阶段已经完成,我们转到组织数据读取循环。在每次循环迭代之前,我们都要检查是否已到达文件的末尾。
FileSeek(handle, 0, SEEK_SET); while(!FileIsEnding(handle)) { string s = FileReadString(handle); datetime open_time = StringToTime(s); string type = FileReadString(handle); double volume = StringToDouble(FileReadString(handle)); string deal_symbol = FileReadString(handle); double open_price = StringToDouble(FileReadString(handle)); volume = MathMin(volume, StringToDouble(FileReadString(handle))); datetime close_time = StringToTime(FileReadString(handle)); double close_price = StringToDouble(FileReadString(handle)); s = FileReadString(handle); s = FileReadString(handle); s = FileReadString(handle);
在循环的主体中,我们首先读取一笔业务的所有信息,并将其写入局部变量。根据文件结构,最后 3 个元素包含成交的佣金、掉期利息、和利润。我们不会用到轨迹中的这些数据,因为开仓时间和价格也许与历史记录中显示的时间和价格不同。因此,利润值也可能不同。此外,佣金和掉期利息取决于经纪商的设置。
接下来,我们检查交易操作的金融产品与我们正分析品种的对应关系,其是在参数中传递的。如果品种不匹配,转至循环的下一次迭代。
if(StringFind(deal_symbol, symbol, 0) < 0) continue;
如果成交是按所需的金融产品执行的,我们将创建一个成交描述对象的实例。
ResetLastError(); CDeal *deal = new CDeal(); if(!deal) { PrintFormat("Error of create new deal object: %d", GetLastError()); return false; }
然后我们填充它。不过,请留意以下之处。我们可以轻松保存:
- 仓位类型
- 开仓和平仓时间
- 开仓价
- 交易量
- 一点数的大小
但止损和止盈价格并未在交易历史中显示。取而代之,仅显示实际平仓的价格。我们将在此采用十分简单的逻辑:
- 我们引入了一个假设,即该笔持仓是通过止损或止盈平仓的。
- 在这种情况下,如果持仓以获利平仓,则由止盈平仓。否则,它由止损平仓。在相应的字段(止损或止盈)中,标记平仓价。
- 对立的字段保持为空。
deal.OpenTime = open_time; deal.CloseTime = close_time; deal.OpenPrice = open_price; deal.Volume = volume; deal.point = point; if(type == "Sell") { deal.Type = POSITION_TYPE_SELL; if(close_price < open_price) { deal.TakeProfit = close_price; deal.StopLos = 0; } else { deal.TakeProfit = 0; deal.StopLos = close_price; } } else { deal.Type = POSITION_TYPE_BUY; if(close_price > open_price) { deal.TakeProfit = close_price; deal.StopLos = 0; } else { deal.TakeProfit = 0; deal.StopLos = close_price; } }
我完全明白无止损交易的风险,但同时我期待在模型的下游训练期间将其降至最低。
我们把所创建成交描述加到动态数组之中,然后转到循环的下一次迭代。
ResetLastError(); if(!Deals.Add(deal)) { PrintFormat("Error of add new deal: %d", GetLastError()); return false; } }
到达文件末尾后,我们关闭它,并以 true 为结果退出方法。
FileClose(handle); //--- return true; }
生成智能体动作向量的算法非常简单。我们遍历整个成交数组,并为每笔成交调用相应的方式。
vector<float> CDeals::Action(datetime current, double ask, double bid, int period_seconds) { vector<float> result = vector<float>::Zeros(NActions); for(int i = 0; i < Deals.Total(); i++) { CDeal *deal = Deals.At(i); if(!deal) continue; vector<float> action = deal.Action(current, ask, bid, period_seconds);
不过,有一些细微差别。我们假设在历史记录中,一个信号可以并发开立若干笔持仓,包括不同的方向。因此,我们需要将从存档中获得的所有成交加进向量。但我们只能增加交易量。简单地添加止损和止盈价位是不正确的。请记住,在智能体的动作向量中,止损和止盈被指定为距当前价格的相对单位的偏移。因此,当往向量里加入止损和止盈价位时,我们取最大偏差。未按时平仓的交易量将在新烛条开盘时由 EA 平仓,因为在这种情况下,我们预计总持仓的总交易量会降低。
result[0] += action[0]; result[3] += action[3]; result[1] = MathMax(result[1], action[1]); result[2] = MathMax(result[2], action[2]); result[4] = MathMax(result[4], action[4]); result[5] = MathMax(result[5], action[5]); } //--- return result; }
我们将智能体动作的最终向量传递给调用程序,并终止该方法。
据此,我们完成了准备工作,并转到 EA “...\RealORL\ResearchRealORL.mq5” 的工作。该 EA 是在前面讨论的 EA “...\...\Research.mq5” 的基础上创建的,因此它继承了它们的构造模板。它还继承了所有外部参数。
//+------------------------------------------------------------------+ //| Input parameters | //+------------------------------------------------------------------+ input ENUM_TIMEFRAMES TimeFrame = PERIOD_H1; input double MinProfit = -10000; //--- input group "---- RSI ----" input int RSIPeriod = 14; //Period input ENUM_APPLIED_PRICE RSIPrice = PRICE_CLOSE; //Applied price //--- input group "---- CCI ----" input int CCIPeriod = 14; //Period input ENUM_APPLIED_PRICE CCIPrice = PRICE_TYPICAL; //Applied price //--- input group "---- ATR ----" input int ATRPeriod = 14; //Period //--- input group "---- MACD ----" input int FastPeriod = 12; //Fast input int SlowPeriod = 26; //Slow input int SignalPeriod = 9; //Signal input ENUM_APPLIED_PRICE MACDPrice = PRICE_CLOSE; //Applied price //--- input int Agent = 1;
同时,该 EA 不使用任何模型,因为我们使用信号交易的历史记录,其中已经为有了交易操作的决定。因此,我们删除了所有模型对象,并添加了一个 CDeals 信号成交数组对象。
SState sState; STrajectory Base; STrajectory Buffer[]; STrajectory Frame[1]; CDeals Deals; //--- float dError; datetime dtStudied; //--- CSymbolInfo Symb; CTrade Trade; //--- MqlRates Rates[]; CiRSI RSI; CiCCI CCI; CiATR ATR; CiMACD MACD; //--- double PrevBalance = 0; double PrevEquity = 0;
同样,在 EA 初始化方法中,我们加载交易操作的历史记录,取代加载预训练模型。
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { //--- if(!Symb.Name(_Symbol)) return INIT_FAILED; Symb.Refresh(); //--- if(!RSI.Create(Symb.Name(), TimeFrame, RSIPeriod, RSIPrice)) return INIT_FAILED; //--- if(!CCI.Create(Symb.Name(), TimeFrame, CCIPeriod, CCIPrice)) return INIT_FAILED; //--- if(!ATR.Create(Symb.Name(), TimeFrame, ATRPeriod)) return INIT_FAILED; //--- if(!MACD.Create(Symb.Name(), TimeFrame, FastPeriod, SlowPeriod, SignalPeriod, MACDPrice)) return INIT_FAILED; if(!RSI.BufferResize(HistoryBars) || !CCI.BufferResize(HistoryBars) || !ATR.BufferResize(HistoryBars) || !MACD.BufferResize(HistoryBars)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); return INIT_FAILED; } //--- if(!Trade.SetTypeFillingBySymbol(Symb.Name())) return INIT_FAILED; //--- load history if(!Deals.LoadDeals(SignalFile(Agent), "EURUSD", SymbolInfoDouble(_Symbol, SYMBOL_POINT))) return INIT_FAILED; //--- PrevBalance = AccountInfoDouble(ACCOUNT_BALANCE); PrevEquity = AccountInfoDouble(ACCOUNT_EQUITY); //--- return(INIT_SUCCEEDED); }
注意,在加载信号成交数据时,我们指示 SignalFile(Agent),代替文件名。此处我们使用宏替换。这就是为什么我们之前创建了统一的信号文件名 "SignalX.csv"。宏替换返回信号历史记录文件的统一名称,按外部 Agent 参数指示值作为标识符。
#define SignalFile(agent) StringFormat("Signals\\Signal%d.csv",agent)
这允许我们随后在 MetaTrader 5 策略测试器中以优化模式运行 “...\RealORL\ResearchRealORL.mq5”。通过 Agent 参数的优化,每次验算都能依据自己的信号历史文件。以这种方式,我们将能够并行处理若干个信号文件,并从中收集与环境交互的轨迹。
与环境的交互是在 OnTick 方法中实现的。此处,如常,我们首先检查新柱线开盘事件的发生情况。
void OnTick() { //--- if(!IsNewBar()) return;
如有必要,我们会下载历史价格走势数据。我们还更新了处理指标的对象缓冲区。
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;
在奖励向量中,我们立即填充余额和净值变化的影响元素。
sState.rewards[0] = float((sState.account[0] - PrevBalance) / PrevBalance); sState.rewards[1] = float(1.0 - sState.account[1] / PrevBalance);
并保存我们在下一根柱线上计算奖励所需的余额和净值。
PrevBalance = sState.account[0]; PrevEquity = sState.account[1];
替代智能体的前馈验算,我们从信号交易的历史记录中请求一个动作向量。
vector<float> temp = Deals.Action(TimeCurrent(), SymbolInfoDouble(_Symbol, SYMBOL_ASK), SymbolInfoDouble(_Symbol, SYMBOL_BID), PeriodSeconds(TimeFrame) );
动作向量的处理和解码,是根据早前准备好的算法实现的。首先,我们排除了多方向交易量。
double min_lot = Symb.LotsMin(); double step_lot = Symb.LotsStep(); double stops = MathMax(Symb.StopsLevel(), 1) * 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] > 0 && (temp[1] * MaxTP * Symb.Point()) <= stops) || (temp[2] > 0 && (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 = (temp[1] > 0 ? NormalizeDouble(Symb.Ask() + temp[1] * MaxTP * Symb.Point(), Symb.Digits()) : 0); double buy_sl = (temp[2] > 0 ? NormalizeDouble(Symb.Ask() - temp[2] * MaxSL * Symb.Point(), Symb.Digits()) : 0); 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(temp[3] < min_lot || (temp[4] > 0 && (temp[4] * MaxTP * Symb.Point()) <= stops) || (temp[5] > 0 && (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 = (temp[4] > 0 ? NormalizeDouble(Symb.Bid() - temp[4] * MaxTP * Symb.Point(), Symb.Digits()) : 0); double sell_sl = (temp[5] > 0 ? NormalizeDouble(Symb.Bid() + temp[5] * MaxSL * Symb.Point(), Symb.Digits()) : 0); 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); } }
在方法的末尾,我们将数据添加到奖励向量之中,复制动作向量,并传递结构添加到轨迹当中。
if((buy_value + sell_value) == 0) sState.rewards[2] -= (float)(atr / PrevBalance); else sState.rewards[2] = 0; for(ulong i = 0; i < NActions; i++) sState.action[i] = temp[i]; sState.rewards[3] = 0; sState.rewards[4] = 0; if(!Base.Add(sState)) ExpertRemove(); }
我们针对 EA “...\RealORL\ResearchRealORL.mq5” 方法的回顾到此结束,因为其余方法的用法没有变更。附件中提供了 EA 和本文中用到的所有程序的完整代码。
真实-ORL 方法的作者没有提出学习扮演者政策的新方法。在我们的实验中,我们既未修改政策学习算法,亦或模型架构。我们有意识地采取这一步,令条件与上一篇文章中的模型训练相当。最终,这将令我们能够评估 “真实-ORL” 框架本身对政策学习结果的影响。
3. 测试
上面,我们收集了有关各种信号的交易操作的信息,并准备了一个智能交易系统,将收集到的信息转换为与环境交互的轨迹。现在,我们转入测试已完成的工作,并评估所选轨迹对模型训练结果的影响。在这项工作中,我们将训练全新模型,并依据随机参数初始化。在上一篇文章中,我们优化了之前训练的模型。
首先,我们运行 EA 将信号历史转换为轨迹 “...\RealORL\ResearchRealORL.mq5”。我们将在完全优化模式下运行 EA。
我们将仅针对一个参数进行优化 Agent。在参数范围内,我们将指示信号文件的第一个和最后一个 ID,增量为 “1”。
结果是一些非常有趣的轨迹。
在分析期间,有五次验算以亏损收场,而有一次余额翻倍。
单次验算盈利最高的轨迹,在 2023 年 7 月 2 日和 2023 年 7 月 25 日显示出相当深的回撤。我不会讨论信号作者使用的策略,因为我并不熟悉它。此外,回撤很可能是因提前开仓引起的,原因在于开仓时间点是在所分析时间帧的柱线开盘时刻,与实际开仓时间有偏移引起的。当然,还有止损的用法,我们故意将其设置为零,在这种情况下,将导致更多亏损。
保存轨迹后,我们转入训练模型。为此,我们运行 EA “...\RealORL\Study.mq5”。
仅据从信号操作结果中收集的轨迹数据进行初级训练。我必须承认奇迹并未发生。初始训练后的模型结果距预期甚远。训练策略在 2023 年前 7 个月的训练期间,和 2023 年 8 月的测试历史间隔中都产生了亏损。但我不会说拟议的 “真实-ORL” 框架无效。选定的 20 条轨迹与框架作者使用的 3000 条轨迹相去甚远。这 20 条轨迹甚至没有涵盖智能体各种可能动作的一小部分。
在继续训练之前,使用 EA “...\RealORL\Research.mq5” 将更多数据添加到训练轨迹的缓冲区之中。该 EA 根据智能体的预训练政策执行决策。多亏智能体的潜在状态和政策的随机性,对环境的探索得以执行。两个随机性创建了相当多的智能体动作,这令探索环境成为可能。随着智能体政略的学习,由于每个参数的变化减小,两个随机性都会降低。这令智能体的动作更具可预测性和意识。
我们在缓冲区中添加了 200 条新轨迹,并重复模型训练过程。
这一次,智能体政策训练过程相当漫长。在我得到可盈利的政策之前,我不得不用 “...\RealORL\Research.mq5” EA 多次更新经验回放缓冲区。请注意,经验回放缓冲区完全填满后,在更新的过程中,我们会将亏损最高(盈利最低)的轨迹替换为盈利更多的轨迹。因此,我们只替换了使用 “...\RealORL\Research.mq5” EA 收集的轨迹。来自信号的轨迹,由于其普遍的盈利能力,始终保留在经验回放缓冲区当中。
如前所述,作为长期训练的结果,我设法获得了能够在训练集上产生盈利的政策。甚至,由此产生的政策能够将获得的经验推广到新数据当中。训练区间之后的历史数据的盈利证明了这一点。
根据测试样本的历史数据,智能体进行了 131 笔业务,其中 48.85% 的获利了结。最大盈利交易比最大亏损低近 10%(分别为 379.89 和 398.49)。同时,平均盈利交易比平均亏损高出 40%。结果就是,测试期间的盈利因子为 1.34,恢复因子为 0.94。
还应当注意的是,多头(70)和空头(61)业务之间几乎是相当的。这表明智能体有能力识别局部趋势,而非仅随全局趋势。
结束语
在本文中,我们讨论了 “真实-ORL” 框架,它来自机器人技术。该框架的作者在工作中使用真实机器人进行了相当广泛的实证研究,这令他们能够得出以下结论:
- 对于区域内任务,强化学习算法可以普适至数据稀缺问题区域、以及动态问题。
- 使用异构数据之后,ORL 性能的变化往往会因智能体、任务设计、和数据特征而异。
- 某些异构的、与任务无关的轨迹可以提供重叠的数据支持,并能更好的学习,从而令 ORL 智能体提高其性能。
- 每个任务的最佳智能体即可以是 ORL 算法,亦或 ORL 和 BC 之间的等同物。论文中提出的估测表明,即使在区域外数据模式下,对于真实世界来说,离线强化学习也是一种更现实有效的方式。
在我们的工作中,我们研究了将提议的框架用于金融市场领域的可能性。特别是,“真实-ORL” 框架的作者提出的方法令我们能够利用市场上存在的各种不同信号的历史来训练模型。然而,为了最大限度地提高环境的多样性,我们需要大量的轨迹。因此,这需要尽可能多地收集不同的轨迹。在这项工作中仅使用 20 条轨迹可能被视为一个错误。“真实-ORL” 的作者在他们的工作中使用了 3000 多条轨迹。
我个人的看法是,该方法可以、而且应该用于模型的初始训练,并且比收集随机轨迹具有优势。不过,仅使用“冻结”轨迹数据不足以构建最优智能体政策。很难期待从我选择的少量轨迹中得到严谨的结果。但是,该方法的作者在他们的工作中也未能获得理论上的最大可能结果。此外,有关信号的信息是有限的,且未考虑到所有风险。例如,信号不包含有关止损和止盈的信息。缺乏这些数据阻碍了对风险的全面评估和控制。因此,在信号轨迹上训练模型需要对获得的其它轨迹进一步优调,同时考虑到预训练的政策。
参考
文中所用程序
# | 名称 | 类型 | 说明 |
---|---|---|---|
1 | Research.mq5 | EA | 样本收集 EA |
2 | ResearchRealORL.mq5 | EA | 用于使用 Real-ORL 方法收集示例的 EA |
3 | ResearchExORL.mq5 | EA | 使用 ExORL 方法收集示例的 EA |
4 | Study.mq5 | EA | 智能体训练 EA |
5 | Test.mq5 | EA | 模型测试 EA |
6 | Trajectory.mqh | 类库 | 系统状态定义结构 |
7 | NeuroNet.mqh | 类库 | 创建神经网络的类库 |
8 | NeuroNet.cl | 代码库 | OpenCL 程序代码库 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/13854


