处理 tpl 图表模板

MQL5 API 提供了两个用于处理模板的函数。模板是扩展名为 tpl的文件,用于保存图表的所有内容,即图表的全部设置,以及已绘制的图形、指标和 EA(如有)。

bool ChartSaveTemplate(long chartId, const string filename)

该函数将当前图表的设置保存为指定名称的 .tpl 模板文件。

图表由 chartId参数指定,其中 0 表示当前活动图表。

保存模板的文件名(filename)可以不指定“.tpl”扩展名:系统会自动添加该扩展名。默认情况下,模板会被保存到terminal_dir/Profiles/Templates/文件夹中,随后可以在终端中手动应用这些模板。不过,除了指定文件名外,还可以指定相对于 MQL5 目录的路径,尤其是以“/Files/”开头的路径。因此,我们可以使用 文件 操作函数打开保存的模板,对其进行分析,并在必要时对它们进行编辑(请参阅后续的 ChartTemplate.mq5示例)。

如果指定路径中已存在同名文件,其内容将被覆盖。

稍后,我们将看到一个保存和应用模板的综合示例。

bool ChartApplyTemplate(long chartId, const string filename)

该函数将指定文件中的模板应用到 chartId对应的图表上。

将按照以下规则搜索模板文件:

  • 如果 filename包含路径(以反斜杠“\”或正斜杠“/”开头),则模板将相对于目录进行匹配 terminal_data_directory/MQL5.
  • 如果文件名中不包含路径,则将在调用该函数的 EX5 文件可执行文件所在的同一位置搜索模板。
  • 如果在前两个位置未找到模板,则会在标准模板文件夹 terminal_dir/Profiles/Templates/中搜索模板。

请注意,terminal_data_directory指的是存储修改后文件的文件夹,其位置可能因操作系统类型、用户名和计算机安全设置而异。通常,它与terminal_dir文件夹不同,尽管在某些情况下(例如使用管理员组账户操作时),它们可能会相同。可以使用 TerminalInfoString 函数查找 terminal_data_directoryterminal_directory 文件夹的位置(请分别参阅 TERMINAL_DATA_PATH 和 TERMINAL_PATH 常量)。

ChartApplyTemplate 调用将创建一个命令,该命令会被添加到图表的消息队列中,且仅在所有先前命令处理完成后才会执行。

加载模板会停止图表上运行的所有 MQL 程序,包括启动加载操作的程序。如果模板中包含指标和 EA 交易,将启动它们的新实例。

出于安全考虑,将包含 EA 交易的模板应用于图表时,可以限制 交易权限 。如果调用ChartApplyTemplate函数的 MQL 程序没有交易权限,则通过模板加载的 EA 交易也将无交易权限,此限制不受模板设置影响。如果调用 ChartApplyTemplate的 MQL 程序被允许交易,但模板设置中禁止交易,则加载的 EA 交易仍无法进行交易。

脚本 ChartDuplicate.mq5的一个示例允许创建当前图表的副本。

void OnStart()
{
   const string temp = "/Files/ChartTemp";
   if(ChartSaveTemplate(0temp))
   {
      const long id = ChartOpen(NULL0);
      if(!ChartApplyTemplate(idtemp))
      {
         Print("Apply Error: "_LastError);
      }
   }
   else
   {
      Print("Save Error: "_LastError);
   }
}

首先,使用 ChartSaveTemplate创建一个临时 tpl 模板文件,然后打开新图表(调用 ChartOpen),最后通过 ChartApplyTemplate 函数将该模板应用于新图表。

但在许多情况下,程序员面临更具挑战性的任务:不仅仅是应用模板,还要预先编辑模板内容。

通过使用模板,你可以更改许多无法通过其他 MQL5 API 函数修改的图表特性。例如,在不同时间范围下指标的可见性、指标子窗口的排列顺序以及应用于这些子窗口的对象。

tpl 文件格式与终端在会话之间存储图表时使用的 .chr 文件格式完全相同(位于 terminal_directory/Profiles/Charts/profile_name文件夹中)。

tpl 文件是具有特定语法的文本文件。其中的特性可以是单行书写的 key=value 键值对,也可以是包含多个 key=value 键值对特性的某种分组结构。这类分组在下文中将统称为“容器”,因为除了包含独立特性外,它们还可以包含其他嵌套的容器。

容器以类似“<tag>”的行开头,其中tag是预定义容器类型之一,并以配套的类似“</tag>”行结束(标签名称必须匹配)。换言之,这种格式在某种程度上类似于 XML(但没有头文件),其中所有词法单元必须必须单独成行,且标签特性并非通过其特性(如 XML 中在开始部分 <tag attribute1=value1...>)来表示,而是通过标签内部文本来表示。

