大型 EA 交易示例
为了总结和巩固关于测试程序能力方面的知识,我们一步一步地考虑一个大型 EA 交易示例。在本示例中,我们将总结以下几个方面:
- 使用多种交易品种,包括柱线同步
- 使用 EA 交易的指标
- 使用事件
- 独立计算主要交易统计数据
- 计算针对可变手数调整的 R2 定制优化标准
- 发送和处理带有应用数据的帧(按交易品种细分的交易报告)
我们将使用 MultiMartingale.mq5 作为 EA 交易的技术基础,但将通过切换到交易多币种的超买/超卖信号和作为可选添加项增加手数来降低风险。之前,在 BandOsMA.mq5 中,我们已经看到了如何基于指标交易信号进行操作。这次我们将使用 UseUnityPercentPro.mq5 作为信号指标。但是,我们需要先修改一下。我们姑且称新版本为 UnityPercentEvent.mq5。
UnityPercentEvent.mq5
回想一下 Unity 指标的本质。该指标用于计算一组给定金融工具中包含的货币或报价机的相对强度(假设所有金融工具都有一个共同的货币,通过该货币可以进行转换)。在每根柱线上,所有货币的读数均已形成:一些更贵,一些更便宜,这两个极端元素处于一种边界状态。进一步讲,可以为其考虑两种本质上相反的策略:
- 进一步分解(确认并继续向两侧强烈移动)
- 回调(由于超买和超卖而向中心移动的反转)
要交易这些信号中的任何一个,我们必须做一个两种货币的工作交易品种(或交易代码),如果市场报价中有适合这种组合的交易品种。例如,如果指标的上线属于 EUR,下线属于 USD,则二者对应的是 EURUSD 对,根据突破策略,我们应买入;但是根据反弹策略,我们应卖出。
在更一般情况下,例如,当具有共同报价货币的差价合约或大宗商品被列入指标的一篮子工作金融工具时,并不总是能够创造一个真正的金融工具。对于这种情况,必须通过引入交易合成物(复合仓位)使 EA 交易变得更加复杂,但此时我们不会这样做,而是将我们自己限制在外汇市场,在外汇市场上,几乎所有的交叉汇率通常都是可用的。
因此,EA 交易不仅要读取所有的指标缓冲区,还要找出对应于最大值和最小值的货币名称。此处我们有一个小障碍。
MQL5 不允许读取第三方指示符缓冲区的名称,通常也不允许读取除整数以外的任何行特性。有三个函数可设置特性:PlotIndexSetInteger、PlotIndexSetDouble 和 PlotIndexSetString,但是读取特性的函数只有一个:PlotIndexGetInteger。
理论上,如果编译成一个交易综合体的 MQL 程序是由一个开发人员创建的,这个问题不大。具体而言,我们可以将指标的一部分源代码分离到一个头文件中,并将其不仅纳入指标中,还纳入 EA 交易中。然后,在 EA 交易中,可以重复分析指标的输入参数,并恢复货币列表,与用指标创建的货币列表完全相似。复制计算虽然不是很优雅,但管用。但是,若指标有不同的开发人员,并且他们不想公开算法或计划在将来改变算法(指标和 EA 交易的编译版本将变得不兼容),也需要更通用的解决方案。这种将别人的指标与自己的指标“对接”或者向别人定制 EA 交易的做法非常普遍。因此,指标开发人员应使指标尽可能可友好集成。
一种可能的解决方案是,指标在初始化后可发送带有缓冲区编号和名称的消息。
这是如何在 UnityPercentEvent.mq5 指标的 OnInit 处理程序中完成的呢(以下代码以简短形式显示,因为几乎没有什么变化)。
int OnInit()
{
// find the common currency for all pairs
const string common = InitSymbols();
...
// set up the displayed lines in the currency cycle
int replaceIndex = -1;
for(int i = 0; i <= SymbolCount; i++)
{
string name;
// change the order so that the base (common) currency goes under index 0,
// the rest depends on the order in which the pairs are entered by the user
if(i == 0)
{
name = common;
if(name != workCurrencies.getKey(i))
{
replaceIndex = i;
}
}
else
{
if(common == workCurrencies.getKey(i) && replaceIndex > -1)
{
name = workCurrencies.getKey(replaceIndex);
}
else
{
name = workCurrencies.getKey(i);
}
}
// set up rendering of buffers
PlotIndexSetString(i, PLOT_LABEL, name);
...
// send indexes and buffer names to programs where they are needed
EventChartCustom(0, (ushort)BarLimit, i, SymbolCount + 1, name);
}
...
}
|
与原始版本相比,此处只增加了一行。该行包含 EventChartCustom 调用。BarLimit 输入变量用作指标复本(可能是几个)的标识符。由于该指标将从 EA 交易中调用,并且不会显示给用户,所以只需指定一个小的正数,至少为 1,但是也可能是其它数字,例如 10。
现在,指标已经准备好,其信号可以用于第三方 EA 交易。我们开始开发 EA 交易 UnityMartingale.mq5。为了简化演示,我们将其分为 4 个阶段,逐步添加新的块。我们将有三个初步版本和一个最终版本。
UnityMartingaleDraft1.mq5
第 1 阶段,对于 UnityMartingaleDraft1.mq5 版本,我们先以 MultiMartingale.mq5 为基础进行修改。
我们将之前的 StartType 输入变量重命名为 SignalType,该变量决定了系列中第一笔交易的方向。该变量被用来在 BREAKOUT 和 PULLBACK 之间做出选择。
enum SIGNAL_TYPE
{
BREAKOUT,
PULLBACK
};
...
input SIGNAL_TYPE StartType = 0; // SignalType
|
要设置指标,我们需要一组单独的输入变量。
input group "U N I T Y S E T T I N G S"
input string UnitySymbols = "EURUSD,GBPUSD,USDCHF,USDJPY,AUDUSD,USDCAD,NZDUSD";
input int UnityBarLimit = 10;
input ENUM_APPLIED_PRICE UnityPriceType = PRICE_CLOSE;
input ENUM_MA_METHOD UnityPriceMethod = MODE_EMA;
input int UnityPricePeriod = 1;
|
请注意,UnitySymbols 参数包含用于构建指标的聚类金融工具列表,通常不同于我们想要交易的工作金融工具列表。交易金融工具仍在 WorkSymbols 参数中设置。
例如,默认情况下,我们可将一组主要 Forex 货币对传递给该指标,因此我们不仅可以将主要货币对指定为交易货币,还可以将任何交叉货币对指定为交易货币。一般来说,合理的做法是将这一组货币对限制在交易条件最好的金融工具(特别是小或中等点差)。此外,我们通常希望避免扭曲,即,在所有货币对中保持每种货币的等额,从而在统计上抵消为其中一种货币选择方向不成功的潜在风险。
接下来,我们将指标控件打包在 UnityController 类中。除了指标 handle,该类字段还存储以下数据:
- buffers 指标的数量,其在初始化后从指标的消息中接收
- 正在读取数据的 bar 编号(通常,0 表示当前未完成,1 表示上次完成)
- 包含从指定柱线上的指标缓冲区读取的值的 data 数组
- 上次读取时间 lastRead
- 按分时报价或柱线显示的操作标志 tickwise
此外,该类使用 MultiSymbolMonitor 对象来同步所有相关交易品种的柱线。
class UnityController
{
int handle;
int buffers;
const int bar;
double data[];
datetime lastRead;
const bool tickwise;
MultiSymbolMonitor sync;
...
|
在通过自变量接受指标所有参数的构造函数中,我们创建了指标并设置了 sync 对象。
public:
UnityController(const string symbolList, const int offset, const int limit,
const ENUM_APPLIED_PRICE type, const ENUM_MA_METHOD method, const int period):
bar(offset), tickwise(!offset)
{
handle = iCustom(_Symbol, _Period, "MQL5Book/p6/UnityPercentEvent",
symbolList, limit, type, method, period);
lastRead = 0;
string symbols[];
const int n = StringSplit(symbolList, ',', symbols);
for(int i = 0; i < n; ++i)
{
sync.attach(symbols[i]);
}
}
~UnityController()
{
IndicatorRelease(handle);
}
...
|
缓冲区的数量由 attached 方法设置。我们将在收到来自指标的消息时调用该方法。
void attached(const int b)
{
buffers = b;
ArrayResize(data, buffers);
}
|
当所有交易品种的最后一条柱线具有相同的时间时,一种特殊的方法 isReady 返回 true。只有在这种同步状态下,我们才能获得指标的正确值。应注意的是,此处假设所有金融工具交易时段的时间表相同。如果情况并非如此,则需要更改时序分析。
bool isReady()
{
return sync.check(true) == 0;
}
|
根据指标操作模式的不同,我们可以不同的方式定义当前时间:当在每条分时报价重新计算时(tickwise 等于 true),我们使用服务器时间,当每个柱线重新计算时,我们使用最后一个柱线的开盘时间。
datetime lastTime() const
{
return tickwise ? TimeTradeServer() : iTime(_Symbol, _Period, 0);
}
|
如果当前时间没有改变,相应地,存储在 data 缓冲器中的最后读数据仍然相关,则该方法的存在将允许我们排除读取指标。这就是如何在 read 方法中组织对指示符缓冲区的读取。对于具有 bar 索引的柱线,我们只需要每个缓冲区的一个值。
bool read()
{
if(!buffers) return false;
for(int i = 0; i < buffers; ++i)
{
double temp[1];
if(CopyBuffer(handle, i, bar, 1, temp) == 1)
{
data[i] = temp[0];
}
else
{
return false;
}
}
lastRead = lastTime();
return true;
}
|
最后,我们只需将读取时间保存到 lastRead 变量中。如果该变量为空或者不等于新的当前时间,用下面的方法访问控制器数据会导致使用 read 来读取指标缓冲区。
控制器的主要外部方法是用于获取最大值和最小值索引的 getOuterIndices 和读取这些值的操作符 '[]'。
bool isNewTime() const
{
return lastRead != lastTime();
}
bool getOuterIndices(int &min, int &max)
{
if(isNewTime())
{
if(!read()) return false;
}
max = ArrayMaximum(data);
min = ArrayMinimum(data);
return true;
}
double operator[](const int buffer)
{
if(isNewTime())
{
if(!read())
{
return EMPTY_VALUE;
}
}
return data[buffer];
}
};
|
之前,EA 交易 BandOsMA.mq5 引入了 TradingSignal 接口的概念。
interface TradingSignal
{
virtual int signal(void);
};
|
在此基础上,我们将使用 UnityPercentEvent 指标来说明信号的实现。控制器对象 UnityController 被传递给构造函数。其还显示了货币指数(缓冲区),即我们想要跟踪的信号。我们能够为选定的工作交易品种创建任意一组不同的交易品种。
class UnitySignal: public TradingSignal
{
UnityController *controller;
const int currency1;
const int currency2;
public:
UnitySignal(UnityController *parent, const int c1, const int c2):
controller(parent), currency1(c1), currency2(c2) { }
virtual int signal(void) override
{
if(!controller.isReady()) return 0; // waiting for bars synchronization
if(!controller.isNewTime()) return 0; // waitng for time to change
int min, max;
if(!controller.getOuterIndices(min, max)) return 0;
// overbought
if(currency1 == max && currency2 == min) return +1;
// oversold
if(currency2 == max && currency1 == min) return -1;
return 0;
}
};
|
signal 方法在不确定的情况下返回 0,在两种特定货币的超买和超卖状态下返回 +1 或 -1。
为了最终形成交易策略,我们使用了 TradingStrategy 接口。
interface TradingStrategy
{
virtual bool trade(void);
};
|
在这种情况下,UnityMartingale 类是在其基础上创建的,这在很大程度上与 MultiMartingale.mq5 中的 SimpleMartingale 一致。我们将只展示其不同之处。
class UnityMartingale: public TradingStrategy
{
protected:
...
AutoPtr<TradingSignal> command;
public:
UnityMartingale(const Settings &state, TradingSignal *signal)
{
...
command = signal;
}
virtual bool trade() override
{
...
int s = command[].signal(); // get controller signal
if(s != 0)
{
if(settings.startType == PULLBACK) s *= -1; // reverse logic for bounce
}
ulong ticket = 0;
if(position[] == NULL) // clean start - there were (and is) no positions
{
if(s == +1)
{
ticket = openBuy(settings.lots);
}
else if(s == -1)
{
ticket = openSell(settings.lots);
}
}
else
{
if(position[].refresh()) // position exists
{
if((position[].get(POSITION_TYPE) == POSITION_TYPE_BUY && s == -1)
|| (position[].get(POSITION_TYPE) == POSITION_TYPE_SELL && s == +1))
{
// signal in the other direction - we need to close
PrintFormat("Opposite signal: %d for position %d %lld",
s, position[].get(POSITION_TYPE), position[].get(POSITION_TICKET));
if(close(position[].get(POSITION_TICKET)))
{
// position = NULL; - save the position in the cache
}
else
{
position[].refresh(); // control possible closing errors
}
}
else
{
// the signal is the same or absent - "trailing"
position[].update();
if(trailing[]) trailing[].trail();
}
}
else // no position - open a new one
{
if(s == 0) // no signals
{
// here is the full logic of the old Expert Advisor:
// - reversal for martingale loss
// - continuation by the initial lot in a profitable direction
...
}
else // there is a signal
{
double lots;
if(position[].get(POSITION_PROFIT) >= 0.0)
{
lots = settings.lots; // initial lot after profit
}
else // increase the lot after the loss
{
lots = MathFloor((position[].get(POSITION_VOLUME) * settings.factor) / lotsStep) * lotsStep;
if(lotsLimit < lots)
{
lots = settings.lots;
}
}
ticket = (s == +1) ? openBuy(lots) : openSell(lots);
}
}
}
}
...
}
|
交易部分准备就绪。现在开始考虑其初始化。UnityController 对象的自动指针和带有货币名称的数组在全局水平进行说明。交易系统的池完全与之前开发完全一致。
AutoPtr<TradingStrategyPool> pool;
AutoPtr<UnityController> controller;
int currenciesCount;
string currencies[];
|
在 OnInit 处理程序中,我们可创建 UnityController 对象,并等待指标通过缓冲区索引发送货币分布。
int OnInit()
{
currenciesCount = 0;
ArrayResize(currencies, 0);
if(!StartUp(true)) return INIT_PARAMETERS_INCORRECT;
const bool barwise = UnityPriceType == PRICE_CLOSE && UnityPricePeriod == 1;
controller = new UnityController(UnitySymbols, barwise,
UnityBarLimit, UnityPriceType, UnityPriceMethod, UnityPricePeriod);
// waiting for messages from the indicator on currencies in buffers
return INIT_SUCCEEDED;
}
|
如果在指标输入参数中选择了价格类型 PRICE_CLOSE 和单个周期,则控制器中的计算将对每个柱线执行一次。在所有其他情况下,信号将按分时报价进行更新,但不会超过每秒一次(回想一下控制器中 lastTime 方法的实现)。
辅助方法 StartUp 通常与 EA 交易 MultiMartingale 中的旧 OnInit 处理程序执行相同的操作。其用设置填充 Settings 结构体,检查其正确性并创建交易系统 TradingStrategyPool 的池,该池由用于不同交易品种 WorkSymbols 的 UnityMartingale 类对象组成。但是,由于我们需要等待有关缓冲区中货币分布的信息,现在这个过程分为两个阶段。因此,StartUp 函数有一个输入参数,表示来自 OnInit 以及之后来自 OnChartEvent 的调用。
当分析 StartUp 源代码时,重要的是要记住,当我们只交易一个与当前图表匹配的金融工具时和当指定了一篮子金融工具时,初始化是不同的。当 WorkSymbols 为空行时,第一种模式处于活动状态。为特定金融工具优化 EA 交易非常方便。找到几种金融工具的设置后,我们可以在 WorkSymbols 中将它们组合起来。
bool StartUp(const bool init = false)
{
if(WorkSymbols == "")
{
Settings settings =
{
UseTime, HourStart, HourEnd,
Lots, Factor, Limit,
StopLoss, TakeProfit,
StartType, Magic, SkipTimeOnError, Trailing, _Symbol
};
if(settings.validate())
{
if(init)
{
Print("Input settings:");
settings.print();
}
}
else
{
if(init) Print("Wrong settings, please fix");
return false;
}
if(!init)
{
...// creating a trading system based on the indicator
}
}
else
{
Print("Parsed settings:");
Settings settings[];
if(!Settings::parseAll(WorkSymbols, settings))
{
if(init) Print("Settings are incorrect, can't start up");
return false;
}
if(!init)
{
...// creating a trading system based on the indicator
}
}
return true;
}
|
OnInit 中的 StartUp 函数可用 true 参数调用,这意味着只检查设置的正确性。交易系统对象的创建会被延迟,直到从 OnChartEvent 中的指标接收到消息为止。
void OnChartEvent(const int id,
const long &lparam, const double &dparam, const string &sparam)
{
if(id == CHARTEVENT_CUSTOM + UnityBarLimit)
{
PrintFormat("%lld %f '%s'", lparam, dparam, sparam);
if(lparam == 0) ArrayResize(currencies, 0);
currenciesCount = (int)MathRound(dparam);
PUSH(currencies, sparam);
if(ArraySize(currencies) == currenciesCount)
{
if(pool[] == NULL)
{
start up(); // indicator readiness confirmation
}
else
{
Alert("Repeated initialization!");
}
}
}
}
|
此处我们记住了全局变量 currenciesCount 中的货币数量,并将它们存储在 currencies 数组中,之后我们可用 false 参数(默认值,因此省略)调用 StartUp。消息会按照其在指标缓冲区中的存在顺序从队列中到达。因此,我们可得到指数和货币名称之间的匹配。
再次调用 StartUp 时,会执行一个附加代码:
bool StartUp(const bool init = false)
{
if(WorkSymbols == "") // one current symbol
{
...
if(!init) // final initialization after OnInit
{
controller[].attached(currenciesCount);
// split _Symbol into 2 currencies from the currencies array []
int first, second;
if(!SplitSymbolToCurrencyIndices(_Symbol, first, second))
{
PrintFormat("Can't find currencies (%s %s) for %s",
(first == -1 ? "base" : ""), (second == -1 ? "profit" : ""), _Symbol);
return false;
}
// create a pool from a single strategy
pool = new TradingStrategyPool(new UnityMartingale(settings,
new UnitySignal(controller[], first, second)));
}
}
else // symbol basket
{
...
if(!init) // final initialization after OnInit
{
controller[].attached(currenciesCount);
const int n = ArraySize(settings);
pool = new TradingStrategyPool(n);
for(int i = 0; i < n; i++)
{
...
// split settings[i].symbol into 2 currencies from currencies[]
int first, second;
if(!SplitSymbolToCurrencyIndices(settings[i].symbol, first, second))
{
PrintFormat("Can't find currencies (%s %s) for %s",
(first == -1 ? "base" : ""), (second == -1 ? "profit" : ""),
settings[i].symbol);
}
else
{
// add a strategy to the pool on the next trading symbol
pool[].push(new UnityMartingale(settings[i],
new UnitySignal(controller[], first, second)));
}
}
}
}
|
辅助函数 SplitSymbolToCurrencyIndices 用于选择传递交易品种的基础货币和盈利货币,并在 currencies 数组中找到其索引。因此,我们可获得在 UnitySignal 对象中生成信号的参考数据。每种货币都有自己的一对货币索引。
bool SplitSymbolToCurrencyIndices(const string symbol, int &first, int &second)
{
const string s1 = SymbolInfoString(symbol, SYMBOL_CURRENCY_BASE);
const string s2 = SymbolInfoString(symbol, SYMBOL_CURRENCY_PROFIT);
first = second = -1;
for(int i = 0; i < ArraySize(currencies); ++i)
{
if(currencies[i] == s1) first = i;
else if(currencies[i] == s2) second = i;
}
return first != -1 && second != -1;
}
|
总之,EA 交易准备就绪。
你可以看到,在 EA 交易的最后示例中,我们有策略类和交易信号类。我们特意使其成为通用接口 TradingStrategy 和 TradingSignal 的子节点,以便随后能够收集兼容但不同的实现,这些实现可以在期货 EA 交易的开发中进行组合。这种统一的具体类通常应被分离到单独的头文件中。在我们的示例中,为了简化逐步修改,我们没有这样做。
但是,所说明的方法为面向对象编程的标准。特别是,正如我们在 创建 EA 交易草案一节中所述,MetaTrader 5 附带了一个头文件 framework,其中包含 MQL 向导中使用的交易操作、交易品种指标和资金管理的标准类。其他类似的解决方案发布在 mql5.com 网站的文章和代码库版块中。
你可以使用现成的类结构体作为项目的基础,前提是它们在功能和易用性方面合适。
为了完成该图,我们想在 EA 交易中引入我们自己的基于 R2 的优化标准。为了避免 R2 计算公式中的线性回归与我们策略中包含的可变手数之间发生矛盾,我们计算系数不是用于通常的结余线,而是用于每次交易中按手数规范化的累积增量。
为此,在 OnTester 处理程序中,我们选择类型为 DEAL_TYPE_BUY 和 DEAL_TYPE_SELL 以及方向为 OUT 的交易。我们将会请求构成财务结果(利润/损失)的所有交易特性,即 DEAL_PROFIT、DEAL_SWAP、DEAL_COMMISSION、DEAL_FEE 及其 DEAL_VOLUME 交易量。
#defineSTAT_PROPS5// number of requested deal properties
doubleOnTester()
{
HistorySelect(0、LONG_MAX);
constENUM_DEAL_PROPERTY_DOUBLEprops[STAT_PROPS] =
{
DEAL_PROFIT、DEAL_SWAP、DEAL_COMMISSION、DEAL_FEE、DEAL_VOLUME
};
doubleexpenses[][STAT_PROPS];
ulongtickets[];// needed because of 'select' method prototype, but useful for debugging
DealFilterfilter;
filter中介绍过。let(DEAL_TYPE, (1<<DEAL_TYPE_BUY) | (1<<DEAL_TYPE_SELL),IS::OR_BITWISE)
中介绍过。let(DEAL_ENTRY, (1<<DEAL_ENTRY_OUT) | (1<<DEAL_ENTRY_INOUT) | (1<<DEAL_ENTRY_OUT_BY),
IS::OR_BITWISE)
中介绍过。select(props、tickets、expenses);
...
|
接下来,在 balance 数组中,我们可累计按交易量规范化的盈利/亏损,并计算其标准 R2。
const int n = ArraySize(tickets);
double balance[];
ArrayResize(balance, n + 1);
balance[0] = TesterStatistics(STAT_INITIAL_DEPOSIT);
for(int i = 0; i < n; ++i)
{
double result = 0;
for(int j = 0; j < STAT_PROPS - 1; ++j)
{
result += expenses[i][j];
}
result /= expenses[i][STAT_PROPS - 1]; // normalize by volume
balance[i + 1] = result + balance[i];
}
const double r2 = RSquaredTest(balance);
return r2 * 100;
}
|
EA 交易的第一版本基本准备就绪。我们没有纳入对使用 TickModel.mqh 的分时报价模型的检查。假设在 OHLC M1 模式或更好的模式下生成分时报价时,将测试 EA 交易。当检测到“仅开盘价”模式时,EA 交易将向终端发送一个带有错误状态的特殊帧,并将其从测试程序中卸载。不幸的是,这样只会停止本轮测试,但优化将会继续进行。因此,在终端中运行的 EA 交易的副本会向用户发出“警报”,以便用户手动中断优化。
void OnTesterPass()
{
ulong pass;
string name;
long id;
double value;
uchar data[];
while(FrameNext(pass, name, id, value, data))
{
if(name == "status" && id == 1)
{
Alert("Please stop optimization!");
Alert("Tick model is incorrect: OHLC M1 or better is required");
// it would be logical if the next call would stop all optimization,
// but it is not
ExpertRemove();
}
}
}
|
你可以优化任何交易品种的 SYMBOL SETTINGS 参数,并对不同的交易品种重复优化。同时,COMMON SETTINGS 和 UNITY SETTINGS 组应包含相同的设置,因为它们适用于交易系统的所有交易品种和实例。例如,所有优化都必须启用或禁用 Trailing。另请注意,单个交易品种的输入变量(即交易品种设置组)仅在 WorkSymbols 包含空字符串时有效。所以,在优化阶段,要保持该输入变量为空。
例如,为了分散风险,你可以在完全独立的货币对上持续优化 EA 交易:EURUSD、AUDJPY、GBPCHF、NZDCAD 或其他组合。三个带有私有设置示例的设置文件可连接到源代码。
#property tester_set "UnityMartingale-eurusd.set"
#property tester_set "UnityMartingale-gbpchf.set"
#property tester_set "UnityMartingale-audjpy.set"
|
要想一次交易三个交易品种,这些设置应该“打包”到一个公共参数 WorkSymbols:
EURUSD+0.01*1.6^5(200,200)[17,21];GBPCHF+0.01*1.2^8(600,800)[7,20];AUDJPY+0.01*1.2^8(600,800)[7,20]
|
该设置也包含在一个单独的文件中。
#property tester_set "UnityMartingale-combo.set"
|
当前版本的 EA 交易的一个问题是,测试报告将提供所有交易品种的一般统计数据(更准确地说,是所有交易策略的统计数据,因为我们可以在池中包含不同的类别),而对我们而言,单独监控和估算系统的每个组成部分很有意义。
要做到这一点,需要学习如何通过类比测试程序的执行结果独立计算交易的主要财务指标。我们将在 EA 交易开发的第 2 阶段处理这个问题。
UnityMartingaleDraft2.mq5
可能经常需要统计数据计算,所以我们将在一个单独的头文件 TradeReport.mqh 中实现该计算,因此我们将源代码组织到适当的类中。
我们调用主类 TradeReport。许多交易变量取决于结余和自由保证金(净值)曲线。因此,该类包含用于跟踪当前结余和利润的变量,并包含结余历史的不断更新的数组。我们不会存储净值的历史,因为它可能会在每一条分时报价上发生变化,因此,最好在移动中进行计算。稍后我们会看到为什么要有结余曲线。
class TradeReport
{
double balance; // current balance
double floating; // current floating profit
double data[]; // full balance curve - prices
datetime moments[]; // and date/time
...
|
更改和读取类字段是使用方法完成的,包括构造函数,其中结余由 ACCOUNT_BALANCE 特性初始化。
TradeReport()
{
balance = AccountInfoDouble(ACCOUNT_BALANCE);
}
void resetFloatingPL()
{
floating = 0;
}
void addFloatingPL(const double pl)
{
floating += pl;
}
void addBalance(const double pl)
{
balance += pl;
}
double getCurrent() const
{
return balance + floating;
}
...
|
这些方法需要迭代计算净值提取(动态)。对于结余提取的一次性计算,需要使用 data 结余数组(我们将在测试结束时进行计算)。
基于曲线的波动(结余或净值无关紧要),应使用相同的算法计算绝对和相对提款。因此,该算法和存储中间状态所需的其内部变量在嵌套结构体 DrawDown 中实现。以下代码显示了其主要方法和特性。
struct DrawDown
{
double
series_start,
series_min,
series_dd,
series_dd_percent,
series_dd_relative_percent,
series_dd_relative;
...
void reset();
void calcDrawdown(const double &data[]);
void calcDrawdown(const double amount);
void print() const;
};
|
当我们知道整个数组时,第一个 calcDrawdown 方法用于计算提款,可用于结余计算。第二个 calcDrawdown 方法用于迭代计算提款:每次调用时,都会告知其序列的下一个值,可用于净值计算。
我们知道,除了提款之外,还有报告的大量标准统计数据,但我们将首先支持其中的一小部分。为此,我们在另一个嵌套结构体 GenericStats 中描述相应的字段。这是从 DrawDown 继承而来的,因为我们仍然需要在报告中提款。
struct GenericStats: public DrawDown
{
long deals;
long trades;
long buy_trades;
long wins;
long buy_wins;
long sell_wins;
double profits;
double losses;
double net;
double pf;
double average_trade;
double recovery;
double max_profit;
double max_loss;
double sharpe;
...
|
根据变量的名称,很容易猜出其对应于什么标准度量指标。一些度量指标是多余的,因此可省略。例如,给定总交易次数 (trades) 和其中的买入交易次数 (buy_trades),我们可以很容易地求出卖出交易次数 (trades - sell_trades)。互补的盈利/亏损统计数据也是如此。连续盈利和亏损不进行统计。如果愿意,可以用这些指标来补充我们的报告。
为了与测试程序的一般统计数据保持一致,提供了一个 fillByTester 方法,可通过 TesterStatistics 函数填充所有字段。我们稍后会用到该方法。
void fillByTester()
{
deals = (long)TesterStatistics(STAT_DEALS);
trades = (long)TesterStatistics(STAT_TRADES);
buy_trades = (long)TesterStatistics(STAT_LONG_TRADES);
wins = (long)TesterStatistics(STAT_PROFIT_TRADES);
buy_wins = (long)TesterStatistics(STAT_PROFIT_LONGTRADES);
sell_wins = (long)TesterStatistics(STAT_PROFIT_SHORTTRADES);
profits = TesterStatistics(STAT_GROSS_PROFIT);
losses = TesterStatistics(STAT_GROSS_LOSS);
net = TesterStatistics(STAT_PROFIT);
pf = TesterStatistics(STAT_PROFIT_FACTOR);
average_trade = TesterStatistics(STAT_EXPECTED_PAYOFF);
recovery = TesterStatistics(STAT_RECOVERY_FACTOR);
sharpe = TesterStatistics(STAT_SHARPE_RATIO);
max_profit = TesterStatistics(STAT_MAX_PROFITTRADE);
max_loss = TesterStatistics(STAT_MAX_LOSSTRADE);
series_start = TesterStatistics(STAT_INITIAL_DEPOSIT);
series_min = TesterStatistics(STAT_EQUITYMIN);
series_dd = TesterStatistics(STAT_EQUITY_DD);
series_dd_percent = TesterStatistics(STAT_EQUITYDD_PERCENT);
series_dd_relative_percent = TesterStatistics(STAT_EQUITY_DDREL_PERCENT);
series_dd_relative = TesterStatistics(STAT_EQUITY_DD_RELATIVE);
}
};
|
当然,我们需要为测试程序无法计算的交易系统的那些单独结余和净值实现我们自己的计算。上面介绍了 calcDrawdown 方法的原型。在操作期间,它们可用“series_dd”前缀填充最后一组字段。此外,TradeReport 类还包含一个计算夏普比率的方法。作为输入,它需要一系列数字和无风险融资利率。完整的源代码可以在随附的文件中找到。
static double calcSharpe(const double &data[], const double riskFreeRate = 0);
|
你可能也猜到了,当调用这个方法时,带有结余的 TradeReport 类相关元素数组会在 data 参数中传递。填充该数组并为特定指标调用上述方法的过程发生在 calcStatistics 方法中(参见下文)。交易的对象筛选器可作为输入 (filter)、初始存款 (start) 和时间 (origin) 传递给该数组。我们假设调用代码会以这样一种方式设置筛选器,即只有我们感兴趣的交易系统的交易才会进行筛选。
该方法返回一个填充的结构体 GenericStats,此外,还用结余值和变化的时间引用分别填充 TradeReport 对象内的 data 和 moments 数组。我们将在 EA 交易的最终版本中需要该对象。
GenericStats calcStatistics(DealFilter &filter,
const double start = 0, const datetime origin = 0,
const double riskFreeRate = 0)
{
GenericStats stats;
ArrayResize(data, 0);
ArrayResize(moments, 0);
ulong tickets[];
if(!filter.select(tickets)) return stats;
balance = start;
PUSH(data, balance);
PUSH(moments, origin);
for(int i = 0; i < ArraySize(tickets); ++i)
{
DealMonitor m(tickets[i]);
if(m.get(DEAL_TYPE) == DEAL_TYPE_BALANCE) //deposit/withdrawal
{
balance += m.get(DEAL_PROFIT);
PUSH(data, balance);
PUSH(moments, (datetime)m.get(DEAL_TIME));
}
else if(m.get(DEAL_TYPE) == DEAL_TYPE_BUY
|| m.get(DEAL_TYPE) == DEAL_TYPE_SELL)
{
const double profit = m.get(DEAL_PROFIT) + m.get(DEAL_SWAP)
+ m.get(DEAL_COMMISSION) + m.get(DEAL_FEE);
balance += profit;
stats.deals++;
if(m.get(DEAL_ENTRY) == DEAL_ENTRY_OUT
|| m.get(DEAL_ENTRY) == DEAL_ENTRY_INOUT
|| m.get(DEAL_ENTRY) == DEAL_ENTRY_OUT_BY)
{
PUSH(data, balance);
PUSH(moments, (datetime)m.get(DEAL_TIME));
stats.trades++; // trades are counted by exit deals
if(m.get(DEAL_TYPE) == DEAL_TYPE_SELL)
{
stats.buy_trades++; // closing with a deal in the opposite direction
}
if(profit >= 0)
{
stats.wins++;
if(m.get(DEAL_TYPE) == DEAL_TYPE_BUY)
{
stats.sell_wins++; // closing with a deal in the opposite direction
}
else
{
stats.buy_wins++;
}
}
}
else if(!TU::Equal(profit, 0))
{
PUSH(data, balance); // entry fee (if any)
PUSH(moments, (datetime)m.get(DEAL_TIME));
}
if(profit >= 0)
{
stats.profits += profit;
stats.max_profit = fmax(profit, stats.max_profit);
}
else
{
stats.losses += profit;
stats.max_loss = fmin(profit, stats.max_loss);
}
}
}
if(stats.trades > 0)
{
stats.net = stats.profits + stats.losses;
stats.pf = -stats.losses > DBL_EPSILON ?
stats.profits / -stats.losses : MathExp(10000.0); // NaN(+inf)
stats.average_trade = stats.net / stats.trades;
stats.sharpe = calcSharpe(data, riskFreeRate);
stats.calcDrawdown(data); // fill in all fields of the DrawDown substructure
stats.recovery = stats.series_dd > DBL_EPSILON ?
stats.net / stats.series_dd : MathExp(10000.0);
}
return stats;
}
};
|
此处可以看到我们是如何调用 calcSharpe 和 calcDrawdown 来获取 data 数组上的相应指标。其余指标可在 calcStatistics 内部的循环中直接计算。
TradeReport 类准备就绪,我们可以将 EA 交易的功能扩展到 UnityMartingaleDraft2.mq5 版本。
我们向 UnityMartingale 类添加新成员。
class UnityMartingale: public TradingStrategy
{
protected:
...
TradeReport report;
TradeReport::DrawDown equity;
const double deposit;
const datetime epoch;
...
|
我们需要 report 对象来调用 calcStatistics,其中将包括结余提取。equity 对象是独立计算净值提取所必需的。初始结余和日期以及净值提取计算的开始,都在构造函数中进行设置。
public:
UnityMartingale(const Settings &state, TradingSignal *signal):
symbol(state.symbol), deposit(AccountInfoDouble(ACCOUNT_BALANCE)),
epoch(TimeCurrent())
{
...
equity.calcDrawdown(deposit);
...
}
|
随着每次调用 trade 方法,按净值提取的计算会持续进行。
virtual bool trade() override
{
...
if(MQLInfoInteger(MQL_TESTER))
{
if(position[])
{
report.resetFloatingPL();
// after reset, sum all floating profits
// why we call addFloatingPL for each existing position,
// but this strategy has a maximum of 1 position at a time
report.addFloatingPL(position[].get(POSITION_PROFIT)
+ position[].get(POSITION_SWAP));
// after taking into account all the amounts - update the drawdown
equity.calcDrawdown(report.getCurrent());
}
}
...
}
|
正确计算所需要的还不止于此。除了结余之外,我们还应该考虑到浮动盈亏。上面的代码章节只显示了 addFloatingPL 调用,但是 TradeReport 类也有一种修改结余的方法:addBalance中介绍过。但是,只有当仓位平仓时,结余才会改变。
由于有 OOP 的概念,在我们的情况下平仓一个仓位相当于删除 PositionState 类的 position 对象。那么为什么不能拦截呢?
PositionState 类没有为此提供任何方法,但是我们可以用特殊的构造函数和析构函数声明一个派生类 PositionStateWithEquity。
创建对象时,不仅将仓位标识符传递给构造函数,还要传递一个指向报告对象的指针(信息将发送给改报告对象)
class PositionStateWithEquity: public PositionState
{
TradeReport *report;
public:
PositionStateWithEquity(const long t, TradeReport *r):
PositionState(t), report(r) { }
...
|
在析构函数中,我们可通过平仓 ID 找到所有交易,计算总的财务结果(以及佣金和其他扣除额),然后调用 addBalance 来找出关联 report 对象。
~PositionStateWithEquity()
{
if(HistorySelectByPosition(get(POSITION_IDENTIFIER)))
{
double result = 0;
DealFilter filter;
int props[] = {DEAL_PROFIT, DEAL_SWAP, DEAL_COMMISSION, DEAL_FEE};
Tuple4<double, double, double, double> overheads[];
if(filter.select(props, overheads))
{
for(int i = 0; i < ArraySize(overheads); ++i)
{
result += NormalizeDouble(overheads[i]._1, 2)
+ NormalizeDouble(overheads[i]._2, 2)
+ NormalizeDouble(overheads[i]._3, 2)
+ NormalizeDouble(overheads[i]._4, 2);
}
}
if(CheckPointer(report) != POINTER_INVALID) report.addBalance(result);
}
}
};
|
还有一点需要澄清:如何为仓位而不是 PositionState 创建 PositionStateWithEquity 类对象。为此,只需对 TradingStrategy 类中调用 new 运算符的几处进行更改即可。
position=MQLInfoInteger(MQL_TESTER) ?
newPositionStateWithEquity(tickets[0], &report) :newPositionState(tickets[0]);
|
至此,我们已经实现了数据收集。现在我们需要直接生成报告,也就是调用 calcStatistics。此处,我们需要扩展我们的 TradingStrategy 接口:添加 statement 方法。
interface TradingStrategy
{
virtual bool trade(void);
virtual bool statement();
};
|
然后,在这个当前的实现中,为了我们的策略,我们应该工作满足其逻辑结论。
class UnityMartingale: public TradingStrategy
{
...
virtual bool statement() override
{
if(MQLInfoInteger(MQL_TESTER))
{
Print("Separate trade report for ", settings.symbol);
// equity drawdown should already be calculated on the fly
Print("Equity DD:");
equity.print();
// balance drawdown is calculated in the resulting report
Print("Trade Statistics (with Balance DD):");
// configure the filter for a specific strategy
DealFilter filter;
filter.let(DEAL_SYMBOL, settings.symbol)
.let(DEAL_MAGIC, settings.magic, IS::EQUAL_OR_ZERO);
// zero "magic" number is needed for the last exit deal
// - it is done by the tester itself
HistorySelect(0, LONG_MAX);
TradeReport::GenericStats stats =
report.calcStatistics(filter, deposit, epoch);
stats.print();
}
return false;
}
...
|
新方法可直接在日志中打印所有计算出的指标。通过在交易系统 TradingStrategyPool 的池中转发相同的方法,我们向 OnTester 处理程序请求所有交易品种的单独报告。
double OnTester()
{
...
if(pool[] != NULL)
{
pool[].statement(); // ask all trading systems to display their results
}
...
}
|
我们检查一下我们报告的正确性。为此,我们在测试程序中运行 EA 交易,一次一个交易品种,并将标准报告与我们的计算进行比较。例如,要建立 UnityMartingale-eurusd.set,在 EURUSD H1 交易中,我们将得到 2021 年的此类指标。

2021年测试程序报告,EURUSD H1
在日志中,我们的版本显示为两种结构体:带有净值提取的DrawDown 和带有结余提取指标和其他统计数据的GenericStats。
Separate trade report for EURUSD
Equity DD:
[maxpeak] [minpeak] [series_start] [series_min] [series_dd] [series_dd_percent] »
[0] 10022.48 10017.03 10000.00 9998.20 6.23 0.06 »
» [series_dd_relative_percent] [series_dd_relative]
» 0.06 6.23
Trade Statistics (with Balance DD):
[maxpeak] [minpeak] [series_start] [series_min] [series_dd] [series_dd_percent] »
[0] 10022.40 10017.63 10000.00 9998.51 5.73 0.06 »
» [series_dd_relative_percent] [series_dd_relative] »
» 0.06 5.73 »
» [deals] [trades] [buy_trades] [wins] [buy_wins] [sell_wins] [profits] [losses] [net] [pf] »
» 194 97 43 42 19 23 57.97 -39.62 18.35 1.46 »
» [average_trade] [recovery] [max_profit] [max_loss] [sharpe]
» 0.19 3.20 2.00 -2.01 0.15
|
很容易验证这些数字是否与测试程序的报告相符。
现在我们一次性开始三个交易品种的同期交易(设置 UnityMartingale-combo.set)。
除了 EURUSD 条目,GBPCHF 和 AUDJPY 结构体将会出现在日志中。
Separate trade report for GBPCHF
Equity DD:
[maxpeak] [minpeak] [series_start] [series_min] [series_dd] [series_dd_percent] »
[0] 10029.50 10000.19 10000.00 9963.65 62.90 0.63 »
» [series_dd_relative_percent] [series_dd_relative]
» 0.63 62.90
Trade Statistics (with Balance DD):
[maxpeak] [minpeak] [series_start] [series_min] [series_dd] [series_dd_percent] »
[0] 10023.68 9964.28 10000.00 9964.28 59.40 0.59 »
» [series_dd_relative_percent] [series_dd_relative] »
» 0.59 59.40 »
» [deals] [trades] [buy_trades] [wins] [buy_wins] [sell_wins] [profits] [losses] [net] [pf] »
» 600 300 154 141 63 78 394.53 -389.33 5.20 1.01 »
» [average_trade] [recovery] [max_profit] [max_loss] [sharpe]
» 0.02 0.09 9.10 -6.73 0.01
Separate trade report for AUDJPY
Equity DD:
[maxpeak] [minpeak] [series_start] [series_min] [series_dd] [series_dd_percent] »
[0] 10047.14 10041.53 10000.00 9961.62 48.20 0.48 »
» [series_dd_relative_percent] [series_dd_relative]
» 0.48 48.20
Trade Statistics (with Balance DD):
[maxpeak] [minpeak] [series_start] [series_min] [series_dd] [series_dd_percent] »
[0] 10045.21 10042.75 10000.00 9963.62 44.21 0.44 »
» [series_dd_relative_percent] [series_dd_relative] »
» 0.44 44.21 »
» [deals] [trades] [buy_trades] [wins] [buy_wins] [sell_wins] [profits] [losses] [net] [pf] »
» 332 166 91 89 54 35 214.79 -170.20 44.59 1.26 »
» [average_trade] [recovery] [max_profit] [max_loss] [sharpe]
» 0.27 1.01 7.58 -5.17 0.09
|
在这种情况下,测试程序报告将包含一般化数据,借助我们的类,我们已经收到了以前无法获得的细节。
但是,在日志中查看伪报告不是很方便。此外,我希望至少能看到结余线的图示,因为其表现通常比纯统计数据更能说明系统的适用性。
我们来改进 EA 交易,使其能够生成 HTML 格式可视化报告:毕竟,随着时间的推移,测试程序的报告也可以导出到 HTML,保存,并进行比较。此外,这种报告今后可以在优化过程中以帧的形式传输到终端,用户甚至可以在整个过程完成之前就开始研究特定传递的报告。
这将是示例 UnityMartingaleDraft3.mq5 的倒数第二个版本。
UnityMartingaleDraft3.mq5
该交易报告的可视化包括结余线和带有统计指标的表格。我们不会生成类似于测试程序报告的完整报告,而是将我们自己限制在选定的最重要值上。我们的目的是实现一种工作机制,然后可以根据个人需求进行定制。
我们将以 TradeReportWriter 类 (TradeReportWriter.mqh) 的形式安排算法的依据。该类将能够存储来自不同交易系统的任意报告数量:每个报告都存储在一个单独的对象 DataHolder 中,其中包括结余值和时间戳(分别是 data 和 when)、包含统计信息的 stats 结构体以及要显示的行标题、颜色和宽度。
class TradeReportWriter
{
protected:
class DataHolder
{
public:
double data[]; // balance changes
datetime when[]; // balance timestamps
string name; // description
color clr; // color
int width; // line width
TradeReport::GenericStats stats; // trading indicators
};
...
|
我们为 DataHolder 类对象分配了一个自动指针数组 curves。此外,我们需要对金额和期限施加通用限制,以匹配图中所有交易系统的行。这将由 lower、upper、start 和 stop 变量提供。
AutoPtr<DataHolder> curves[];
double lower, upper;
datetime start, stop;
public:
TradeReportWriter(): lower(DBL_MAX), upper(-DBL_MAX), start(0), stop(0) { }
...
|
addCurve 方法可添加结余线。
virtual bool addCurve(double &data[], datetime &when[], const string name,
const color clr = clrNONE, const int width = 1)
{
if(ArraySize(data) == 0 || ArraySize(when) == 0) return false;
if(ArraySize(data) != ArraySize(when)) return false;
DataHolder *c = new DataHolder();
if(!ArraySwap(data, c.data) || !ArraySwap(when, c.when))
{
delete c;
return false;
}
const double max = c.data[ArrayMaximum(c.data)];
const double min = c.data[ArrayMinimum(c.data)];
lower = fmin(min, lower);
upper = fmax(max, upper);
if(start == 0) start = c.when[0];
else if(c.when[0] != 0) start = fmin(c.when[0], start);
stop = fmax(c.when[ArraySize(c.when) - 1], stop);
c.name = name;
c.clr = clr;
c.width = width;
ZeroMemory(c.stats); // no statistics by default
PUSH(curves, c);
return true;
}
|
addCurve 方法的第二个版本不仅可添加结余线,还在 GenericStats 结构体中添加了一组金融变量。
virtual bool addCurve(TradeReport::GenericStats &stats,
double &data[], datetime &when[], const string name,
const color clr = clrNONE, const int width = 1)
{
if(addCurve(data, when, name, clr, width))
{
curves[ArraySize(curves) - 1][].stats = stats;
return true;
}
return false;
}
|
使报告可视化的最重要类方法是抽象的。
virtual void render() = 0;
|
这样便有可能实现多种显示报告的方式,例如,既可以记录到不同格式的文件,也可以直接在图表上绘图。我们现在来关注在 HTML 文件的格式上,因为这是技术上最先进和最普遍的方法。
新类 HTMLReportWriter 有一个构造函数,其参数可指定文件名称以及带有结余曲线的图大小。我们可以众所周知的 SVG 矢量图形格式生成图像本身:在这种情况下这是理想的,因为其为 XML 语言的子集,而 XML 语言就是 HTML 本身。
class HTMLReportWriter: public TradeReportWriter
{
int handle;
int width, height;
public:
HTMLReportWriter(const string name, const int w = 600, const int h = 400):
width(w), height(h)
{
handle = FileOpen(name,
FILE_WRITE | FILE_TXT | FILE_ANSI | FILE_REWRITE);
}
~HTMLReportWriter()
{
if(handle != 0) FileClose(handle);
}
void close()
{
if(handle != 0) FileClose(handle);
handle = 0;
}
...
|
在探讨主要的公共 render 方法之前,有必要向读者介绍一种技术,将在本书的最后第 7 部分详细说明。我们谈论的是 资源:是指连接到 MQL 程序、用于处理多媒体(声音和图像)的文件和任意数据数组、嵌入编译过的指示符,或者仅仅作为应用程序信息的存储库。我们现在使用后一种选项。
重点是,最好不要完全用 MQL 代码生成 HTML 页面,而是基于一个模板(页面模板),MQL 代码只会在其中插入一些变量的值。这是编程中一项众所周知的技术,允许将算法和程序的外部表示(或其工作结果)分开。因此,我们可以分别试验 HTML 模板和 MQL 代码,在熟悉的环境中使用每个组件。具体而言,MetaEditor 仍然不太适合编辑和查看网页,就像标准浏览器对 MQL5 一无所知一样(虽然这是可以修复的)。
我们将 HTML 报告模板存储在连接到 MQL5 源代码的文本文件中,作为资源。使用特殊指令 #resource 建立连接。例如,在 TradeReportWriter.mqh 文件中有下面的一行。
#resource "TradeReportPage.htm" as string ReportPageTemplate
|
这意味着在源代码旁边应有 TradeReportPage.htm 文件,其将作为字符串 ReportPageTemplate 在 MQL 代码中提供。根据扩展名,你可以理解为文件是一个网页。下面是这个文件的内容及其缩写(我们的任务不是教读者网页开发,尽管,显然这方面的知识对交易者也很有用)。添加缩进是为了直观地表示 HTML 标记的嵌套结构体;文件中没有缩进。
<!DOCTYPE html>
<html>
<head>
<title>Trade Report</title>
<style>
*{font: 9pt "Segoe UI";}
.center{width:fit-content;margin:0 auto;}
...
</style>
</head>
<body>
<div class="center">
<h1>Trade Report</h1>
~
</div>
</body>
<script>
...
</script>
</html>
|
模板的基础由开发人员选择。有大量现成的 HTML 模板系统,但是它们提供了很多冗余的特性,因此对于我们的示例而言太复杂了。我们将开发我们自己的概念。
首先,我们注意到大多数网页都有一个开头部分(页眉),一个结尾部分(页脚),有用的信息位于中间。在这个意义上,上述报告草案也不例外。其使用字符 '~' 来表示有用的内容。相反,MQL 代码将不得不插入一个结余图像和一个指标表格。但是 '~' 的存在并不是必须的,因为页面可以是一个整体,也就是非常有用的中间部分:毕竟,如果需要,MQL 代码可以将处理一个模板的结果插入到另一个模板中。
为了结束关于 HTML 模板的题外话,我们要注意另外一件事。理论上,一个网页由执行不同函数的标签组成。标准的 HTML 标签会告诉浏览器应显示什么。除此之外,还有级联样式 (CSS),用于说明显示方式。最后,网页可以有一个 JavaScript 脚本形式的动态组件,用于交互地控制第一个和第二个网页。
通常,这三个组件独立地进行模板化处理,例如,一个 HTML 模板,严格地说,应只包含 HTML 而不包含 CSS 或 JavaScript。这允许 “解除绑定”网页的内容、外观和行为, 从而方便开发(建议在 MQL5 中遵循相同的方法!).
但是,在我们的示例中,我们已经在模板中包含了所有组件。特别是,在上面模板中,我们看到了带有 CSS 样式的标签 <style> 和带有一些 JavaScrip t函数的标签 <script>,这些标签都省略了。这样做是为了简化示例,重点是 MQL5 特性而不是网页开发。
将 ReportPageTemplate 变量中的网页模板作为资源连接起来,我们就可以编写 render 方法了。
virtual void render() override
{
string headerAndFooter[2];
StringSplit(ReportPageTemplate, '~', headerAndFooter);
FileWriteString(handle, headerAndFooter[0]);
renderContent();
FileWriteString(handle, headerAndFooter[1]);
}
...
|
该方法实际上通过 '~' 字符将页面分成上下两半,按原样显示它们,并在它们之间调用辅助方法 renderContent。
我们已经介绍过,报告由一个带有结余曲线的总体图和带有交易系统指标的表格组成,因此实现 renderContent 是自然的。
private:
void renderContent()
{
renderSVG();
renderTables();
}
|
renderSVG 中的图像生成基于另一个模板文件 TradeReportSVG.htm,该文件绑定到一个字符串变量 SVGBoxTemplate:
#resource "TradeReportSVG.htm" as string SVGBoxTemplate
|
这个模板的内容是我们此处列出的最后一个。如果愿意,可以自行查看模板其余部分的源代码。
<span id="params" style="display:block;width:%WIDTH%px;text-align:center;"></span>
<a id="main" style="display:block;text-align:center;">
<svg width="%WIDTH%" height="%HEIGHT%" xmlns="http://www.w3.org/2000/svg">
<style>.legend {font: bold 11px Consolas;}</style>
<rect x="0" y="0" width="%WIDTH%" height="%HEIGHT%"
style="fill:none; stroke-width:1; stroke: black;"/>
~
</svg>
</a>
|
在 renderSVG 方法的代码中,我们将看到一个常用技巧是:将内容分成交易品种 '~' 之前和之后,但此处有点儿新东西。
void renderSVG()
{
string headerAndFooter[2];
if(StringSplit(SVGBoxTemplate, '~', headerAndFooter) != 2) return;
StringReplace(headerAndFooter[0], "%WIDTH%", (string)width);
StringReplace(headerAndFooter[0], "%HEIGHT%", (string)height);
FileWriteString(handle, headerAndFooter[0]);
for(int i = 0; i < ArraySize(curves); ++i)
{
renderCurve(i, curves[i][].data, curves[i][].when,
curves[i][].name, curves[i][].clr, curves[i][].width);
}
FileWriteString(handle, headerAndFooter[1]);
}
|
在页面顶部的字符串 headerAndFooter[0] 中,我们正在寻找特殊形式“%WIDTH%”和“%HEIGHT%”的子字符串,并用图像所需的宽度和高度替换它们。正是根据这一原则,可以在我们的模板中进行值替换。例如,在此模板中,这些子字符串实际出现在 rect 标记中:
<rect x="0" y="0" width="%WIDTH%" height="%HEIGHT%" style="fill:none; stroke-width:1; stroke: black;"/>
|
因此,如果定制的报告大小为 600 x 400,该行将转换为以下内容:
<rect x="0" y="0" width="600" height="400" style="fill:none; stroke-width:1; stroke: black;"/>
|
这将在浏览器中显示指定尺寸为 1 像素宽的黑色边框。
绘制特定结余线的标记的生成通过 renderCurve 方法处理,我们可将所有必要的数组和其他设置(名称、颜色和厚度)传递给该方法。我们将此方法和其他高度专业化方法(renderTables 和 renderTable)留给独立研究。
我们回到 UnityMartingaleDraft3.mq5 EA 交易的主模块。设置结余图的大小,并连接 TradeReportWriter.mqh。
#define MINIWIDTH 400
#define MINIHEIGHT 200
#include <MQL5Book/TradeReportWriter.mqh>
|
为了将策略与报告生成器“连接”起来,需要修改TradingStrategy 接口中的 statement 方法:传递一个指向 TradeReportWriter 对象的指针,调用代码可以创建并配置该对象。
interface TradingStrategy
{
virtual bool trade(void);
virtual bool statement(TradeReportWriter *writer = NULL);
};
|
现在我们在我们的 UnityMartingale 策略类中,为该方法的具体实现添加一些行。
class UnityMartingale: public TradingStrategy
{
...
TradeReport report;
...
virtual bool statement(TradeReportWriter *writer = NULL) override
{
if(MQLInfoInteger(MQL_TESTER))
{
...
// it's already been done
DealFilter filter;
filter.let(DEAL_SYMBOL, settings.symbol)
.let(DEAL_MAGIC, settings.magic, IS::EQUAL_OR_ZERO);
HistorySelect(0, LONG_MAX);
TradeReport::GenericStats stats =
report.calcStatistics(filter, deposit, epoch);
...
// adding this
if(CheckPointer(writer) != POINTER_INVALID)
{
double data[]; // balance values
datetime time[]; // balance points time to synchronize curves
report.getCurve(data, time); // fill in the arrays and transfer to write to the file
return writer.addCurve(stats, data, time, settings.symbol);
}
return true;
}
return false;
}
|
这一切都归结于从 report 对象(TradeReport 类)获得一个结余数组和一个带有指标的结构体,并传递给 TradeReportWriter 对象,以调用 addCurve。
当然,交易策略池可确保将同一个对象TradeReportWriter 转移到所有策略,以生成合并报告。
class TradingStrategyPool: public TradingStrategy
{
...
virtual bool statement(TradeReportWriter *writer = NULL) override
{
bool result = false;
for(int i = 0; i < ArraySize(pool); i++)
{
result = pool[i][].statement(writer) || result;
}
return result;
}
|
最后,OnTester 处理程序经历了最大的修改。下面几行足以生成交易策略的 HTML 报告。
double OnTester()
{
...
const static string tempfile = "temp.html";
HTMLReportWriter writer(tempfile, MINIWIDTH, MINIHEIGHT);
if(pool[] != NULL)
{
pool[].statement(&writer); // ask strategies to report their results
}
writer.render(); // write the received data to a file
writer.close();
}
|
但是,为了清晰起见和方便用户查看,最好在报告中添加一条总体结余曲线以及一个包含总体指标的表格。仅当在 EA 交易设置中指定了几个交易品种时,其输出才有意义,如若不然,一个策略的报告会与文件中的一般策略一致。
这需要更多一点的代码。
double OnTester()
{
...
// had it before
DealFilter filter;
// set up the filter and fill in the array of deals based on it tickets
...
const int n = ArraySize(tickets);
// add this
const bool singleSymbol = WorkSymbols == "";
double curve[]; // total balance curve
datetime stamps[]; // date and time of total balance points
if(!singleSymbol) // the total balance is displayed only if there are several symbols/strategies
{
ArrayResize(curve, n + 1);
ArrayResize(stamps, n + 1);
curve[0] = TesterStatistics(STAT_INITIAL_DEPOSIT);
// MQL5 does not allow to know the test start time,
// this could be found out from the first transaction,
// but it is outside the filter conditions of a specific system,
// so let's just agree to skip time 0 in calculations
stamps[0] = 0;
}
for(int i = 0; i < n; ++i) // deal cycle
{
double result = 0;
for(int j = 0; j < STAT_PROPS - 1; ++j)
{
result += expenses[i][j];
}
if(!singleSymbol)
{
curve[i + 1] = result + curve[i];
stamps[i + 1] = (datetime)HistoryDealGetInteger(tickets[i], DEAL_TIME);
}
...
}
if(!singleSymbol) // send the tester's statistics and the overall curve to the report
{
TradeReport::GenericStats stats;
stats.fillByTester();
writer.addCurve(stats, curve, stamps, "Overall", clrBlack, 3);
}
...
}
|
我们看看我们得到了什么。如果我们使用设置 UnityMartingale-combo.set 运行 EA 交易,将在其中一个代理的 MQL5/Files 文件夹中获得 temp.html 文件。以下是其在浏览器中的效果。

具有多种交易策略/交易品种的 EA 交易 HTML 报告
现在,我们知道了如何在一次试传递中生成报告,我们可以在优化期间将其发送到终端,在运行中选择最佳报告,并在整个过程结束之前呈现给用户。所有报告都放在终端的 MQL5/Files 内的一个单独文件夹中。该文件夹将收到一个名称,其中包含测试程序设置中的交易品种和时间范围以及 EA 交易名称。
UnityMartingale.mq5
我们知道,要向终端发送一个文件,只需要调用 FrameAdd 函数。我们已经在先前版本的框架内生成了该文件。
double OnTester()
{
...
if(MQLInfoInteger(MQL_OPTIMIZATION))
{
FrameAdd(tempfile, 0, r2 * 100, tempfile);
}
}
|
在接收 EA 交易实例中,我们将执行必要的准备工作。我们用每次优化传递的主要参数来说明 Pass 结构体。
struct Pass
{
ulong id; // pass number
double value; // optimization criterion value
string parameters; // optimized parameters as list 'name=value'
string preset; // text to generate set-file (with all parameters)
};
|
在 parameters 字符串中,“名称=值”对用 '&' 符号连接。这对以后报告的网页交互很有用('&' 符号是网页地址中组合参数的标准符号)。我们没有介绍设置文件的格式,但是下面形成 preset 字符串的源代码允许你在实践中研究这个问题。
当帧到达时,我们将根据优化标准向 TopPasses 数组写入改进。当前最佳传递将始终是数组中的最后一轮测试,也可以在 BestPass 变量中提供。
Pass TopPasses[]; // stack of constantly improving passes (last one is best)
Pass BestPass; // current best pass
string ReportPath; // dedicated folder for all html files of this optimization
|
在 OnTesterInit 处理程序中,我们创建一个文件夹名。
void OnTesterInit()
{
BestPass.value = -DBL_MAX;
ReportPath = _Symbol + "-" + PeriodToString(_Period) + "-"
+ MQLInfoString(MQL_PROGRAM_NAME) + "/";
}
|
在 OnTesterPass 处理程序中,我们将按顺序只选择那些指标有所改善的帧,为其找到优化的以及其他参数的值,并将所有这些信息添加到结构体的 Pass 数组中。
void OnTesterPass()
{
ulong pass;
string name;
long id;
double value;
uchar data[];
// input parameters for the pass corresponding to the current frame
string params[];
uint count;
while(FrameNext(pass, name, id, value, data))
{
// collect passes with improved stats
if(value > BestPass.value && FrameInputs(pass, params, count))
{
BestPass.preset = "";
BestPass.parameters = "";
// get optimized and other parameters for generating a set-file
for(uint i = 0; i < count; i++)
{
string name2value[];
int n = StringSplit(params[i], '=', name2value);
if(n == 2)
{
long pvalue, pstart, pstep, pstop;
bool enabled = false;
if(ParameterGetRange(name2value[0], enabled, pvalue, pstart, pstep, pstop))
{
if(enabled)
{
if(StringLen(BestPass.parameters)) BestPass.parameters += "&";
BestPass.parameters += params[i];
}
BestPass.preset += params[i] + "||" + (string)pstart + "||"
+ (string)pstep + "||" + (string)pstop + "||"
+ (enabled ? "Y" : "N") + "<br>\n";
}
else
{
BestPass.preset += params[i] + "<br>\n";
}
}
}
BestPass.value = value;
BestPass.id = pass;
PUSH(TopPasses, BestPass);
// write the frame with the report to the HTML file
const string text = CharArrayToString(data);
int handle = FileOpen(StringFormat(ReportPath + "%06.3f-%lld.htm", value, pass),
FILE_WRITE | FILE_TXT | FILE_ANSI);
FileWriteString(handle, text);
FileClose(handle);
}
}
}
|
改进后的结果报告保存在文件中,文件名包括优化标准值和传递编号。
以下是最有趣的事情。在 OnTesterDeinit 处理程序中,我们可以形成一个通用的 HTML 文件 (overall.htm),该文件允许你一次看到所有的报告(比如,前 100 个)。它对模板使用了我们前面提到的相同方案。
#resource "OptReportPage.htm" as string OptReportPageTemplate
#resource "OptReportElement.htm" as string OptReportElementTemplate
void OnTesterDeinit()
{
int handle = FileOpen(ReportPath + "overall.htm",
FILE_WRITE | FILE_TXT | FILE_ANSI, 0, CP_UTF8);
string headerAndFooter[2];
StringSplit(OptReportPageTemplate, '~', headerAndFooter);
StringReplace(headerAndFooter[0], "%MINIWIDTH%", (string)MINIWIDTH);
StringReplace(headerAndFooter[0], "%MINIHEIGHT%", (string)MINIHEIGHT);
FileWriteString(handle, headerAndFooter[0]);
// read no more than 100 best records from TopPasses
for(int i = ArraySize(TopPasses) - 1, k = 0; i >= 0 && k < 100; --i, ++k)
{
string p = TopPasses[i].parameters;
StringReplace(p, "&", " ");
const string filename = StringFormat("%06.3f-%lld.htm",
TopPasses[i].value, TopPasses[i].id);
string element = OptReportElementTemplate;
StringReplace(element, "%FILENAME%", filename);
StringReplace(element, "%PARAMETERS%", TopPasses[i].parameters);
StringReplace(element, "%PARAMETERS_SPACED%", p);
StringReplace(element, "%PASS%", IntegerToString(TopPasses[i].id));
StringReplace(element, "%PRESET%", TopPasses[i].preset);
StringReplace(element, "%MINIWIDTH%", (string)MINIWIDTH);
StringReplace(element, "%MINIHEIGHT%", (string)MINIHEIGHT);
FileWriteString(handle, element);
}
FileWriteString(handle, headerAndFooter[1]);
FileClose(handle);
}
|
下图显示了在多币种模式下通过 UnityPricePeriod 参数优化 UnityMartingale.mq5 后的网页概览外观。

带有最佳优化测试轮次交易报告的网页概览
对于每个报告,我们只显示上半部分,即结余图所在位置。这部分是最方便的,一眼就能看到估算结果。
优化参数列表 ("name=value&name=value...") 显示在每个图的上方。按下一行,可打开一个块,其中包含该传递所有设置的设置文件文本。如果在块内单击,其内容将被复制到剪贴板上。其可以保存在文本编辑器中,从而得到一个现成的设置文件。
单击图表可进入特定的报告页面以及记分卡(如上所述)。
在这一节的最后,我们遇到了另一个问题。之前我们承诺展示 TesterHideIndicators 函数的效果。UnityMartingale.mq5 EA 交易当前使用 UnityPercentEvent.mq5 指标。在任何测试之后,该指标会显示在打开的图表上。我们假设我们想要对用户隐藏 EA 交易的工作机制并从中获取交易品种。然后,在创建通过 iCustom 接收说明符的对象 UnityController 之前,可以在 OnInit 处理程序中调用 TesterHideIndicators 函数(带有 true 参数)。
int OnInit()
{
...
TesterHideIndicators(true);
...
controller = new UnityController(UnitySymbols, barwise,
UnityBarLimit, UnityPriceType, UnityPriceMethod, UnityPricePeriod);
return INIT_SUCCEEDED;
}
|
此版本的 EA 交易将不再在图表上显示指标。但是,隐藏的也不是很好。如果我们查看测试程序的日志,我们将会在许多有用的信息中看到关于加载程序的行:首先是关于加载 EA 交易本身的消息,之后是关于加载指标的消息。
...
expert file added: Experts\MQL5Book\p6\UnityMartingale.ex5.
...
program file added: \Indicators\MQL5Book\p6\UnityPercentEvent.ex5.
...
|
因此,细心的用户可以找到指标名称。这种可能性可以通过资源机制来消除,我们已经在网页空白上下文的传递中提到了这一点。事实证明,已编译的指标也可以作为资源嵌入到 MQL 程序中(EA 交易或其他指标中)。而这样的资源程序在测试程序的日志中也不再提及。我们将在本书的第七章详细研究这些资源,现在我们将在我们的 EA 交易的最终版本中显示与其相关的行。
首先,我们用 #resource 指标指令来描述资源。事实上,该指令只包含编译指示符文件的路径(显然,该路径肯定事先已经编译过),此处强制使用双反斜杠作为分隔符,因为前单斜杠在资源路径中不支持。
#resource "\\Indicators\\MQL5Book\\p6\\UnityPercentEvent.ex5"
|
然后,在 iCustom 调用的行中,替换之前的运算符:
UnityController(const string symbolList, const int offset, const int limit,
const ENUM_APPLIED_PRICE type, const ENUM_MA_METHOD method, const int period):
bar(offset), tickwise(!offset)
{
handle = iCustom(_Symbol, _Period,
"MQL5Book/p6/UnityPercentEvent", // <---
symbolList, limit, type, method, period);
...
|
完全相同,但是有一个资源的链接(注意以双冒号 '::' 开头的语法,这是区分文件系统中的一般路径和资源中的路径所必需的)。
UnityController(const string symbolList, const int offset, const int limit,
const ENUM_APPLIED_PRICE type, const ENUM_MA_METHOD method, const int period):
bar(offset), tickwise(!offset)
{
handle = iCustom(_Symbol, _Period,
"::Indicators\\MQL5Book\\p6\\UnityPercentEvent.ex5", // <---
symbolList, limit, type, method, period);
...
|
现在,EA 交易的编译版本可以单独交付给用户,而无单独指标,因为其隐藏在 EA 交易内部。这不会以任何方式影响其性能,但考虑到 TesterHideIndicators 的挑战,内部设备是隐藏的。切记,如果指标随后更新,EA 交易也需要重新编译。