读写变量(文本文件)
文本文件具有它们自己的一组函数用于原子(逐元素)保存和读取数据。与前一节中的二进制文件集略有不同。还需要注意的是,不存在类似的用于将结构体或结构体数组写入/读入一个文本文件的函数。如果试图将任何这些函数用于文本文件,这些函数将无效,且引发内部错误代码 5011 (FILE_NOTBIN)。
我们已经知道,MQL5 中的文本文件有两种形式:纯文本和 CSV 格式的文本。对应的模式 FILE_TXT 或 FILE_CSV 是在打开文件时设置,在未关闭并重新获取句柄的情况下无法更改。它们之间的差异只有在读取文件时才表现出来。两种模式均以相同方式记录。
在 TXT 模式下,每次调用读函数(我们将在本节学习的任何函数之一)都会查找文件中的下一个换行符(一个 '\n' 字符或一对 '\r\n'),并处理截至该换行符的所有内容。处理的意义在于将来自文件的文本转换为对应于调用函数的特定类型值。在最简单的情况下,如果调用了 FileReadString 函数,则不执行任何处理(字符串“按原样”返回)。
在 CSV 模式下,每次调用读函数,文件中的文本不仅按换行符,而且还按打开文件时指定的附加定界符进行逻辑分割。从文件当前位置到最近定界符的片段处理方式类似。
换言之,在文件内读取文本和转移内部位置是按片段从定界符到定界符完成,其中定界符不仅指 FileOpen 参数列表中的 delimiter 字符,而且指换行符 ('\n', '\r\n') 以及文件的开头和末尾。
附加定界符对于将文本写入到 FILE_TXT 和 FILE_CSV 文件具有相同效果,但仅当使用 FileWrite 函数时:其自动将该字符插入在记录的元素之间。FileWriteString 函数分隔符被忽略。
我们来看看对这些在函数的正式描述,然后探讨 FileTxtCsv.mq5 中的一个示例。
uint FileWrite(int handle, ...)
该函数属于可变参数函数类别。这些参数在函数原型中用省略号指示。仅支持内置数据类型。要写入结构体或类对象,必须解除它们的元素引用,然后单个传递元素。
该函数将在第一个之后传递的所有自变量写入到一个具有 handle 描述符的文本文件。与常规自变量列表一样,自变量以逗号分隔。输出到文件的自变量数量不能超过 63。
输出时,数值数据根据 (string) 的标准转换规则转换为文本格式。double 类型的值以传统格式或科学指数格式(选择更紧凑的选项)输出为 16 位有效数位。float 类型的数据以 7 个有效数位的精度显示。要以不同精度或明确指定的格式显示实数,使用 DoubleToString 函数(参见 数字转换为字符串以及相反转换章节)。
datetime 类型的值以 "YYYY.MM.DD hh:mm:ss" 格式输出(参见 日期和时间章节)。
标准颜色(来自 web 颜色列表)显示为名称,非标准颜色显示为 RGB 分量值三元组(参见 颜色),以逗号分隔(注意:逗号是 CSV 中最常用的分隔符字符)。
对于枚举,显示表示元素的整数,而不是显示其标识符(名称)。例如,当写入 FRIDAY(来自 ENUM_DAY_OF_WEEK,参见 枚举)时,我们在文件中得到数字 5。
bool 类型的值输出为字符串 "true" 或 "false"。
如果在打开文件时指定了一个非 0 定界符,将通过相应自变量的转换而被插入在两个相邻行之间该定界符。
所有自变量均被写入文件之后,会添加一个行终止符 '\r\n'。
该函数返回写入的字节数,如果出错,则返回 0。
uint FileWriteString(int handle, const string text, int length = -1)
该函数将 text 字符串参数写入到一个具有 handle 描述符的文本文件。length 参数仅适用于二进制文件,在此环境中忽略(行完全写入)。
FileWriteString 函数也能够处理二进制文件。该函数的这一应用在前一节中介绍过。
任何分隔符(在一行中的元素之间)和换行符必须由编程人员插入/添加。
该函数返回写入字节数(在 FILE_UNICODE 模式下,是以字符数表示的字符串长度的 2 倍),如果出错,则返回 0。
string FileReadString(int handle, int length = -1)
该函数从一个具有 handle 描述符的文件读取一个截至下一个定界符的字符串(CSV 文件中的定界符字符、任何文件中的换行字符,或者直至文件末尾)。length 参数仅适用于二进制文件,在此环境中忽略。
生成的字符串可以使用标准 缩减规则 或使用 转换函数转换为所要求类型的值。或者,可以使用专用读取函数:FileReadBool、FileReadDatetime 和 FileReadNumber 在下文中介绍。
如果出错,将返回空字符串。错误代码可通过变量 _LastError 或函数 GetLastError获取。特别要注意的是,到达文件末尾时错误代码为 5027 (FILE_ENDOFFILE)。
bool FileReadBool(int handle)
该函数读取一个 CSV 文件中直至下一个定界符的片段,或截至行末的片段,并将其转换为一个 bool 类型的值。如果该片段包含文本 "true"(任何大小写形式,包括混合大小写例如 "True")或包含一个非零数字,则我们得到 true。否则得到 false。
单词 "true" 必须占用整个读取的元素。即使字符串以 "true" 开头,但是后面还有连续部分(例如 "True Volume"),我们也会得到 false。
datetime FileReadDatetime(int handle)
该函数从 CSV 文件读取以下任一格式的字符串:"YYYY.MM.DD hh:mm:ss"、"YYYY.MM.DD" 或 "hh:mm:ss",并将其转换为 datetime 类型的值。如果该片段不包含日期和/或日期的有效文本表示,则函数将返回零或“奇怪”时间,取决于其可以解读为日期和时间片段的字符。对于空字符串或非数值字符串,我们获得当前日期,但时间为零。
更灵活的日期和时间读取(支持更多格式)可以通过组合使用两个函数实现:StringToTime(FileReadString(handle))。有关 StringToTime 的更多详细信息,参见 日期和时间。
double FileReadNumber(int handle)
该函数从读取一个 CSV 文件直至下一个定界符的片段,或截至行末的片段,并将其转换为一个 double 类型的值(根据标准 类型转化 规则进行转换)。
请注意,double 可能丢失极大值的精度,从而可能影响对于 long/ulong 类型大数字的读取(double 中的整数的失真临界值是 9007199254740992:此类现象的一个示例见 联合体章节)。
在前一节中讨论过的函数,包括 FileReadDouble、FileReadFloat、FileReadInteger、FileReadLong 和 FileReadStruct,不能被应用于文本文件。
FileTxtCsv.mq5 脚本演示了如何处理文本文件。上次我们将报价上传到了一个二进制文件。现在我们以 TXT 和 CSV 格式进行。
一般来说,MetaTrader 5 允许你通过“交易品种”对话框以 CSV 格式导出和导入报价。但出于学习目的,我们将复现这一过程。此外,软件实施允许偏离默认生成的确切格式。下面显示了以标准方式导出的 XAUUSD H1 历史记录片段。
<DATE> » <TIME> » <OPEN> » <HIGH> » <LOW> » <CLOSE> » <TICKVOL> » <VOL> » <SPREAD>
2021.01.04 » 01:00:00 » 1909.07 » 1914.93 » 1907.72 » 1913.10 » 4230 » 0 » 5
2021.01.04 » 02:00:00 » 1913.04 » 1913.64 » 1909.90 » 1913.41 » 2694 » 0 » 5
2021.01.04 » 03:00:00 » 1913.41 » 1918.71 » 1912.16 » 1916.61 » 6520 » 0 » 5
2021.01.04 » 04:00:00 » 1916.60 » 1921.89 » 1915.49 » 1921.79 » 3944 » 0 » 5
2021.01.04 » 05:00:00 » 1921.79 » 1925.26 » 1920.82 » 1923.19 » 3293 » 0 » 5
2021.01.04 » 06:00:00 » 1923.20 » 1923.71 » 1920.24 » 1922.67 » 2146 » 0 » 5
2021.01.04 » 07:00:00 » 1922.66 » 1922.99 » 1918.93 » 1921.66 » 3141 » 0 » 5
2021.01.04 » 08:00:00 » 1921.66 » 1925.60 » 1921.47 » 1922.99 » 3752 » 0 » 5
2021.01.04 » 09:00:00 » 1922.99 » 1925.54 » 1922.47 » 1924.80 » 2895 » 0 » 5
2021.01.04 » 10:00:00 » 1924.85 » 1935.16 » 1924.59 » 1932.07 » 6132 » 0 » 5
|
其中,我们尤其可能不满意默认分隔符字符(制表符,表示为 '"')、列顺序,或者日期和时间被分为两个字段。
在我们的脚本中,我们将选择逗号作为分隔符,并且我们将以 MqlRates 结构体字段的顺序生成列。上传以及随后的测试读取将在 FILE_TXT 和 FILE_CSV 模式下执行。
const string txtfile = "MQL5Book/atomic.txt";
const string csvfile = "MQL5Book/atomic.csv";
const short delimiter = ',';
|
报价将在 OnStart 函数开头以标准方式请求:
void OnStart()
{
MqlRates rates[];
int n = PRTF(CopyRates(_Symbol, _Period, 0, 10, rates)); // 10
|
我们将单独指定数组中各列的名称,并使用辅助函数 StringCombine 将它们组合起来。需要单独的标题,因为我们使用可选择的定界符字符将它们组合为一个共用标题(一种替代解决方案可基于 StringReplace)。我们鼓励你独立学习源代码 StringCombine:它相对于内置 StringSplit执行相反操作。
const string columns[] = {"DateTime", "Open", "High", "Low", "Close",
"Ticks", "Spread", "True"};
const string caption = StringCombine(columns, delimiter) + "\r\n";
|
最后一列本应名为 "Volume",但我们使用其示例检查函数 FileReadBool 的执行。你可以假定当前名称的含义是 "True Volume"(但这样一个字符串不会被解释为 true)。
接下来,我们以 FILE_TXT 和 FILE_CSV 模式打开两个文件,并将准备好的标头写入这两个文件中。
int fh1 = PRTF(FileOpen(txtfile, FILE_TXT | FILE_ANSI | FILE_WRITE, delimiter));//1
int fh2 = PRTF(FileOpen(csvfile, FILE_CSV | FILE_ANSI | FILE_WRITE, delimiter));//2
PRTF(FileWriteString(fh1, caption)); // 48
PRTF(FileWriteString(fh2, caption)); // 48
|
由于 FileWriteString 函数不会自动添加换行符,我们已将 "\r\n" 添加到了 caption 变量。
for(int i = 0; i < n; ++i)
{
FileWrite(fh1, rates[i].time,
rates[i].open, rates[i].high, rates[i].low, rates[i].close,
rates[i].tick_volume, rates[i].spread, rates[i].real_volume);
FileWrite(fh2, rates[i].time,
rates[i].open, rates[i].high, rates[i].low, rates[i].close,
rates[i].tick_volume, rates[i].spread, rates[i].real_volume);
}
FileClose(fh1);
FileClose(fh2);
|
从 rates 数组写入结构体字段以相同方式完成,通过在一个循环中为两个文件中分别调用 FileWrite。别忘了,FileWrite 函数会在自变量之间自动插入一个定界符字符,并在字符串末尾添加 "\r\n"。当然,可以独立将所有输出值转换为字符串并使用 FileWriteString 将它们发送到文件,但那样的话我们必须自行处理分隔符和换行符。某些情况下不需要分隔符和换行符,例如,如果你以 JSON 格式以紧凑形式写入时(基本上在一个大行内)。
因此,在记录阶段,两个文件是相同的方式管理,结果是相同的。这里是它们的针对 XAUUSD,H1 的内容示例(你的结果可能不同):
DateTime,Open,High,Low,Close,Ticks,Spread,True
2021.08.19 12:00:00,1785.3,1789.76,1784.75,1789.06,4831,5,0
2021.08.19 13:00:00,1789.06,1790.02,1787.61,1789.06,3393,5,0
2021.08.19 14:00:00,1789.08,1789.95,1786.78,1786.89,3536,5,0
2021.08.19 15:00:00,1786.78,1789.86,1783.73,1788.82,6840,5,0
2021.08.19 16:00:00,1788.82,1792.44,1782.04,1784.02,9514,5,0
2021.08.19 17:00:00,1784.04,1784.27,1777.14,1780.57,8526,5,0
2021.08.19 18:00:00,1780.55,1784.02,1780.05,1783.07,5271,6,0
2021.08.19 19:00:00,1783.06,1783.15,1780.73,1782.59,3571,7,0
2021.08.19 20:00:00,1782.61,1782.96,1780.16,1780.78,3236,10,0
2021.08.19 21:00:00,1780.79,1780.9,1778.54,1778.65,1017,13,0
|
处理这些文件的差异将在读取阶段开始显现。
我们打开一个文本文件进行读取,并使用 FileReadString 函数在一个循环中“扫描”它,直至它返回一个空字符串(即直至文件末尾)。
string read;
fh1 = PRTF(FileOpen(txtfile, FILE_TXT | FILE_ANSI | FILE_READ, delimiter)); // 1
Print("===== Reading TXT");
do
{
read = PRTF(FileReadString(fh1));
}
while(StringLen(read) > 0);
|
日志将显示类似这样的内容:
===== Reading TXT
FileReadString(fh1)=DateTime,Open,High,Low,Close,Ticks,Spread,True / ok
FileReadString(fh1)=2021.08.19 12:00:00,1785.3,1789.76,1784.75,1789.06,4831,5,0 / ok
FileReadString(fh1)=2021.08.19 13:00:00,1789.06,1790.02,1787.61,1789.06,3393,5,0 / ok
FileReadString(fh1)=2021.08.19 14:00:00,1789.08,1789.95,1786.78,1786.89,3536,5,0 / ok
FileReadString(fh1)=2021.08.19 15:00:00,1786.78,1789.86,1783.73,1788.82,6840,5,0 / ok
FileReadString(fh1)=2021.08.19 16:00:00,1788.82,1792.44,1782.04,1784.02,9514,5,0 / ok
FileReadString(fh1)=2021.08.19 17:00:00,1784.04,1784.27,1777.14,1780.57,8526,5,0 / ok
FileReadString(fh1)=2021.08.19 18:00:00,1780.55,1784.02,1780.05,1783.07,5271,6,0 / ok
FileReadString(fh1)=2021.08.19 19:00:00,1783.06,1783.15,1780.73,1782.59,3571,7,0 / ok
FileReadString(fh1)=2021.08.19 20:00:00,1782.61,1782.96,1780.16,1780.78,3236,10,0 / ok
FileReadString(fh1)=2021.08.19 21:00:00,1780.79,1780.9,1778.54,1778.65,1017,13,0 / ok
FileReadString(fh1)= / FILE_ENDOFFILE(5027)
|
每次调用 FileReadString 均以 FILE_TXT 模式读取整行(截至 '\r\n')。要将其分为两个元素,我们应实现额外处理。或者,我们可以使用 FILE_CSV 模式。
我们来对 CSV 文件执行相同操作。
fh2 = PRTF(FileOpen(csvfile, FILE_CSV | FILE_ANSI | FILE_READ, delimiter)); // 2
Print("===== Reading CSV");
do
{
read = PRTF(FileReadString(fh2));
}
while(StringLen(read) > 0);
|
这次日志中将有更多条目:
===== Reading CSV
FileReadString(fh2)=DateTime / ok
FileReadString(fh2)=Open / ok
FileReadString(fh2)=High / ok
FileReadString(fh2)=Low / ok
FileReadString(fh2)=Close / ok
FileReadString(fh2)=Ticks / ok
FileReadString(fh2)=Spread / ok
FileReadString(fh2)=True / ok
FileReadString(fh2)=2021.08.19 12:00:00 / ok
FileReadString(fh2)=1785.3 / ok
FileReadString(fh2)=1789.76 / ok
FileReadString(fh2)=1784.75 / ok
FileReadString(fh2)=1789.06 / ok
FileReadString(fh2)=4831 / ok
FileReadString(fh2)=5 / ok
FileReadString(fh2)=0 / ok
...
FileReadString(fh2)=2021.08.19 21:00:00 / ok
FileReadString(fh2)=1780.79 / ok
FileReadString(fh2)=1780.9 / ok
FileReadString(fh2)=1778.54 / ok
FileReadString(fh2)=1778.65 / ok
FileReadString(fh2)=1017 / ok
FileReadString(fh2)=13 / ok
FileReadString(fh2)=0 / ok
FileReadString(fh2)= / FILE_ENDOFFILE(5027)
|
重点在于,FileReadString 函数在 FILE_CSV 模式下将定界符字符纳入考虑,并将字符串拆分为元素。每次调用 FileReadString 都会从 CSV 表格返回一个值(单元格)。很明显,生成的字符串需要随后转换为适当类型。
这个问题可以使用专用函数 FileReadDatetime、FileReadNumber 和 FileReadBool 以通用方式解决。然而,任何情况下,开发者必须跟踪当前可读列的编号,并确定其实际含义。该算法的一个示例见测试的第三步。该算法使用同一个 CSV 文件(为简单起见,我们在每步结束时关闭该文件,并在下一步开始时打开它)。
为简化按列号向 MqlRates 结构体中下一字段进行赋值的操作,我们创建了包含模板方法 set 的子结构体 MqlRates:
struct MqlRatesM : public MqlRates
{
template<typename T>
void set(int field, T v)
{
switch(field)
{
case 0: this.time = (datetime)v; break;
case 1: this.open = (double)v; break;
case 2: this.high = (double)v; break;
case 3: this.low = (double)v; break;
case 4: this.close = (double)v; break;
case 5: this.tick_volume = (long)v; break;
case 6: this.spread = (int)v; break;
case 7: this.real_volume = (long)v; break;
}
}
};
|
在 OnStart 函数中,我们已经描述了一个该结构体的数组,我们将在这个数组中添加传入值。需要该数组以简化使用 ArrayPrint 进行的日志记录(在 MQL5 中没有用于自行打印结构体的现成函数)。
Print("===== Reading CSV (alternative)");
MqlRatesM r[1];
int count = 0;
int column = 0;
const int maxColumn = ArraySize(columns);
|
之所以需要用于统计记录数量的 count 变量,不仅是出于统计目的,也是一种用作跳过第一行(包含标头,不含数据)的手段。当前列号在 column 变量中跟踪。其最大值不应超过列数 maxColumn。
现在我们只需打开该文件并使用各种函数从文件中循环读取元素,直至发生错误,尤其诸如 5027 (FILE_ENDOFFILE) 的预期错误,该错误表示已到达文件末尾。
当列号为 0 时,我们应用 FileReadDatetime 函数。对于其它列,则使用 FileReadNumber。第一行带标头的情况例外:对于这种情况,我们调用 FileReadBool 函数以演示它将如何处理被故意添加到最后一列的 "True" 标头。
fh2 = PRTF(FileOpen(csvfile, FILE_CSV | FILE_ANSI | FILE_READ, delimiter)); // 1
do
{
if(column)
{
if(count == 1) // demo for FileReadBool on the 1st record with headers
{
r[0].set(column, PRTF(FileReadBool(fh2)));
}
else
{
r[0].set(column, FileReadNumber(fh2));
}
}
else // 0th column is the date and time
{
++count;
if(count >1) // the structure from the previous line is ready
{
ArrayPrint(r, _Digits, NULL, 0, 1, 0);
}
r[0].time = FileReadDatetime(fh2);
}
column = (column + 1) % maxColumn;
}
while(_LastError == 0); // exit when end of file 5027 is reached (FILE_ENDOFFILE)
// printing the last structure
if(column == maxColumn - 1)
{
ArrayPrint(r, _Digits, NULL, 0, 1, 0);
}
|
这是日志中记录的情况:
===== Reading CSV (alternative)
FileOpen(csvfile,FILE_CSV|FILE_ANSI|FILE_READ,delimiter)=1 / ok
FileReadBool(fh2)=false / ok
FileReadBool(fh2)=false / ok
FileReadBool(fh2)=false / ok
FileReadBool(fh2)=false / ok
FileReadBool(fh2)=false / ok
FileReadBool(fh2)=false / ok
FileReadBool(fh2)=true / ok
2021.08.19 00:00:00 0.00 0.00 0.00 0.00 0 0 1
2021.08.19 12:00:00 1785.30 1789.76 1784.75 1789.06 4831 5 0
2021.08.19 13:00:00 1789.06 1790.02 1787.61 1789.06 3393 5 0
2021.08.19 14:00:00 1789.08 1789.95 1786.78 1786.89 3536 5 0
2021.08.19 15:00:00 1786.78 1789.86 1783.73 1788.82 6840 5 0
2021.08.19 16:00:00 1788.82 1792.44 1782.04 1784.02 9514 5 0
2021.08.19 17:00:00 1784.04 1784.27 1777.14 1780.57 8526 5 0
2021.08.19 18:00:00 1780.55 1784.02 1780.05 1783.07 5271 6 0
2021.08.19 19:00:00 1783.06 1783.15 1780.73 1782.59 3571 7 0
2021.08.19 20:00:00 1782.61 1782.96 1780.16 1780.78 3236 10 0
2021.08.19 21:00:00 1780.79 1780.90 1778.54 1778.65 1017 13 0
|
可以看到,在所有标头中,仅最后一个被转换为 true 值,而所有前面的标头均为 false。
读取结构体的内容与原始数据相同。