支持的标签列表:

  • chart - 根容器,包含主图表特性及所有子容器;
  • expert - 包含 EA 交易通用特性的容器,例如交易权限(位于 chart 内);
  • window - 包含窗口/子窗口特性的容器及其从属容器(位于 chart 内);
  • object - 包含图形对象特性的容器(位于 window 内);
  • indicator - 包含指标特性的容器(位于 window 内);
  • graph - 包含指标图表特性的容器(位于 indicator 内);
  • level - 包含指标水平位特性的容器(位于 indicator 内);
  • period - 包含对象或指标在特定时间范围下的可见性特性的容器(位于 object 或 indicator 内);
  • inputs - 包含自定义指标和 EA 交易的设置的容器(input 变量)。

key=value 键值对形式的特性列表可能非常广泛,且没有官方文档记录。如果有必要,你可以自行处理平台的这些特性。

以下是一个 tpl 文件的片段(格式中的缩进用于直观展示容器的嵌套结构):

<chart>
id=0
symbol=EURUSD
description=Euro vs US Dollar
period_type=1
period_size=1
digits=5
...
<window>
  height=117.133747
  objects=0
  <indicator>
    name=Main
    path=
    apply=1
    show_data=1
    ...
    fixed_height=-1
  </indicator>
</window>
<window>
  <indicator>
    name=Momentum
    path=
    apply=6
    show_data=1
    ...
    fixed_height=-1
    period=14
    <graph>
      name=
      draw=1
      style=0
      width=1
      color=16748574
    </graph>
  </indicator>
  ...
</window>
</chart>

我们有用于处理 tpl 文件的TplFile.mqh头文件,通过该文件,你可以分析和修改模板。它包含两个类:

  • Container – 用于读取并存储文件元素(考虑层级结构/嵌套关系),以及在可能的修改后写入文件;
  • Selector – 用于按顺序遍历层级结构中的元素(容器对象),以查找与特定查询匹配的元素,该查询以类似于 XPath 选择器的字符串形式编写(例如 "/path/element[attribute=value]")。

Container类的对象通过构造函数创建,该构造函数将用于读取的 tpl 文件描述符作为第一个参数,将标签名称作为第二个参数。默认情况下,标签名称为 NULL,这表示根容器(整个文件)。因此,容器本身会在读取文件的过程中自动填充内容(请参阅 read方法)。

当前元素的特性(即直接位于此容器内的 "key=value" 键值对)应被添加到MapArray<string,string> properties映射中。嵌套容器会被添加到 Container *children[]数组中。

#include <MQL5Book/MapArray.mqh>
   
#define PUSH(A,V) (A[ArrayResize(AArraySize(A) + 1) - 1] = V)
   
class Container
{
   MapArray<string,stringproperties;
   Container *children[];
   const string tag;
   const int handle;
public:
   Container(const int hconst string t = NULL): handle(h), tag(t) { }
   ~Container()
   {
      for(int i = 0i < ArraySize(children); ++i)
      {
         if(CheckPointer(children[i]) == POINTER_DYNAMICdelete children[i];
      }
   }
      
   bool read(const bool verbose = false)
   {
      while(!FileIsEnding(handle))
      {
         string text = FileReadString(handle);
         const int len = StringLen(text);
         if(len > 0)
         {
            if(text[0] == '<' && text[len - 1] == '>')
            {
               const string subtag = StringSubstr(text1len - 2);
               if(subtag[0] == '/' && StringFind(subtagtag) == 1)
               {
                  if(verbose)
                  {
                     print();
                  }
                  return true;       // the element is ready
               }
               
               PUSH(childrennew Container(handlesubtag)).read(verbose);
            }
            else
            {
               string pair[];
               if(StringSplit(text, '=', pair) == 2)
               {
                  properties.put(pair[0], pair[1]);
               }
            }
         }
      }
      return false;
   }
   ...
};

read方法中,我们将读取文件并逐行解析。如果遇到类似 <tag> 的开始标签,我们将创建一个新的容器对象,并在其中继续读取内容。如果遇到类似 </tag> 且名称相同的结束标签,我们将返回一个成功标志(true),表示该容器已生成完毕。在剩余行中,我们将读取 "键 = 值" 键值对并将它们添加到 properties数组中。

我们已经准备好Selector用于在模板中搜索元素。一个包含被搜索标签层级结构的字符串将被传递给其构造函数。例如,字符串 "/chart/window/indicator" 对应一个图表,该图表包含一个窗口/子窗口,而该窗口又包含任意指标。搜索结果将是第一个匹配项。此查询通常会找到报价图表,因为它在模板中作为名为 "Main" 的指标存储,且位于文件开头、其他子窗口之前。

更实用的查询是指定特定特性的名称和值。具体来说,修改后的字符串 "/chart/window/indicator[name=Momentum]" 将仅查找Momentum指标。这种搜索方式与调用 ChartWindowFind不同,因为在此处指定名称不带参数,而 ChartWindowFind 使用指标简称,该简称通常包含参数值,但参数值可能会变化。

对于内置指标,name特性包含指标自身名称;而对于自定义指标,则显示为 "Custom Indicator"。自定义指标的链接在 path特性中以可执行文件路径的形式提供,例如:"Indicators\MQL5Book\IndTripleEMA.ex5"。

我们来看看 Selector类的内部结构。

class Selector
{
   const string selector;
   string path[];
   int cursor;
public:
   Selector(const string s): selector(s), cursor(0)
   {
      StringSplit(selector, '/', path);
   }
   ...

在构造函数中,我们将 selector查询分解为独立的组件,并将它们保存到path 数组中。在模式中,当前正在匹配的路径组件由cursor变量提供。搜索开始时,我们位于根容器(即考虑整个 tpl 文件),且cursor为 0。当找到匹配项时,cursor应递增(请参阅下文 accept 方法)。

在该类中,运算符 [] 被重载,通过该运算符可以获取路径的第 i 个片段。它还考虑了在片段中,方括号内可以指定 "[key=value]" 对。

