English Русский Español Deutsch 日本語 Português
MQL5 编程基础: 文件

MQL5 编程基础: 文件

MetaTrader 5示例 | 28 十二月 2016, 08:55
10 691 0
Dmitry Fedoseev
Dmitry Fedoseev

内容

概论

如同许多其它编程语言, MQL5 具有处理文件的功能。虽然处理文件在开发 MQL5 智能交易系统和指标时不是很常见的任务, 但每个开发人员都迟早会面临这一挑战。需要处理文件的问题范围足够广泛。这里面包括生成自定义交易报告, 为 EA 或指标创建含有复杂参数的特殊文件, 读取行情数据 (例如, 财经日历), 等等。本文涵盖了 MQL5 中拥有的所有文件操纵函数。它们当中的每一个都伴有一个简单的实际任务, 旨在磨练您的技能。除了这些任务, 本文还研究了一定数量的可在实践中应用的函数。

MQL5 文档所包括的文件函数 在此。  

读取文本文件

最简单和最频繁使用的函数是读取文本文件。让我们来直接操练。打开 MetaEditor。选择文件 — 打开数据文件夹。在新窗口里打开 MQL5 文件夹。之后, 打开 Files 文件夹。此文件夹所含文件可供 MQL5 文件函数处理。这种限制确保数据安全。MetaTrader 用户踊跃共享 MQL5 应用。若无此限制, 就会很容易遭到入侵并危害您的电脑, 诸如重要文件被删除或破坏, 个人隐私被窃。

在最新打开的 MQL5/Files 文件夹里创建文本文件。为此, 在文件夹的任意位置点击并选择 新建 — 文本文档。将文件命名为 "test"。它的全名应为 "test.txt"。我推荐在您的电脑上启用显示文件扩展名。

在文件更名之后, 打开它。它会在记事本编辑器里打开。在文件里写入 2-3 行文本并将之保存。确认在保存为窗口的下拉菜单里选择 ANSI 编码 (图例. 1)。


图例. 1. 在 Windows 的记事本里保存文件。红色箭头示意选择文件编码 

现在, 我们将通过 MQL5 读取文件。在 MetaEditor 中创建一个脚本并将其命名为 sTestFileRead。

此文件应在读写文件之前打开, 并在操作之后关闭。通过 FileOpen() 函数打开的文件有两个强制参数。第一个是文件名。我们应在此指定 "test.txt"。请注意我们指定的是相对于 MQL5/Files 文件夹的路径, 而非完整路径。第二个参数是定义文件操作模式的 标志 组合。我们将要读取文件, 所以我们应指定 FILE_READ 标志。"test.txt" 是一个以 ANSI 编码的文件, 其意味着我们应使用两个标志: FILE_TXT 和 FILE_ANSI。组合标志是通过 "|" 符号指明为逻辑 "或" 操作。

函数 FileOpen() 返回文件句柄。我们不会深入有关句柄函数的细节。我们只说它是一个数字值 (int), 可用来替代字符串形式的文件名。当打开文件时要指定文件的字符串名, 而在其后文件句柄用于执行该文件的操作动作。

我们来打开文件 (在 sTestFileRead 脚本的 OnStart() 函数里写入代码):

int h=FileOpen("test.txt",FILE_READ|FILE_ANSI|FILE_TXT);

之后, 确认文件真的被打开。这可通过检查返回的句柄值来完成:

if(h==INVALID_HANDLE){
   Alert("打开文件错误");
   return; 
}

文件打开错误十分常见。如果文件已打开, 它被可以被再次打开。文件也许在一些第三方应用里被打开。例如, 文件也许在 Windows 的记事本和 MQL5 里被同时打开。但如果它在 Microsoft Excel 中打开, 那么它可以在任何地方打开。  

从文本文件里读取数据 (以 FILE_TXT 标志打开) 可通过 FileReadString() 函数来完成。读取是按照逐行进行。一次函数调用读取文件的单独一行。我们来读取一行并将其显示在消息窗里。

string str=FileReadString(h);
Alert(str);

关闭文件:

FileClose(h);

请注意调用 FileReadString() 和 FileClose() 函数时要指定 FileOpen() 函数打开文件时返回的句柄。

现在, 您可以运行 sTestFileRead 脚本。如果有出错的地方, 请将您的代码与下面附带的 sTestFileRead 文件比较。来自 "test.txt" 文件的首行应作为脚本操作的结果出现在窗口里 (图例. 2)。

 
图例. 2. 脚本 sTestFileRead 的操作结果

迄今, 我们已从 "test.txt" 文件中读取了一行。为了读取剩余的两行, 我们可以再调用 FileReadString() 函数两次, 但实际情况中文件的行数通常预先不明。为解决此难题, 我们应使用 FileIsEnding() 函数和 while 操作符。如果我们在读取时抵达文件末尾, 则 FileIsEnding() 函数返回 'true'。我们来编写一个定制函数, 使用 FileIsEnding() 函数读取所有文件行并将它们显示在消息窗口。对于处理文件的各种教习实验它都很有用。我们得到以下函数:

void ReadFileToAlert(string FileName){
   int h=FileOpen(FileName,FILE_READ|FILE_ANSI|FILE_TXT);
   if(h==INVALID_HANDLE){
      Alert("打开文件错误");
      return;
   }   
   Alert("=== 开始 ===");   
   while(!FileIsEnding(h)){
      string str=FileReadString(h);
      Alert(str);   
   }
   FileClose(h);

 我们来创建 sTestFileReadToAlert 脚本, 将函数复制到其内并从脚本的 OnStart() 函数里调用它:

void OnStart(){
   ReadFileToAlert("test.txt");
}

消息窗口里包含 "=== 开始 ===" 行且 "test.txt" 文件的所有三行出现。文件现在完整读取 (图例. 3)。 


图例. 3. 我们已使用 FileIsEnding() 函数和 'do while' 循环读取整个文件   

创建文本文件

为了创建文件, 使用 FileOpen() 函数打开它。打开 "test.txt" 文件时要使用 FILE_READ 标志替代 FILE_WRITE:

int h=FileOpen("test.txt",FILE_WRITE|FILE_ANSI|FILE_TXT);

打开文件之后, 如同读取文件一样确认检查句柄。如果函数执行成功, 新的 "test.txt" 文件已被创建。如果文件已经存在, 它会被整个清除。打开文件写入时要当心, 不要丢失有价值的数据。  

文本文件的写入是通过 FileWrite() 函数进行。第一个参数设置文件句柄, 而第二个设置写入文件的一行内容。每次调用 FileWrite() 函数时会自动写入换行。

我们在文件里循环写入十行。最终的脚本代码 (sTestFileCreate) 如下所见:

void OnStart(){
   int h=FileOpen("test.txt",FILE_WRITE|FILE_ANSI|FILE_TXT);
   if(h==INVALID_HANDLE){
      Alert("打开文件错误");
      return;
   }
   for(int i=1;i<=10;i++){
      FileWrite(h,"Line-"+IntegerToString(i));
   }
   FileClose(h);
   Alert("文件已创建");
}

代码执行之后, 文件 "test.txt" 应包含十行。为查看文件内容, 在记事本里打开它或执行 sTestFileReadToAlert 脚本。

函数 FileWrite() 的注意事项。它的参数可多于两个。您可以传递多个字符串变量到函数, 并在写入时将它们组合成一行。在特定代码里, 函数 FileWrite() 的调用可如下编写:

FileWrite(h,"Line-",IntegerToString(i));

函数将会自动将之组合成一行。

在文本文件末尾写入

有时, 需要向已存在文件里添加一行或多行新文本, 而其余内容保持不变。为执行此动作, 文件应以同时读写模式打开。这意味着当调用 FileOpen() 函数打开文件时, 两个标志 (FILE_READ 和 FILE_WRITE) 均要指定。

int h=FileOpen("test.txt",FILE_READ|FILE_WRITE|FILE_ANSI|FILE_TXT);

如果指定名称的文件不存在, 则会创建该文件。如果它已经存在, 则其被打开时内容保持不变。然而, 如果我们立即开始写入文件, 则其先前的内容被删除, 因为此时是从文件的开头执行写入。

当操纵文件时, 会有一个 "指针" — 代表位置的数字值, 文件下一次执行操作的写入或读出点。打开文件时, 指针会自动设置在文件的开头。在数据读取或写入期间, 它会根据读取或写入数据的大小自动重新定位。若有必要, 您可以自己重新设置指针。为此, 使用 FileSeek() 函数。  

为了保留先前内容并在文件末尾添加新内容, 在写入之前将指针重定位到文件末尾。

FileSeek(h,0,SEEK_END);

有三个参数会发送到 FileSeek() 函数: 句柄, 指针重定位数值和指针平移计算模式。在此例中, 常量 SEEK_END 意即文件末尾。所以, 指针从文件末尾平移 0 个字节 (意即其最末端)。

最终的添加到尾端的脚本代码如下:

void OnStart(){
   int h=FileOpen("test.txt",FILE_READ|FILE_WRITE|FILE_ANSI|FILE_TXT);
   if(h==INVALID_HANDLE){
      Alert("打开文件错误");
      return;
   }
   FileSeek(h,0,SEEK_END);
   FileWrite(h,"附加行");
   FileClose(h);
   Alert("已添加到文件");
}

此脚本也在下面附带 (sTestFileAddToFile)。启动脚本并检查 test.txt 文件的内容。每次调用 sTestFileAddToFile 脚本都会向 test.txt 添加一行。

修改文本文件的指定行

文本文件的情况下, 在整个文件内自由移动指针的能力仅能用于在文件里附加新内容, 而不可进行修改。不可能在文件中的某行里进行更改, 因为文件行只是一个概念, 而文件实际上包含一系列连续的数据。有时, 这个系列含有在文本编辑器里不可见的特殊字符。它们表示以下信息应该显示在一个新行。如果我们将指针设置在行的开始处并开始写入, 如果写入数据的大小短于现有行的大小, 则行中的先前数据将保留。否则, 换行符将与下一行的部分数据一起被删除。

我们来尝试替换 test.txt 中的第二行。打开用于读写的文件, 读一行, 将指针重定位到第二行的开始并写入一行由两个字母 "AB" 组成的新行 (sTestFileChangeLine2-1 脚本在下面附带):

void OnStart(){
   int h=FileOpen("test.txt",FILE_READ|FILE_WRITE|FILE_ANSI|FILE_TXT);
   if(h==INVALID_HANDLE){
      Alert("打开文件错误");
      return;
   }
   string str=FileReadString(h);
   FileWrite(h,"AB");
   FileClose(h);
   Alert("完成");
}

得到的 test.txt 文件现在如下所示 (图例. 4):

 
图例. 4. 尝试更改一行后文本文件的内容 

现在, 我们有两行而不是一行: "AB" 和 "-2"。"-2" 是从第二行删除四个字符之后剩下的。原因是当使用 FileWrite() 函数写入一行时, 它会在写入文本的末尾添加换行符。在 Windows 操作系统里, 换行符包括两个字符。如果我们在 "AB" 行中添加两个字符, 我们就能理解为什么在生成的文件中删除了四个字符。  

执行 sTestFileCreate 脚本恢复 test.txt, 并尝试将第二行替换为更长的一行。我们来写入 "Line-12345" (sTestFileChangeLine2-2 脚本附加在下面):

void OnStart(){
   int h=FileOpen("test.txt",FILE_READ|FILE_WRITE|FILE_ANSI|FILE_TXT);
   if(h==INVALID_HANDLE){
      Alert("打开文件错误");
      return;
   }
   string str=FileReadString(h);
   FileWrite(h,"Line-12345");
   FileClose(h);
   Alert("完成");
}

我们来看看结果文件 (图例. 5):

 
图例. 5. 第二次尝试改变文本文件单行的结果 

由于新的一行长于以前的, 第三行也受到影响。  

对文本文件进行更改的唯一方法是完整读取并重写。我们应将文件读到数组里, 修改必要的数组元素, 逐行保存到另外的文件, 删除旧文件并将新文件改名。有时, 不需要数组: 当从一个文件里读取每行时, 它们可以写入另一个文件。在某个时间点, 在必要的行中进行更改并保存。之后, 旧文件被删除, 新的文件被重命名。

我们来使用后一选项 (无需数组进行更改)。首先, 我们应创建一个临时文件。我们来编写接收临时文件独有名称的函数。文件名和扩展名被传递到函数。在函数自身内检查文件是否存在 (通过标准 FileIsExists() 函数)。如果文件存在, 则会添加一个数字, 直到具有此名称的文件检测不存在。函数如下所示:

string TmpFileName(string Name,string Ext){
   string fn=Name+"."+Ext; // 形成名字
   int n=0;
   while(FileIsExist(fn)){ // 如果文件存在
      n++;
      fn=Name+IntegerToString(n)+"."+Ext; // 在名字里添加数字
   }
   return(fn);
}

我们来创建 sTestFileChangeLine2-3 脚本, 将函数复制到其内并在 OnStart() 函数里放置以下代码。

打开 test.txt 用于读取:

int h=FileOpen("test.txt",FILE_READ|FILE_ANSI|FILE_TXT);

接收临时文件名并打开它:

string tmpName=TmpFileName("test","txt");

int tmph=FileOpen(tmpName,FILE_WRITE|FILE_ANSI|FILE_TXT);

读取文件逐行计数。所有读取的行发送到临时文件, 且第二行被替换:

   int cnt=0;
   while(!FileIsEnding(h)){
      cnt++;
      string str=FileReadString(h);
      if(cnt==2){
         // 替换此行
         FileWrite(tmph,"New line-2");
      }
      else{
         // 无更改重写
         FileWrite(tmph,str);
      }
   }

关闭两个文件:

FileClose(tmph);
FileClose(h);

现在, 所有我们要做的就是删除源文件并将临时文件更名。标准 FileDelete() 函数用来删除。

FileDelete("test.txt");

为了给文件更名, 我们应当使用设计用来移动或文件更名的标准 FileMove() 函数。函数接收四个强制参数: 重定位文件名 (源文件), 文件重定位标志, 新文件名 (目标标志), 覆盖标志。文件名应当很明显了, 现在是时候来就近看看第二和第四个参数 — 标志。第二个参数定义源文件位置。可在 MQL5 中处理的文件不仅可以位于终端的 MQL5 / Files 文件夹中, 而且可以位于所有终端的公共文件夹中。我们将在后面更详细地研究这一点。现在, 我们设置为 0。最后一个参数定义目标文件位置。如果目标文件存在, 它还具有定义动作的附加标志。由于我们已经删除了源文件 (目标文件), 第四个参数设为 0:

FileMove(tmpName,0,"test.txt",0);

在执行 sTestFileChangeLine2-3 脚本之前, 使用 sTestFileCreate 脚本恢复 test.txt。在 sTestFileChangeLine2-3 脚本操作之后, text.txt 应该具有以下内容 (图例. 6):

 
图例. 6. 行替换之后的文件内容

让我们回到 FileMove() 函数。如果我们设置 FILE_REWRITE 标志 (允许我们重写目标文件) 作为第四个参数:

FileMove(tmpName,0,"test.txt",FILE_REWRITE);

没有必要从脚本中删除源文件。此选项在下面附带的 sTestFileChangeLine2-3 脚本中使用。 

替代 FileMove() 函数, 我们也可用其它标准函数 FileCopy(), 但在此情况下, 我们需要删除临时文件:

FileCopy(tmpName,0,"test.txt",FILE_REWRITE);
FileDelete(tmpName); 

读取文本文件至数组

在本文中已描述了一个很有用的函数 (接收一个未占用的文件名)。现在, 我们来开发另一个常用于处理文件的函数 — 将文件读取到数组。文件名和行数组被传递给函数。数组通过链接传递, 并在函数中填充文件内容。函数依据操作结果返回 true/false。 

bool ReadFileToArray(string FileName,string & Lines[]){
   ResetLastError();
   int h=FileOpen(FileName,FILE_READ|FILE_ANSI|FILE_TXT);
   if(h==INVALID_HANDLE){
      int ErrNum=GetLastError();
      printf("打开文件错误 %s # %i",FileName,ErrNum);
      return(false);
   }
   int cnt=0; // 使用变量作为文件行数计数器
   while(!FileIsEnding(h)){
      string str=FileReadString(h); // 从文件里读取下一行
      // 删除左右两侧的空格, 检测并避免使用空行
      StringTrimLeft(str); 
      StringTrimRight(str);
      if(str!=""){ 
         if(cnt>=ArraySize(Lines)){ // 数组填充完毕
            ArrayResize(Lines,ArraySize(Lines)+1024); // 增加 1024 个元素的数组大小
         }
         Lines[cnt]=str; // 发送读取的行至数组
         cnt++; // 增加读取行数计数
      }
   }
   ArrayResize(Lines,cnt);
   FileClose(h);
   return(true);
}

我们不会详细研究这个函数, 因为迄今为止在此提供的数据中已经全部很清楚了。此外, 它已被详细评论过。我们仅提及一些细微差别。从文件读取一行至 str 变量之后, 行两端的空格已通过 StringTrimLeft()StringTrimRight() 函数删除。之后, 检查 str 字符串是否不为空。这是为了跳过不必要的空行。当数组被填充时, 它以块为单位增加 1024 个元素, 而不是单个元素。函数以这种方式工作更快。最后, 根据读取行的实际数量来缩放数组。

函数可以在下面附带的 sTestFileReadFileToArray 脚本中找到。

创建具有分隔符的文本文件

迄今为止, 我们只考虑了简单的文本文件。然而, 还有另一种文本文件 — 带分隔符的文件。通常,它们带有 .csv 扩展名 ("逗号分隔值" 的缩写)。事实上, 这些是纯文本文件, 可以在文本编辑器中打开, 以及手工读取和编辑。某些字符 (没必要是逗号) 用作行中的字段分隔符。因此, 与简单文本文件相比, 您可以对它们执行一些不同的操作。主要的区别在于, 一个简单的文本文件中, 当调用 FileRedaString() 函数时, 读取整行, 而在带有分隔符的文件中, 读取将执行到分隔符或行尾。函数 FileWrite() 的工作方式也有所不同: 在函数中枚举的所有写入变量不会简单地连接到一行。代之, 在它们之间添加分隔符。 

我们来尝试创建一个 csv 文件。打开文本文件, 就像我们已经完成的写入操作那样, 指定 FILE_CSV 标志而不是 FILE_TXT 标志。第三个参数是用作分隔符的符号:

int h=FileOpen("test.csv",FILE_WRITE|FILE_ANSI|FILE_CSV,";");

我们来在文件中写入十行, 每行三个字段:

   for(int i=1;i<=10;i++){
      string str="Line-"+IntegerToString(i)+"-";
      FileWrite(h,str+"1",str+"2",str+"3");
   }

确认最后关闭文件。代码可以在下面附带的 sTestFileCreateCSV 脚本中找到。"test.csv" 文件作为结果创建。文件内容显示在图例. 7。如我们所见, FileWrite() 函数的参数现在形成了在它们之间含有分隔符的一个单独行。

 
图例. 7. 含有分隔符的文件内容

读取具有分隔符的文本文件

现在, 我们来尝试如同本文开头读取文本文件一样的方式来读取 csv 文件。我们要创建一个名为 sTestFileReadToAlertCSV 的 sTestFileReadToAlert 脚本的副本。更改 ReadFileToAlert() 函数中的第一个字符串: 

int h=FileOpen(FileName,FILE_READ|FILE_ANSI|FILE_CSV,";");

将 ReadFileToAlert() 函数更名为 ReadFileToAlertCSV() 并修改传递到函数的文件名:

void OnStart(){
   ReadFileToAlertCSV("test.csv");
}

脚本操作结果显示已读取该文件的一个字段。这会很容易判断何时读取一行的字段以及何时新行开始。函数 FileIsLineEnding() 即用于此。

我们将 sTestFileReadToAlertCSV 脚本的副本命名为 sTestFileReadToAlertCSV2, 将 ReadFileToAlertCSV 函数改为 ReadFileToAlertCSV2 并修改。加入 FileIsLineEnding() 函数: 若它返回 'true', 显示分界线 "---"。 

void ReadFileToAlertCSV2(string FileName){
   int h=FileOpen(FileName,FILE_READ|FILE_ANSI|FILE_CSV,";");
   if(h==INVALID_HANDLE){
      Alert("打开文件错误");
      return;
   }   
   Alert("=== 开始 ===");   
   while(!FileIsEnding(h)){
      string str=FileReadString(h);
      Alert(str);
      if(FileIsLineEnding(h)){
         Alert("---");
      }
   }
   FileClose(h);
}

 现在, 由脚本发送到消息窗口的字段已被分组 (图例. 8)。


图例. 8. 单一文件行字段分组之间的 "---" 分隔符 

读取具有分隔符的文件至数组

现在我们熟悉了操纵 csv 文件, 我们来开发另一个有用的函数, 读取 csv 文件到数组。结构数组在执行读取时, 其每个元素对应于一个文件行。结构将包含一行数组, 其每个元素对应一个单一的字段。 

结构:

struct SLine{
   string line[];
};

函数:

bool ReadFileToArrayCSV(string FileName,SLine & Lines[]){
   ResetLastError();
   int h=FileOpen(FileName,FILE_READ|FILE_ANSI|FILE_CSV,";");
   if(h==INVALID_HANDLE){
      int ErrNum=GetLastError();
      printf("打开文件错误 %s # %i",FileName,ErrNum);
      return(false);
   }   
   int lcnt=0; // 计算行数的变量
   int fcnt=0; // 计算行内字段数的变量
   while(!FileIsEnding(h)){
      string str=FileReadString(h);
      // 新行 (结构数组的新元素)
      if(lcnt>=ArraySize(Lines)){ // 结构数组完整填充
         ArrayResize(Lines,ArraySize(Lines)+1024); // 增加 1024 个元素的数组大小
      }
      ArrayResize(Lines[lcnt].field,64);// 改变结构的数组大小
      Lines[lcnt].field[0]=str; // 分配第一个字段值
      // 开始读取行内其它字段
      fcnt=1; // 直到行数组中的一个元素被占用
         while(!FileIsLineEnding(h)){ // 读取行内剩余字段
            str=FileReadString(h);
            if(fcnt>=ArraySize(Lines[lcnt].field)){ // 字段数组完整填充
               ArrayResize(Lines[lcnt].field,ArraySize(Lines[lcnt].field)+64); // 数组增加 64 个元素大小
            }     
            Lines[lcnt].field[fcnt]=str; // 分配下一个字段值
            fcnt++; // 增加行计数器
         }
      ArrayResize(Lines[lcnt].field,fcnt); // 根据实际字段数量更改字段数组的大小
      lcnt++; // 增加行计数器
   }
   ArrayResize(Lines,lcnt); // 根据实际行数更改结构 (行) 数组
   FileClose(h);
   return(true);
}

我们不会研究这个函数的细节, 仅在最关键的要点上稍作停留。在 while(!FileIsEnding(h)) 循环的开头读取一个字段。此处我们要添加一个元素到结构数组。检查数组大小, 如果必要, 增加 1024 个元素。一次更改字段数组大小。立即为其设置 64 个元素的大小, 并且从文件读取的第一行字段的值被分配给索引为 0 的元素。之后, 在 while(!FileIsLineEnding(h)) 循环里读取剩余字段。读取另一个字段之后, 检查数组大小, 如有必要, 增加它, 并将从文件读取的行发送到数组。从头至尾读完一行之后 (退出 while(!FileIsLineEnding(h)) 循环), 根据它们的实际数量改变字段数组大小。最后, 根据读取行的实际数量调整行数组的大小。 

函数可在下面附带的 sTestFileReadFileToArrayCSV 脚本中找到。脚本将 test.csv 文件读至数组, 并在消息窗口中显示数组。结果如图例. 8 所示。 

将数组写入具有分隔符的文本文件

如果行中的字段数预先已知, 则任务是非常简单的。类似的任务已在 "创建具有分隔符的文本文件" 部分中解决。如果字段数量未知, 可在循环中将所有带分隔符字段收集到一行中, 然后将该行写入使用 FILE_TXT 标志打开的文件。

打开文件: 

int h=FileOpen("test.csv",FILE_WRITE|FILE_ANSI|FILE_TXT);

使用分隔符将所有字段 (数组元素) 收集到一行。在行的末尾不应有分隔符, 否则在行中将有一个冗余空字段:

   string str="";
   int size=ArraySize(a);
   if(size>0){
      str=a[0];
      for(int i=1;i<size;i++){
         str=str+";"+a[i]; // 使用分隔符合并字段 
      }
   }

将该行写入文件并将其关闭:  

FileWriteString(h,str);
FileClose(h);

此例程可在下面附带的 sTestFileWriteArrayToFileCSV 脚本中找到。

UNICODE 文件

直至目前, 当打开文件定义编码时始终指定 FILE_ANSI 标志。在此编码中, 一个字符对应于一个字节, 因此, 整个集合限定在 256 个符号。然而, 现在 UNICODE 编码被广泛使用。在此编码中, 一个符号由若干个字节定义, 并且文本文件可以含有海量字符, 包括来自不同字母, 象形文字和其它图形符号的字母。

我们来进行一些实验。在编辑器中打开 sTestFileReadToAlert 脚本, 将其保存为 sTestFileReadToAlertUTF 名称下, 并将 FILE_ANSI 标志替换为 FILE_UNICODE:

int h=FileOpen(FileName,FILE_READ|FILE_UNICODE|FILE_TXT);

由于 test.txt 以 ANSI 保存, 所以新窗口包含乱码文本(图例. 9)。

  
图例. 9. 当文件的原始编码与打开文件时所指定的不匹配时, 可以看到乱码文本

显然, 这种事情的发生是因为文件的原始编码与打开文件时指定的编码不匹配。

在编辑器中打开 sTestFileCreate 脚本, 将其保存在 sTestFileCreateUTF 名称下, 并将 FILE_ANSI 标志替换为 FILE_UNICODE:

int h=FileOpen("test.txt",FILE_WRITE|FILE_UNICODE|FILE_TXT);

启动 sTestFileCreateUTF 脚本并创建新的 test.txt 文件。现在, sTestFileReadToAlertUTF 显示出合理的文本 (图例. 10)。

 
图例. 10. 使用 sTestFileReadToAlertUTF 脚本 读取由 sTestFileCreateUTF 脚本生成的文件

在记事本里打开 test.txt 并在主菜单里执行 "另存为..." 命令。请注意, 在 "另存为" 窗口底部的编码列表中选择 Unicode。记事本以某种方式定义了文件编码。Unicode 文件以标准符号集开始, 即所谓的 BOM (字节顺序标记)。稍后, 我们将回到这一点, 并编写用于定义文本文件类型 (ANSI 或 UNCODE) 的函数。 

用于处理具有分隔符的文本文件的附加功能

运用各种 文件函数 来操纵文本文件的内容 (简单文件和带分隔符的文件均有), 我们实际上只需要两个: FileWrite() 和 FileReadString()。除了其它事情, FileReadString() 函数也用来操纵二进制文件 (以下更多)。除了 FileWrite() 函数, FileWriteString() 函数也可使用, 虽然不是关键。 

当操纵含有分隔符的文本文件时, 还有一些其它函数可令操作更便捷: FileReadBool()FileReadNumber() 和 FileReadDatetime()。FileReadNumber() 函数用于读取数字。如果我们预先知道从文件所读字段仅包含数字, 我们可以应用此函数。它的效果等同于使用 FileReadString() 函数读取一行, 并用 StringToDouble() 函数将其转换为数字。类似地, FileReadBool() 函数用于读取 bool 类型的值。字符串可包含 true/false0/1。函数 FileReadDatetime() 用于读取行内的日期格式数据, 并将其转换为日期型数值。其效果类似于读取一行并使用 StringToTime() 函数转换。  

二进制文件

前面讨论的文本文件相当方便, 因为通过程序方法读取的内容与您在文本编辑器中打开文件时看到的内容一致。您可通过在编辑器中检查文件来轻松地管理程序操作结果。如有必要, 可以手工修改文件。文本文件的缺点包括操纵它们时的有限选项 (如果我们想替换单独文件行时面临的困难, 这是很明显的)。

如果文本文件很小, 使用起来很舒服。但它的尺度越大, 操纵它所需的时间就越多。如果您需要快速处理大量数据, 请使用二进制文件。

当以二进制模式打开文件时, 指定 FILE_BIN 标志替代 FILE_TXT 或 FILE_CSV。没有必要指定 FILE_ANSI 或 FILE_UNCODE 文件编码, 因为二进制文件只有数字。

当然, 我们可以在记事本文本编辑器中看看二进制文件内容。有时, 我们甚至可以看到字母和可读的文本, 但这更多是由于记事本自身, 而不是文件内容。

无论如何, 您绝对不应该在文本编辑器中编辑二进制文件, 因为它在此过程中会遭到破坏。我们不会详细说明原因, 我们仅需接受这个事实。当然, 有特殊的二进制文件编辑器, 但编辑过程仍然不直观。

二进制文件, 变量

在 MQL5 中处理文件的大多数函数都是为二进制模式设计的。有用于读/写不同类型变量的函数:  

FileReadDouble() FileWriteDouble()
FileReadFloat() FileWriteFloat()
FileReadInteger() FileWriteInteger()
FileReadLong() FileWriteLong()
FileReadString() FileWriteString()
FileReadStruct() FileWriteStruct()

我们不会在这里讲述所有种类的写/读函数。我们只需它们当中的一个, 而其余的使用相同的方式。我们试验 FileWriteDouble() 和 FileReadDouble() 函数。

首先, 创建一个文件, 向它写入三个变量, 并以随机顺序读取它们。 

打开文件:

int h=FileOpen("test.bin",FILE_WRITE|FILE_BIN);

写入三个双精度变量值 1.2, 3.45, 6.789 到文件:

FileWriteDouble(h,1.2);
FileWriteDouble(h,3.45);
FileWriteDouble(h,6.789);

不要忘记关闭文件。

代码可以在附带的 sTestFileCreateBin 脚本中找到。结果就是, test.bin 文件出现在 MQL5 / Files 文件夹中。在记事本中看看它的内容 (图例. 11)。打开记事本并将文件拖到其内:

 
图例. 11. 在记事本内的二进制文件

如我们所见, 在记事本中查看这样的文件没有意义。

现在, 我们来读文件。显然, FileReadDouble() 函数应该用于读取。打开文件:

int h=FileOpen("test.bin",FILE_READ|FILE_BIN);

声明三个变量, 从文件中读取它们的值, 并在消息窗口中显示它们:

double v1,v2,v3;
   
v1=FileReadDouble(h);
v2=FileReadDouble(h);
v3=FileReadDouble(h);
   
Alert(DoubleToString(v1)," ",DoubleToString(v2)," ",DoubleToString(v3));
  

不要忘记关闭文件。代码可以在附带的 sTestFileReadBin 脚本中找到。作为结果, 我们收到以下消息: 1.20000000 3.45000000 6.78900000。

知道二进制文件的结构, 可以在其中进行一些有限的更改。我们来尝试更改第二个变量, 而非重写整个文件。

打开文件:

int h=FileOpen("test.bin",FILE_READ|FILE_WRITE|FILE_BIN);

打开后, 将指针移动到指定位置。建议使用 sizeof() 函数计算位置。它返回指定数据类型的大小。最好熟知 数据类型 及其大小。将指针移动到第二个变量的开头:

FileSeek(h,sizeof(double)*1,0);

为了更清晰, 我们实现了 sizeof(double) * 1 乘法, 所以很清楚这是第一个变量的结束。如果需要更改第三个变量, 我们需要乘以 2。

写入新的数值: 

FileWriteDouble(h,12345.6789);

代码可在附加的 sTestFileChangeBin 脚本中找到。脚本执行后, 启动 sTestFileReadBin 脚本并收到: 1.20000000 12345.67890000 6.78900000。

您可以用同样的方式读取某个变量 (而不是整个文件)。我们来阅读从 test.bin 中读取第三个双精度变量的代码。

打开文件:

int h=FileOpen("test.bin",FILE_READ|FILE_BIN);

移动指针, 读取数值并将其显示在消息窗口:

FileSeek(h,sizeof(double)*2,SEEK_SET);
double v=FileReadDouble(h);
Alert(DoubleToString(v));

此示例可在下面附带的 sTestFileReadBin2 脚本中找到。结果就是, 我们收到以下消息: 6.78900000 - 第三个变量。更改代码以读取第二个变量。

您可以用相同的方式保存和读取其它类型的变量及其组合。重要的是知道文件结构, 并正确地计算指针设置位置。 

二进制文件, 结构

如果您需要在文件里写入多种不同类型的变量, 最便捷的是声明结构, 并读/写整个结构, 而非逐个读/写变量。文件通常以描述文件中数据位置的结构 (文件格式) 开始, 随后是数据。然而, 有一个限制: 结构不应该有动态数组和行, 因为它们的大小是未知的。

我们来实验写入和读取文件的结构。用不同类型的几个变量描述结构:

struct STest{
   long ValLong;
   double VarDouble;
   int ArrInt[3];
   bool VarBool;
};

代码可以在附加的 sTestFileWriteStructBin 脚本中找到。声明两个变量, 并在 OnStart() 函数中用不同的值填充它们:

STest s1;
STest s2;
   
s1.ArrInt[0]=1;
s1.ArrInt[1]=2; 
s1.ArrInt[2]=3;
s1.ValLong=12345;
s1.VarDouble=12.34;
s1.VarBool=true;
         
s2.ArrInt[0]=11;
s2.ArrInt[1]=22; 
s2.ArrInt[2]=33;
s2.ValLong=6789;
s2.VarDouble=56.78;
s2.VarBool=false;  

现在, 打开文件:

int h=FileOpen("test.bin",FILE_WRITE|FILE_BIN);

在其内写入两个结构:

FileWriteStruct(h,s1);
FileWriteStruct(h,s2);

不要忘记关闭文件。执行脚本创建文件。

现在, 我们来读文件。读取第二个结构。

打开文件:

int h=FileOpen("test.bin",FILE_READ|FILE_BIN);

将指针移动到第二个结构的开始处:

FileSeek(h,sizeof(STest)*1,SEEK_SET);

声明变量(将 STest 结构描述添加到文件的开头), 并从文件中读取数据:

STest s;
FileReadStruct(h,s);

在窗口中描述结构字段的值:

Alert(s.ArrInt[0]," ",s.ArrInt[1]," ",s.ArrInt[2]," ",s.ValLong," ",s.VarBool," ",s.VarDouble);   

作为结果, 我们将在消息窗口看到以下行: 11 22 33 6789 false 56.78。此行对应于第二个结构数据。

示例的代码可在下面附带的 sTestFileReadStructBin 脚本中找到。

利用变量写入结果

在 MQL5 中, 结构 字段彼此相随而无移位 (对齐), 因此可以毫无困难地读取某些结构字段。

从 test.bin 文件中的第二个结构里读取双精度变量的值。重要的是计算用于设置指针的位置: 

FileSeek(h,sizeof(STest)+sizeof(long),SEEK_SET);

其余的类似于我们在本文中已经多次做过的事情: 打开文件, 读取, 关闭。示例的代码可在下面附加的 sTestFileReadStructBin2 脚本中找到。

定义 UNICODE 文件, FileReadInteger 函数

在我们熟悉了二进制文件后, 我们可以创建一个有用的函数来定义一个 UNICODE 文件。这些文件可以通过初始字节值等于 255 来区分。代码 255 对应于不可打印的符号, 因此它不能存在于普通的 ANSI 文件中。

这意味着我们应该从文件中读取一个字节并检查其数值。函数 FileReadInteger() 用于读取各种整数变量值, 除了 长整数, 因为它接收指定读取变量大小的参数。从文件里读取一个字节到 v 变量:

uchar v=FileReadInteger(h,CHAR_VALUE);

现在, 我们只需要检查变量值。完整的代码如下所示:

bool CheckUnicode(string FileName,bool & Unicode){
   ResetLastError();
   int h=FileOpen(FileName,FILE_READ|FILE_BIN);
   if(h==INVALID_HANDLE){
      int ErrNum=GetLastError();
      printf("打开文件错误 %s # %i",FileName,ErrNum);
      return(false);
   }
   uchar v=FileReadInteger(h,CHAR_VALUE);
   Unicode=(v==255);
   FileClose(h);
   return(true);
}

根据检查是否成功, 函数返回 true/false。文件名作为第一个参数传递给函数, 而第二个参数 (通过引用传递) 在函数执行之后所包含的变量等于 true 则为 UNICODE 文件, 而 false 则为 ANSI 文件。 

函数代码及其调用的示例可在下面附带的 sTestFileCheckUnicode 脚本中找到。启动 sTestFileCreate 脚本并使用 sTestFileCheckUnicode 脚本检查其类型。之后, 启动 sTestFileCreateUTF 脚本并再次运行 sTestFileCheckUnicode 脚本。您将得到不同的结果。  

二进制文件, 数组, 结构数组

当使用大量数据时, 二进制文件的主要优点变得明显。数据通常位于数组 (因为使用单独的变量很难接收大量数据) 和字符串中。数组可由满足上述要求的标准变量和结构组成。它们不应包含动态数组和字符串。

使用 FileWriteArray() 函数将数组写入文件。文件句柄作为第一个参数传递给函数, 后跟数组名称。以下两个参数是可选的。如果您不需要保存整个数组, 请指定数组的初始元素索引和保存的元素数。 

使用 FileReadArray() 函数读取数组, 函数参数与 FileWriteArray() 函数参数相同。

我们来写入由三个元素组成的 整数 到文件: 

void OnStart(){
   int h=FileOpen("test.bin",FILE_WRITE|FILE_BIN);
   if(h==INVALID_HANDLE){
      Alert("打开文件错误");
      return;
   }   
   int a[]={1,2,3};   
   FileWriteArray(h,a);   
   FileClose(h);
   Alert("文件已写入");
}

代码可在下面附带的 sTestFileWriteArray 文件中找到。

现在, 读取 (sTestFileReadArray 脚本) 并在窗口中显示它:

void OnStart(){
   int h=FileOpen("test.bin",FILE_READ|FILE_BIN);
   if(h==INVALID_HANDLE){
      Alert("打开文件错误");
      return;
   }   
   int a[];   
   FileReadArray(h,a);   
   FileClose(h);
   Alert(a[0]," ",a[1]," ",a[2]);   
}

结果就是, 我们得到了与先前指定数组所对应的 "1 2 3" 行。请注意, 数组大小未定义, 并且在调用 FileReadArray() 函数时未指定。代之是读取整个文件。但是文件可能有多个不同类型的数组。因此, 保存文件大小也是合理的。我们将 整数双精度 数组写入文件, 起始的一个 整数 变量包含它们的大小:

void OnStart(){
   int h=FileOpen("test.bin",FILE_WRITE|FILE_BIN);
   if(h==INVALID_HANDLE){
      Alert("打开文件错误");
      return;
   }   
   
   // 两个数组
   int a1[]={1,2,3}; 
   double a2[]={1.2,3.4};
   
   // 定义数组大小
   int s1=ArraySize(a1);
   int s2=ArraySize(a2);
   
   // 写入数组 1
   FileWriteInteger(h,s1,INT_VALUE); // 写入数组大小
   FileWriteArray(h,a1); // 写入数组
   
   // 写入数组 2
   FileWriteInteger(h,s2,INT_VALUE); // 写入数组大小
   FileWriteArray(h,a2); // 写入数组   
      
   FileClose(h);
   Alert("文件已写入");
}

代码可在下面附带的 sTestFileWriteArray2 脚本中找到。 

现在, 在读取文件时, 我们首先读取数组大小, 然后读取指定数量的元素:

void OnStart(){
   int h=FileOpen("test.bin",FILE_READ|FILE_BIN);
   if(h==INVALID_HANDLE){
      Alert("打开文件错误");
      return;
   }   
   int a1[];
   double a2[];
   int s1,s2;
   
   s1=FileReadInteger(h,INT_VALUE); // 读取数组 1 的大小
   FileReadArray(h,a1,0,s1); // 按照设置在 s1 里的数值读取元素到数组 
   
   s2=FileReadInteger(h,INT_VALUE); // 读取数组 2 的大小
   FileReadArray(h,a2,0,s2); // 按照设置在 s2 里的数值读取元素到数组    

   FileClose(h);
   Alert(ArraySize(a1),": ",a1[0]," ",a1[1]," ",a1[2]," :: ",ArraySize(a2),": ",a2[0]," ",a2[1]);   
}

代码可在下面附带的 sTestFileReadArray2 脚本中找到。

结果是, 脚本显示消息: 3 : 1 2 3 - 2 : 1.2 3.4 对应于先前写入文件的数组大小和内容。

当使用 FileReadArray() 函数读取数组时, 将自动缩放数组。但是, 只有当前长度小于读取元素的数量时才执行缩放。如果数组长度超过数值, 它保持不变。仅替换数组的一部分。

操纵结构数组与操纵标准类型数组完全相同, 因为结构大小被正确定义 (没有动态数组和字符串)。我们不会在这里提供一个包含结构数组的例子。您可以自行实验它们。

另外, 请注意, 由于我们能够将指针在整个文件中移动, 因此可以只读取一个数组元素或数组的一部分。重要的是正确计算未来的指针位置。在此不展示读取单独元素的示例, 以缩短文章长度。您可以自己尝试一下。

二进制文件, 字符串, 行数组

函数 FileWriteString() 用来在二进制文件里写入字符串。两个强制参数被传递给函数: 文件句柄和写入文件的一行。第一个参数是可选的: 如果只写入行的一部分, 可以设置写入符号的数量。 

行的读取通过 FileReadString() 函数。在此函数中, 第一个参数是句柄, 而第二个 (可选) 用于设置读取字符数。

一般来说, 写入/读取一行非常类似于操纵数组: 一行与整个数组相似, 而一行中的字符与单个数组元素有很多共同点, 因此我们不会示意单行的写/读例子。代之, 我们将研究更复杂的例子: 写和读字符串数组。首先, 我们在文件里写入数组长度的 整数 变量, 然后在循环中写入单独的元素, 在开头处加入其长度的 整数 变量。 

void OnStart(){
   int h=FileOpen("test.bin",FILE_WRITE|FILE_BIN);
   if(h==INVALID_HANDLE){
      Alert("打开文件错误");
      return;
   }   
   
   string a[]={"Line-1","Line-2","Line-3"}; // 写入数组

   FileWriteInteger(h,ArraySize(a),INT_VALUE); // 写入数组长度
   
   for(int i=0;i<ArraySize(a);i++){
      FileWriteInteger(h,StringLen(a[i]),INT_VALUE); // 写入行长度 (单个数组元素)
      FileWriteString(h,a[i]);
   }

   FileClose(h);
   Alert("文件已写入");
}

代码可在附加的 sTestFileWriteStringArray 脚本中找到。

当读取时, 首先读取数组长度, 然后更改数组自身长度, 并读取其长度的单独元素:

void OnStart(){
   int h=FileOpen("test.bin",FILE_READ|FILE_BIN);
   if(h==INVALID_HANDLE){
      Alert("打开文件错误");
      return;
   }   
   
   string a[]; // 读取文件至数组
   
   int s=FileReadInteger(h,INT_VALUE); // 读取数组长度
   ArrayResize(a,s); // 更新数组长度
   
   for(int i=0;i<s;i++){ // 循环读取所有数组元素
      int ss=FileReadInteger(h,INT_VALUE); // 读取行长度
      a[i]=FileReadString(h,ss); // 读一行
   }

   FileClose(h);

   // 显示所读取的数组
   Alert("=== 开始 ===");
   for(int i=0;i<ArraySize(a);i++){
      Alert(a[i]);
   }

}

代码可在附加的 sTestFileReadStringArray 脚本中找到。 

文件的共享文件夹

直至目前, 我们已处理了位于 MQL5 / Files 目录中的文件。然而, 这不是唯一可以找到文件的地方。在 MetaEditor 的主菜单中, 执行文件 - 打开公用数据文件夹。将打开 Files 目录的文件夹。它还可以包含 MQL5 中已开发应用的可用文件。注意它的路径 (图例. 12):


图例. 12. 公用数据文件夹路径 

公共数据文件夹的路径与终端的路径以及我们在整篇文章中处理的 Files 目录无关。无论您启动了多少终端 (包括使用 "/portable" 键运行的终端), 将为它们打开同一个共享文件夹。

此文件夹的路径可以编程定义。数据文件夹的路径 (包含我们在整篇文章中使用的 MQL5/Files 目录):

TerminalInfoString(TERMINAL_DATA_PATH);

共享数据文件夹的路径 (包含 Files 目录):

TerminalInfoString(TERMINAL_COMMONDATA_PATH);

类似地, 您可以定义终端的路径 (安装终端的根目录):

TerminalInfoString(TERMINAL_PATH);

与 MQL5/Files 目录类似, 当从共享文件夹处理文件时, 不需要指定完整路径。代之, 您只需要将 FILE_COMMON 标志添加到传递给 FileOpen() 函数的标志组合。一些文件函数具有指定共享文件夹标志的特定参数。这些是 FileDelete(), FileMove(), FileCopy() 和其它一些。

将 test.txt 从 MQL5/Files 文件夹复制到公用数据文件夹:

   if(FileCopy("test.txt",0,"test.txt",FILE_COMMON)){
      Alert("文件已复制");
   }
   else{
      Alert("文件复制错误");
   }

代码可在附带的 sTestFileCopy 脚本中找到。脚本执行后, test.txt 文件将显示在共享 Files 文件夹中。如果我们第二次启动脚本, 我们将收到错误消息。为了避免它, 通过添加 FILE_REWRITE 标志允许文件覆盖:

FileCopy("test.txt",0,"test.txt",FILE_COMMON|FILE_REWRITE)

现在, 将文件从共享文件夹复制到同一文件夹的不同名文件 (sTestFileCopy2 脚本):

FileCopy("test.txt",FILE_COMMON,"test_copy.txt",FILE_COMMON)

最后, 将文件从公共文件夹复制到 MQL5/Files (sTestFileCopy3 脚本):

FileCopy("test.txt",FILE_COMMON,"test_copy.txt",0)

函数 FileMove() 以相同的方式调用, 只是不创建副本。代之, 文件被移动 (或重命名)。

测试器中的文件

直至此处, 我们的文件操纵只涉及运行 MQL5 程序 (脚本, EA, 指标) 的相关帐户 (在图表上启动)。然而, 当在测试器中启动 EA 时, 一切都是不同的。MetaTrader 5 测试器拥有利用远程代理执行分布式 (云) 测试的能力。粗略地说, 优化进程 1-10 (数字是有条件的) 在一台 PC 上执行, 进程 11-20 则在另一台 PC 上执行, 等等。这可能会引发困难并影响文件使用。在测试器中处理文件时, 我们要考虑这些特点, 并形成应遵循的原则。

当处理文件时, FileOpen() 函数访问位于终端数据文件夹中 MQL5/Files 目录中的文件。当测试时, 函数访问测试代理文件夹内部的 MQL5/Files 目录文件。如果在单一优化进程 (或单独测试) 期间需要文件, 例如, 要存储持仓或挂单数据, 则您需要做的全部就是在下次运行 (初始化 EA) 之前清除文件。如果文件是手工生成的, 且用于确定任意 EA 的操作参数, 那么它将位于终端数据文件夹的 MQL5/Files 目录中。这意味着测试器将无法看到它。为了让 EA 存取文件, 它应传递给代理。这是通过在 EA 中设置 "#property tester_file" 属性完成的。因此, 可以发送任意数量的文件:

#property tester_file "file1.txt"
#property tester_file "file2.txt"
#property tester_file "file3.txt"

然而, 即使使用 "#property tester_file" 指定文件, EA 仍然在位于测试代理目录中写入文件副本。终端数据文件夹中的文件保持不变。而 EA 对文件的进一步读取则从代理文件夹执行。换言之, 读取修改的文件。所以, 如果您需要在 EA 测试和优化期间保存一些用于进一步分析的数据, 那么将数据保存到文件不太适合。您应使用 frames 代替。

如果您不使用远程代理, 请使用共享文件夹处理文件 (打开文件时设置 FILE_COMMON标志)。在此情况下, 不需要在 EA 属性中指定文件名, EA 就能够写入文件。简而言之, 当使用公共数据文件夹时, 从测试器中处理文件要简单得多, 除了事实, 即不应使用远程代理。此外, 请注意文件名, 以便测试的 EA 不会损坏实际工作中的 EA 所用文件。在测试器中工作可用编程定义:

MQLInfoInteger(MQL5_TESTER)

当在测试时, 使用其它文件名。

文件的共享访问

如前所述, 如果文件已经打开, 则不能再次打开。如果文件已由一个应用程序处理, 则另一个程序无法访问该文件, 直到文件关闭。不过, MQL5 提供了共享文件的能力。当打开文件时, 设置附加的 FILE_SHARE_READ (共享读取) 或 FILE_SHARE_WRITE (共享写入) 标志。请小心使用标志。当今的操作系统具有多任务特征。所以, 不能保证写入 — 读取序列可正确地执行。如果您允许共享写入和读取, 也许会发生一个程序写入数据时, 另一个程序在同时读取相同 (未完成) 数据。因此, 我们应该采取其它措施来同步不同程序对文件的访问。这是一个复杂的任务, 远远超出了本文的范围。此外, 更喜欢不进行同步并共享文件 (当在终端之间使用文件交换数据时, 将在下面示出)。

当文件用于定义 EA 或指标的操作参数时, 只要您使用共享读取 (FILE_SHARE_READ) 安全地打开文件, 这种共享是合理的, 例如配置文件。手工创建文件或者由一个额外脚本创建, 然后在初始化期间由 EA 或指标的多个实例读取。在初始化期间, 若干 EA 也许会同时尝试打开文件, 而您应该允许它们这样做。与此同时, 这样还能保证读/写不会同时发生。  

在终端之间使用文件交换数据

您可以在终端之间分配数据交换, 将文件保存到共享文件夹。当然, 为此目的使用文件也许不是最好的解决方案, 但它在某些情况下是有用的。解决方案很简单: 不使用文件的共享访问。代之, 文件以通常的方式打开。当写入正在进行时, 没有其它应用可以打开文件。写入完成后, 文件将关闭, 其它程序实例不可读取。下面是 sTestFileTransmitter 脚本的数据写入函数代码:

bool WriteData(string str){
   for(int i=0;i<30 && !IsStopped();i++){ // 尝试若干次
      int h=FileOpen("data.txt",FILE_WRITE|FILE_ANSI|FILE_TXT);
      if(h!=INVALID_HANDLE){ // 文件打开成功
         FileWriteString(h,str); // 写入数据  
         FileClose(h); // 写入文件
         Sleep(100); // 加入暂停为其它程序让行 
		     // 读取数据
         return(true); // 如果成功返回 'true'
      }
      Sleep(1); // 为其它程序让行的最小暂停 
                // 结束文件读取并缓存 
                // 文件可用时刻
   }
   return(false); // 如果数据写入失败
}

尝试若干次来打开文件。打开文件之后随之写入, 关闭以及相对较长的暂停 (Sleep(100) 函数), 可让其它程序打开文件。在文件打开出错的情况下, 短暂的暂停 (Sleep(1) 函数) 可捕获文件可用时刻。

接收 (读取) 函数遵循相同的原理。下面附带的 sTestFileReceiver 脚本具有这样的函数。获取的数据通过 Comment() 函数显示。在一个图表上启动发射机脚本, 在另一个图表上 (或在另一个终端实例中) 启动接收机脚本。 

一些额外函数

我们已经研究了几乎所有的处理文件函数, 除了一些很少使用的文件: FileSize(), FileTell()FileFlush()。函数 FileSize() 返回打开文件的字节长度:

void OnStart(){
   int h=FileOpen("test.txt",FILE_READ|FILE_ANSI|FILE_TXT);
   if(h==INVALID_HANDLE){
      Alert("打开文件错误");
      return;
   }
   ulong size=FileSize(h);
   FileClose(h);
   Alert("文件长度 "+IntegerToString(size)+" (bytes)");
}

代码可在附加的 sTestFileSize 脚本中找到。脚本执行时, 将打开显示有文件长度的消息窗口。 

函数 FileTell() 返回打开文件的指针位置。此函数很少使用, 很难想到任何适当的例子。只需记住它的存在, 并在万一需要时回忆起它。

FileFlush() 函数更有用。如文档中所述, 函数将文件输入/输出缓冲区中剩余的所有数据发送到磁盘。函数调用的效果类似于关闭和重新打开文件 (尽管它更具资源效率, 文件指针保留在其初始位置)。正如我们所知, 文件在磁盘上存储为条状。不过, 直至文件被打开, 数据均先写入缓冲区而不是磁盘。当文件关闭时, 执行磁盘写入。因此, 在程序异常终止的情况下数据未能保存。如果在每次写入文件后调用 FileFlush(), 数据将保存在磁盘上, 即使程序崩溃也不会导致任何问题。

操作文件夹

除了操纵文件, MQL5 还拥有许多用来处理文件夹的函数: FolderCreate()FolderDelete() 和 FolderClean()。函数 FolderCreate 用于创建文件夹。所有函数都有两个参数。第一个是强制性的文件夹名。第二个是附加的 FILE_COMMON 标志 (用于处理公共数据文件夹中的文件夹)。 

FolderDelete() 删除指定的文件夹。仅能删除一个空文件夹。不过, 清理文件夹的内容不是问题, 因为 FolderClean() 函数可用于此。整个内容包含子文件夹和文件均被删除。 

获取文件清单

有时, 您不记得您需要的文件名。您也许记得开头但结尾不是一个数字, 例如 file1.txt, file2.txt 等。在此情况下, 可以使用掩码获取文件名, 以及 FileFindFirst(), FileFindNext(), FileFindClose() 函数。这些函数即能搜索文件也可搜索文件夹。文件夹名称可以通过末尾的反斜线与文件名区分开。

我们来编写一个有用的函数, 获取文件和文件夹的列表。我们将文件名收集到一个数组中, 而在另一个数组中收集文件夹名:

void GetFiles(string folder, string & files[],string & folders[],int common_flag=0){

   int files_cnt=0; // 文件计数器
   int folders_cnt=0; // 文件夹计数器   
   
   string name; // 接收文件和文件夹名称的变量 

   long h=FileFindFirst(folder,name,common_flag); // 接收搜索句柄 
                                      // 和第一个文件/文件夹的名称 (如果存在)
   if(h!=INVALID_HANDLE){ // 至少有单个文件和文件夹存在
      do{
         if(StringSubstr(name,StringLen(name)-1,1)=="\\"){ // 文件夹
            if(folders_cnt>=ArraySize(folders)){ // 检查数组大小, 
                                                 // 如有必要增加它
               ArrayResize(folders,ArraySize(folders)+64);
            }
            folders[folders_cnt]=name; // 发送文件夹名至数组
            folders_cnt++; // 文件夹计数        
         }
         else{ // 文件
            if(files_cnt>=ArraySize(files)){ // 检查数组大小, 
                                             // 如有必要增加它
               ArrayResize(files,ArraySize(files)+64);
            }
            files[files_cnt]=name; // 发送文件名至数组
            files_cnt++; // 文件计数
         }
      }
      while(FileFindNext(h,name)); // 接收下一个文件或文件夹的名称
      FileFindClose(h); // 搜索结束
   }
   ArrayResize(files,files_cnt); // 根据实际文件数 
                                 // 改变数组大小
   ArrayResize(folders,folders_cnt); // 根据实际文件夹数 
                                        // 改变数组大小
}

验证此函数。我们以下面的方式从脚本调用它: 

void OnStart(){

   string files[],folders[];

   GetFiles("*",files,folders);
   
   Alert("=== 开始 ===");
   
   for(int i=0;i<ArraySize(folders);i++){
      Alert("文件夹: "+folders[i]);
   }      
   
   for(int i=0;i<ArraySize(files);i++){
      Alert("文件: "+files[i]);
   }

}

 sTestFileGetFiles 脚本附于下面。注意 "*" 搜索掩码:

GetFiles("*",files,folders);

掩码允许搜索 MQL5/Files 目录中的所有文件和文件夹。

为了找到以 "test" 开头的所有文件和文件夹, 您可以使用 "test*" 掩码。如果您只需要 txt 文件, 您需要 "*.txt" 掩码, 等等。创建含有多个文件的文件夹 (例如, "folder1")。您可以使用 "folder1\\*" 掩码来接收其包含的文件列表。  

编码页

在本文中, FileOpen() 函数通常应用于示例代码。我们来研究一个我们还没有描述的参数 — 编码页。编码页是文本符号及其数值的转换表。为了更清晰, 我们来看看 ANSI 编码。编码字符表只包含 256 个字符, 这意味着在操作系统设置中定义的单独编码页用于每种语言。默认情况下调用 FileOpen() 函数, CP_ACP 对应于编码页。不太可能有人会需要使用不同的代码页, 因此详细探讨这个问题没有任何意义。一般了解就足够了。  

无限制操纵文件

有时, 您可能需要处理终端文件"沙箱" 之外 (MQL5/Files 或共享文件夹之外) 的文件。这可以显著扩展 MQL5 应用程序的功能, 允许您处理源代码文件, 自动更改它们, 为图形界面生成图像文件, 生成代码, 等等。如果您亲自做, 或雇用一个可信赖的程序员, 您可以这样做。您可以在文章 "通过 WinAPI 进行文件操作" 阅读更多内容。还有一个更容易的方法。MQL5 拥有处理文件的所有必要手段, 因此您可以将文件移动到终端的 "沙箱" 中, 执行所有必要的操作并将其移回。一个单独的 WinAPI 函数 (CopyFile) 就足够了。

应该允许应用 WinAPI 函数, 以便 MQL5 应用程序可以使用它们。在终端设置中启用该权限 (主菜单 - 工具 - 选项 - 智能交易 - 允许 DLL 导入)。在此情况下, 以后启动的所有程序启用该权限。代替通用权限, 您可以只为您要启动的程序启用权限。如果应用程序要访问 WinAPI 函数或其它 DLL, 则在其设置窗口的 "依赖项" 选项卡中显示 "允许 DLL 导入" 选项卡。

有两个版本的 CopyFile 函数: CopyFileA() 和更现代的 CopyFileW()。您可以使用它们中的任何一个。但是, 当使用 CopyFileA() 函数时, 需要首先转换字符串参数。阅读文章 "MQL5 编程基础: 字符串" 的 "调用 API 函数" 章节以了解详细信息。我建议使用更新的 CopyFileW() 函数。在此情况下, 字符串参数按原样指定, 不需要转换。 

为了使用 CopyFileW() 函数, 您应首先导入它。您可以在 kernel32.dll 库中找到它:

#import "kernel32.dll"
   int CopyFileW(string,string,int);
#import

代码可在附加的 sTestFileWinAPICopyFileW 脚本中找到。

脚本将包含其源代码的文件复制到 MQL5/Files:

void OnStart(){
   
   string src=TerminalInfoString(TERMINAL_DATA_PATH)+"\\MQL5\\Scripts\\"+MQLInfoString(MQL_PROGRAM_NAME)+".mq5";
   string dst=TerminalInfoString(TERMINAL_DATA_PATH)+"\\MQL5\\Files\\"+MQLInfoString(MQL_PROGRAM_NAME)+".mq5";
   
   if(CopyFileW(src,dst,0)==1){
      Alert("文件已复制");
   }
   else{
      Alert("复制文件失败");   
   }
}

如果成功, CopyFileW() 返回 1, 否则返回 0。第三个函数参数表示如果目标文件存在, 文件是否可以被覆盖: 0 — 启用, 1 — 禁用。启动脚本。如果操作成功, 检查 MQL5/Files 文件夹。 

请注意, 操作系统对文件复制施加了限制。有所谓的 "用户帐户控制参数"。如果它们被启用, 则某些位置无法成为复制的源或目标。例如, 不可能将文件复制到系统驱动器的根目录。

一些有用脚本

除了创建用于操纵文件的有用函数之外, 我们来创建几个有用的脚本进行更多练习。我们将开发用来将报价导出到 csv 文件和导出交易结果的脚本。

报价导出脚本将拥有定义数据开始和结束日期的参数, 以及定义是否使用日期, 或是否导出所有数据的参数。设置必要的属性以打开脚本的属性窗口:

#property script_show_inputs

随后声明外部参数:

input bool     UseDateFrom = false; // 设置开始日期
input datetime DateFrom=0; // 开始日期
input bool     UseDateTo=false; // 设置结束日期
input datetime DateTo=0; // 结束日期


在 OnStrat() 脚本函数中编写代码。根据脚本参数定义日期:

   datetime from,to;
   
   if(UseDateFrom){
      from=DateFrom;
   }
   else{
      int bars=Bars(Symbol(),Period());
      if(bars>0){
         datetime tm[];
         if(CopyTime(Symbol(),Period(),bars-1,1,tm)==-1){
            Alert("定义开始日期错误, 请稍后再次尝试");
            return;
         }
         else{
            from=tm[0];
         }
         
      }
      else{
         Alert("时间帧构造中, 请稍后再次尝试");
         return;
      }
   }
   
   if(UseDateTo){
      to=DateTo;
   }
   else{
      to=TimeCurrent();
   }   

如果使用日期定义变量, 则使用它们的值。否则, 将使用 TimeCurrent() 作为结束日期, 而开始日期则定义为第一根柱线的时间。 

现在我们有了日期, 将报价复制到 MqlRates 类型数组:

   MqlRates rates[];
   
   if(CopyRates(Symbol(),Period(),from,to,rates)==-1){
      Alert("复制报价失败, 请稍后再次尝试");
   }

将数组数据保存到文件:

   string FileName=Symbol()+" "+IntegerToString(PeriodSeconds()/60)+".csv";
   
   int h=FileOpen(FileName,FILE_WRITE|FILE_ANSI|FILE_CSV,";");
   
   if(h==INVALID_HANDLE){
      Alert("打开文件错误");
      return;
   }
   
   // 按照此格式将数据写入文件: 时间, 开盘价, 最高价, 最低价, 收盘价, 成交量, 即时触发次数
   
   // 第一行所知位置
   FileWrite(h,"时间","开盘价","最高价","最低价","收盘价","成交量","即时触发次数");  
   
   for(int i=0;i<ArraySize(rates);i++){
      FileWrite(h,rates[i].time,rates[i].open,rates[i].high,rates[i].low,rates[i].close,rates[i].real_volume,rates[i].tick_volume);
   }
   
   FileClose(h);

   Alert("保存完毕, 查看文件 "+FileName);   

如果成功, 脚本将打开相应的消息, 通知文件已成功保存。否则, 将显示错误消息。现成的 sQuotesExport 脚本附于下面。

现在, 我们来开发交易历史保存脚本。开始时大致相同: 外部变量优先, 尽管实现时间定义更简单, 因为开始时间为 0 就足以请求历史:

   datetime from,to;
   
   if(UseDateFrom){
      from=DateFrom;
   }
   else{
      from=0;
   }
   
   if(UseDateTo){
      to=DateTo;
   }
   else{
      to=TimeCurrent();
   }  

分派历史: 

   if(!HistorySelect(from,to)){
      Alert("错误分派历史");
      return;
   }

打开文件:

   string FileName="history.csv";
   
   int h=FileOpen(FileName,FILE_WRITE|FILE_ANSI|FILE_CSV,";");
   
   if(h==INVALID_HANDLE){
      Alert("打开文件错误");
      return;
   }

用字段名写第一行:

   FileWrite(h,"时间","成交","订单","品名","类型","方向","交易量","价格","佣金","隔夜利息","盈利","注释");     

遍历所有交易, 将买卖交易写入到文件:

   for(int i=0;i<HistoryDealsTotal();i++){
      ulong ticket=HistoryDealGetTicket(i);
      if(ticket!=0){         
         long type=HistoryDealGetInteger(ticket,DEAL_TYPE);         
         if(type==DEAL_TYPE_BUY || type==DEAL_TYPE_SELL){      
            long entry=HistoryDealGetInteger(ticket,DEAL_ENTRY);      
            FileWrite(h,(datetime)HistoryDealGetInteger(ticket,DEAL_TIME),
                        ticket,
                        HistoryDealGetInteger(ticket,DEAL_ORDER),
                        HistoryDealGetString(ticket,DEAL_SYMBOL),
                        (type==DEAL_TYPE_BUY?"买":"卖"),
                        (entry==DEAL_ENTRY_IN?"开仓":(entry==DEAL_ENTRY_OUT?"平仓":"开仓/平仓")),
                        DoubleToString(HistoryDealGetDouble(ticket,DEAL_VOLUME),2),
                        HistoryDealGetDouble(ticket,DEAL_PRICE),
                        DoubleToString(HistoryDealGetDouble(ticket,DEAL_COMMISSION),2),
                        DoubleToString(HistoryDealGetDouble(ticket,DEAL_SWAP),2),
                        DoubleToString(HistoryDealGetDouble(ticket,DEAL_PROFIT),2),
                        HistoryDealGetString(ticket,DEAL_COMMENT)                     
            );
         }
      }
      else{
         Alert("分配交易时出错,请重试");
         FileClose(h);
         return;
      }
   }

注意: 对于交易类型 (买/卖) 和方向(开仓/平仓), 将数值转换为字符串, 而一些双精度值则转换为小数点后有两位的字符串。 

结束后, 关闭文件并显示消息: 

   FileClose(h);
   Alert("保存完毕, 查看文件 "+FileName); 

 sHistoryExport 脚本附于下面。

更多话题

结论

在本文中, 我们已研究了 MQL5 中所有操纵文件的函数。 尽管看起来主题很窄, 但这篇文章已经变得相当庞大。 不过, 一些与主题相关的问题虽然已经研究, 然而却没有足够的实践例子。无论如何, 最常见的任务已详细讨论, 包括在测试器中操纵文件。此外, 我们开发了一些有用的函数, 所有示例都很实用且逻辑完整。所有代码作为脚本附在下面。

附件

  1. sTestFileRead — 从 ANSI 文本文件中读取单行并在消息框中显示它。
  2. sTestFileReadToAlert — 从 ANSI 文本文件中读取所有行并在消息框中显示它们。
  3. sTestFileCreate — 创建 ANSI 文本文件。
  4. sTestFileAddToFile — 在 ANSI 文本文件中添加一行。
  5. sTestFileChangeLine2-1 — 无效尝试更改 ANSI 文本文件中的单行。
  6. sTestFileChangeLine2-2 — 另一个无效尝试更改 ANSI 文本文件中的单行。
  7. sTestFileChangeLine2-3 — 通过重写整个文件来替换 ANSI 文本文件中的单行。
  8. sTestFileReadFileToArray — 将 ANSI 文本文件读取到数组的有用函数。
  9. sTestFileCreateCSV — 创建 ANSI 的 CSV 文件。
  10. sTestFileReadToAlertCSV — 按字段读取 ANSI 的 CSV 文件到消息框。
  11. sTestFileReadToAlertCSV2 — 通过具有行分隔的字段将 ANSI 的 CSV 文件读取到消息框。 
  12. sTestFileReadFileToArrayCSV — 将 ANSI 的 CSV 文件读取到结构数组。
  13. sTestFileWriteArrayToFileCSV — 将数组作为一行写入 ANSI 的 CSV 文件。
  14. sTestFileReadToAlertUTF — 读取 UNICODE 文本文件并将其显示在消息框中。
  15. sTestFileCreateUTF — 创建 UNICODE 文本文件。
  16. sTestFileCreateBin — 创建二进制文件并向其中写入三个双精度变量。
  17. sTestFileReadBin — 从二进制文件读取三个双精度变量。
  18. sTestFileChangeBin — 在二进制文件中重写第二个双精度变量。
  19. sTestFileReadBin2 — 从二进制文件读取第三个双精度变量。 
  20. sTestFileWriteStructBin — 将结构写入二进制文件。
  21. sTestFileReadStructBin — 从二进制文件读取结构。
  22. sTestFileReadStructBin2 — 从二进制文件中读取具有结构的单个变量。
  23. sTestFileCheckUnicode — 检查文件类型 (ANSI 或 UNCODE)。
  24. sTestFileWriteArray — 将数组写入二进制文件。
  25. sTestFileReadArray — 从二进制文件读取数组。
  26. sTestFileWriteArray2 — 将两个数组写入二进制文件。
  27. sTestFileReadArray2 — 从二进制文件读取两个数组。
  28. sTestFileWriteStringArray — 将字符串数组写入二进制文件。
  29. sTestFileReadStringArray — 从二进制文件读取字符串数组。
  30. sTestFileCopy — 将文件从 MQL5/Files 复制到共享文件夹。
  31. sTestFileCopy2 — 将文件复制到共享文件夹。
  32. sTestFileCopy3 — 将文件从共享文件夹复制到 MQL5/Files。 
  33. sTestFileTransmitter — 通过共享文件夹中的文件传输数据的脚本。
  34. sTestFileReceiver — 通过共享文件夹中的文件接收数据的脚本。
  35. sTestFileSize — 定义文件长度。
  36. sTestFileGetFiles — 根据掩码接收文件列表。
  37. sTestFileWinAPICopyFileW — 使用 WinAPI 的 CopyFileW() 函数示例。
  38. sQuotesExport — 导出报价的脚本。
  39. sHistoryExport — 保存交易历史的脚本。

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

附加的文件 |
files.zip (27.47 KB)
80-20 交易策略 80-20 交易策略
本文介绍用于分析 '80-20' 交易策略而开发的工具 (指标和智能交易系统)。交易策略规则取自 "街头智能。高概率短线交易策略" 作者: Linda Raschke 和 Laurence Connors。我们将使用 MQL5 语言正实现策略规则, 并在最近的行情历史上测试基于策略的指标和智能交易系统。
交易员生存诀窍: 若干测试的比较报告 交易员生存诀窍: 若干测试的比较报告
本文应对在四种不同的金融工具上同时启动智能交易系统测试。四个测试报告的最终比较在表格中提供, 类似于在线商店中陈列商品。附送礼包是为每个品种自动创建分布图表。
通用的之字转向指标 通用的之字转向指标
之字转向指标(ZigZag)是在 MetaTrader 5 用户中最流行的指标之一,本文分析了创建各种版本的之字转向指标的可能性,结果是一个可以使用各种方法扩展其功能的通用指标,它对EA交易和其他指标的开发会非常有用。
海龟汤和海龟汤升级版的改进 海龟汤和海龟汤升级版的改进
本文介绍了来自琳达.布拉福德.瑞斯克(Linda Bradford Raschke)和劳伦斯.A.康纳斯(Laurence A. Connors)的《华尔街智慧:高胜算短线交易策略(Street Smarts: High Probability Short-Term Trading Strategies)》一书的两个交易策略,‘海龟汤’和‘海龟汤升级版’的原则规范。在书中描述的策略非常流行,但是有必要知道的是,作者是基于15年到20年的市场行为来开发它们的。