   string operator[](int iconst
   {
      if(i < 0 || i >= ArraySize(path)) return NULL;
      const int param = StringFind(path[i], "[");
      if(param > 0)
      {
         return StringSubstr(path[i], 0param);
      }
      return path[i];
   }
   ...

accept方法用于检查元素的名称(tag)及其特性(properties)是否与选择器路径中当前光标位置指定的数据相匹配。this[cursor]记录使用了上述重载的 [] 运算符。

   bool accept(const string tagMapArray<string,string> &properties)
   {
      const string name = this[cursor];
      if(!(name == "" && tag == NULL) && (name != tag))
      {
         return false;
      }
      
      // if the request has a parameter, check it among the properties
      // NB! so far only one attribute is supported, but many "tag[a1=v1][a2=v2]..." are needed
      const int start = StringLen(path[cursor]) > 0 ? StringFind(path[cursor], "[") : 0;
      if(start > 0)
      {
         const int stop = StringFind(path[cursor], "]");
         const string prop = StringSubstr(path[cursor], start + 1stop - start - 1);
         
         // NB! only '=' is supported, but it should be '>', '<', etc.
         string kv[];   // key and value
         if(StringSplit(prop, '=', kv) == 2)
         {
            const string value = properties[kv[0]];
            if(kv[1] != value)
            {
               return false;
            }
         }
      }
      
      cursor++;
      return true;
   }
   ...

如果标签名称与路径的当前片段不匹配,或者片段中包含某个参数的值且该值不相等或不存在于数组 properties中,该方法将返回false。在其他情况下,我们将获得条件匹配,其结果是游标将向前移动(cursor++)且该方法将返回 true

当游标到达请求中的最后一个片段时,搜索过程将成功完成,因此我们需要一个方法来判断这一时刻,即 isComplete方法。

   bool isComplete() const
   {
      return cursor == ArraySize(path);
   }
   
   int level() const
   {
      return cursor;
   }

此外,在模板分析过程中可能会出现以下情况:我们遍历了路径的部分容器层级(即找到多个匹配项),但后续的请求片段不再匹配。在这种情况下,需要“返回”到请求的前一级别,为此实现了 unwind方法。

   bool unwind()
   {
      if(cursor > 0)
      {
         cursor--;
         return true;
      }
      return false;
   }
};

现在,我们已准备好使用Selector对象在容器层级结构(读取 tpl 文件后得到的结构)中执行搜索。所有必要操作都将由 Container类中的find 方法执行。它将 Selector对象作为输入参数,并在根据Selector::accept 方法找到匹配项时递归调用自身。到达请求的末尾意味着成功,此时 find方法会将当前容器返回给调用代码。

   Container *find(Selector *selector)
   {
      const string element = StringFormat("%*s"2 * selector.level(), " ")
         + "<" + tag + "> " + (string)ArraySize(children);
      if(selector.accept(tagproperties))
      {
         Print(element + " accepted");
         
         if(selector.isComplete())
         {
            return &this;
         }
         
         for(int i = 0i < ArraySize(children); ++i)
         {
            Container *c = children[i].find(selector);
            if(creturn c;
         }
         selector.unwind();
      }
      else
      {
         Print(element);
      }
      
      return NULL;
   }
   ...

请注意,当我们沿着对象树移动时,find方法会记录当前对象的标签名称和嵌套对象的数量,并且记录时会添加与对象嵌套层级成比例的缩进。如果该项与请求匹配,则会在日志条目中追加 "accepted" 一词。

还需要注意的是,此实现会返回第一个匹配的元素,并不再继续搜索其他候选项,从理论上讲,这对模板很有用,因为模板中同一容器内通常会有多个相同类型的标签。例如,一个窗口可能包含许多对象,而 MQL 程序可能需要解析对象的完整列表。这一方面建议作为可选研究内容。

为简化搜索调用,已添加一个同名方法,该方法接受字符串参数并在本地创建 Selector对象。

   Container *find(const string selector)
   {
      Selector s(selector);
      return find(&s);
   }

由于我们要编辑模板,因此应提供修改容器的方法,特别是用于添加键值对和具有指定标签的新嵌套容器的方法。

   void assign(const string keyconst string value)
   {
      properties.put(keyvalue);
   }
   
   Container *add(const string subtag)
   {
      return PUSH(childrennew Container(handlesubtag));
   }
   
   void remove(const string key)
   {
      properties.remove(key);
   }

编辑完成后,需要将容器的内容写回文件(相同或不同文件)。辅助方法 save将按上述 tpl 格式保存对象:以开始标签 "<tag>" 开头,接着导出所有 key=value 键值对特性,然后对嵌套对象调用save 方法,最后以结束标签 "</tag>" 结尾。文件描述符作为保存操作的参数传入。

   bool save(const int h)
   {
      if(tag != NULL)
      {
         if(FileWriteString(h"<" + tag + ">\n") <= 0)
            return false;
      }
      for(int i = 0i < properties.getSize(); ++i)
      {
         if(FileWriteString(hproperties.getKey(i) + "=" + properties[i] + "\n") <= 0)
            return false;
      }
      for(int i = 0i < ArraySize(children); ++i)
      {
         children[i].save(h);
      }
      if(tag != NULL)
      {
         if(FileWriteString(h"</" + tag + ">\n") <= 0)
            return false;
      }
      return true;
   }

将整个模板写入文件的高级方法称为write。其输入参数(文件描述符)可以等于 0,这意味着写入的文件就是之前读取的文件。但是,文件必须以可写权限打开。

需要注意的是,当覆盖 Unicode 文本文件时,MQL5 不会写入初始 UTF 标记(即所谓的 BOM,字节顺序标记),因此我们必须自行处理这一步骤。否则,没有该标记的话,终端将无法读取和应用我们的模板。

如果调用代码向 h参数传入另一个专门以 Unicode 格式打开用于写入的文件,MQL5 将自动写入 BOM。

   bool write(int h = 0)
   {
      bool rewriting = false;
      if(h == 0)
      {
         h = handle;
         rewriting = true;
      }
      if(!FileGetInteger(hFILE_IS_WRITABLE))
      {
         Print("File is not writable");
         return false;
      }
      
      if(rewriting)
      {
         // NB! We write the BOM manually because MQL5 does not do this when overwritten
         ushort u[1] = {0xFEFF};
         FileSeek(hSEEK_SET0);
         FileWriteString(hShortArrayToString(u));
      }
      
      bool result = save(h);
      
      if(rewriting)
      {
         // NB! MQL5 does not allow to reduce file size,
         // so we fill in the extra ending with spaces
         while(FileTell(h) < FileSize(h) && !IsStopped())
         {
            FileWriteString(h" ");
         }
      }
      return result;
   }

为了演示新类的功能,我们以隐藏特定指标窗口为例进行说明。如你所知,用户可以通过在指标特性对话框(Display选项卡)中重置时间范围的可见性标志来实现这一点。从编程角度来看,这无法直接实现。这正是模板编辑能力发挥作用的地方。

在模板中,指标对时间范围的可见性是在 <indicator> 容器中指定的,其中为每个可见的时间范围单独编写了一个 <period> 容器。例如,M15 时间范围的可见性如下:

<period>
period_type=0
period_size=15
</period>

在 <period> 容器内部,会使用 period_typeperiod_size 特性。 period_type 是一个度量单位,可选值如下:

  • 0 表示分钟
  • 1 表示小时
  • 2 表示周
  • 3 表示月

period_size 是时间范围中的度量单位数量。需要注意的是,日线时间范围被指定为 24 小时。

当 <indicator> 容器中没有嵌套的 <period> 容器时,该指标将在所有时间范围中显示。

本书附带了脚本 ChartTemplate.mq5,该脚本可向图表添加 Momentum 指标(如果还不存在),并将其设置为仅在月线时间范围下可见。

void OnStart()
{
   // if Momentum(14) is not on the chart yet, add it
   const int w = ChartWindowFind(0"Momentum(14)");
   if(w == -1)
   {
      const int momentum = iMomentum(NULL014PRICE_TYPICAL);
      ChartIndicatorAdd(0, (int)ChartGetInteger(0CHART_WINDOWS_TOTAL), momentum);
      // not necessarily here because the script will exit soon,
      // however explicitly declares that the handle will no longer be needed in the code
      IndicatorRelease(momentum);
   }
   ...

接下来,我们将当前图表模板保存到一个文件中,然后打开该文件进行写入和读取操作。可以分配一个单独的文件用于写入。

   const string filename = _Symbol + "-" + PeriodToString(_Period) + "-momentum-rw";
   if(PRTF(ChartSaveTemplate(0"/Files/" + filename)))
   {
      int handle = PRTF(FileOpen(filename + ".tpl",
         FILE_READ | FILE_WRITE | FILE_TXT | FILE_SHARE_READ | FILE_SHARE_WRITE));
      // alternative - another file open for writing only
      // int writer = PRTF(FileOpen(filename + "w.tpl",
      //    FILE_WRITE | FILE_TXT | FILE_SHARE_READ | FILE_SHARE_WRITE));

在获得文件描述符后,我们将创建一个根容器 main,并将整个文件读取到该容器中(嵌套容器及其所有特性将自动读取)。

      Container main(handle);
      main.read();

然后,我们将定义一个选择器来搜索Momentum指标。理论上,更严谨的方法还需要检查指定周期 (14),但我们的类不支持同时查询多个特性(此可能性留作自主研究)。

使用选择器搜索目标对象,输出找到的结果(仅作参考),并为其添加嵌套的 <period> 容器以设置月线时间范围的显示。

      Container *found = main.find("/chart/window/indicator[name=Momentum]");
      if(found)
      {
         found.print();
         Container *period = found.add("period");
         period.assign("period_type""3");
         period.assign("period_size""1");
      }

最后,我们将修改后的模板写入同一个文件,关闭该文件并将其应用到图表上。

      main.write(); // or main.write(writer);
      FileClose(handle);
      
      PRTF(ChartApplyTemplate(0"/Files/" + filename));
   }
}

在空白图表上运行该脚本时,我们将在日志中看到如下条目。

ChartSaveTemplate(0,/Files/+filename)=true / ok
FileOpen(filename+.tpl,FILE_READ|FILE_WRITE|FILE_TXT| »
» FILE_SHARE_READ|FILE_SHARE_WRITE|FILE_UNICODE)=1 / ok
 <> 1 accepted
  <chart> 2 accepted
    <window> 1 accepted
      <indicator> 0
    <window> 1 accepted
      <indicator> 1 accepted
Tag: indicator
                    [key]    [value]
[ 0] "name"               "Momentum"
[ 1] "path"               ""        
[ 2] "apply"              "6"       
[ 3] "show_data"          "1"       
[ 4] "scale_inherit"      "0"       
[ 5] "scale_line"         "0"       
[ 6] "scale_line_percent" "50"      
[ 7] "scale_line_value"   "0.000000"
[ 8] "scale_fix_min"      "0"       
[ 9] "scale_fix_min_val"  "0.000000"
[10] "scale_fix_max"      "0"       
[11] "scale_fix_max_val"  "0.000000"
[12] "expertmode"         "0"       
[13] "fixed_height"       "-1"      
[14] "period"             "14"      
ChartApplyTemplate(0,/Files/+filename)=true / ok

由此可见,在找到所需指标(标记为 "accepted")之前,算法已先在主窗口中找到该指标,但该指标不符合要求,因为其名称所需的 "Momentum" 不一致。

现在,如果打开图表上的指标列表,将看到 momentum,并且在其特性对话框的 Display 选项卡上,唯一启用的时间范围是 Month

本书附有TplFileFull.mqh文件的扩展版本,该版本支持在标签选择条件中使用不同的比较运算,以及将多个匹配标签选择到数组中。在脚本 ChartUnfix.mq5中可以找到使用该功能的示例,该脚本可解除所有图表子窗口的固定大小。