交易者经常需要分析大量数据。 这些通常包括数字、报价、指标值和交易报告。 由于这些数字所依赖的参数和条件数量众多,我们应将它们分开考虑,并从不同角度观察整个过程。 整体信息量形成了一种虚拟超立方体,其中每个参数定义其自身的维度,该维度与其余维度相互垂直。
可以使用流行的 OLAP(
在线分析处理)技术处理和分析这种超立方体。
方法名称中的“在线 (online)”一词不是指互联网,而是指结果的及时性。 操作原理意味着超立方体单元的初步计算,之后您能够以直观的形式快速提取和查看立方体的任何横截面。 可将之与 MetaTrader
中的优化过程进行比较:测试器首先计算交易变量(可能需要相当长的时间,即使并未提示),然后输出报告,其结果与输入参数相关联。 从 MetaTrader 5 Build 1860 开始,平台支持通过切换各种优化条件来查看优化结果的动态变化。 这与 OLAP
的理念十分接近。 但是对于完整的分析,我们需要选择超立方体的许多其他切面的能力。
我们会尝试在 MetaTrader 中应用 OLAP 方法,并利用 MQL 工具实现多维分析。 在继续实现之前,我们需要确定所要分析的数据。 这些可能包括交易报告、优化结果或指标值。
此阶段的选择并不十分重要,因为我们的目标是开发适用于任何数据的通用面向对象引擎。 但我们需要将引擎应用于特定结果。 最热门的任务之一是分析交易报告。 我们将考查这项任务。
在交易报告中,按品种、周内星期值、买卖操作来细分利润也许会有用。 另一种选择是比较不同交易机器人的性能结果(即,按魔幻数字逐一划分)。 下一个合乎逻辑的问题在于,是否可以组合各种维度:品种按星期值与智能交易系统关联,或添加其他一些分组。 所有这些都可以利用 OLAP 完成。
体系结构
根据面向对象的方法,大型任务应该分解为简单的逻辑相关部分,而每个部分根据传入数据,内部状态和一些规则集合执行自己的角色。
我们将使用的第一个类是包含源数据的记录 — ‘Record’。 这样的记录可以存储一次交易操作或一个优化过程相关的数据,等等。
‘Record’ 是具有任意数量字段的向量。 由于这是一个抽象的实体,每个字段的含义并不重要。 对于每个特定的应用程序,我们将创建一个派生类,它“知道”字段的用途,并相应地处理它们。
需要另一个类 ‘DataAdapter’ 从一些抽象来源读取记录(例如交易账户历史,CSV 文件,HTML 报告,或使用 WebRequest 从网络上获得的数据)。 在此阶段它只执行一个功能:它逐个遍历记录,并提供对它们的访问。
稍后,我们能够为每个实际应用程序创建派生类。 这些类将从相关来源填充记录数组。
所有记录都可能以某种方式显示在超立方体单元中。 在此阶段我们无需知道如何做到这一点,但这是本项目的思路:从多维数据集合单元中的记录字段派发输入值,并使用所选的聚合函数计算广义统计数据。
基本多维数据集合级别仅提供主要属性,例如维度数、名称和每个维度的大小。 此数据在 MetaCube 类中提供。
派生类随后将相关统计信息填入这些单元。 特定聚合器的最常见示例包括所有值的总和,或所有记录相同字段的平均值。 不过会有更多不同类型的聚合器。
若要启用单元中数值的聚合,每个记录必须接收一组索引,这些索引将其映射到多维数据集合的某个唯一单元。 此任务将由特殊的 “Selector” 类执行。 Selector 对应于超立方体的一侧(轴,坐标)。
抽象 Selector 基类提供了一个可编程接口,用于定义一组有效值,并将每个条目映射到其中一个值。 例如,如果目的是按星期值切分记录,则派生的 Selector 类应返回星期值的编号,从 0 到 6。 特定 Selector
的允许值数量定义此多维数据集维度的大小。 这对于星期值来说是显而易见的,即 7。
此外,有时对于滤除一些记录(从分析中排除它们)很有用。 因此,我们需要一个 Filter 类。 它与 Selector 类似,但它对允许值设置了额外的限制。 例如,我们可以基于星期值的选择器创建一个过滤器。 在该过滤器中,可以指定需要从计算中排除或包含在其中的星期值。
一旦创建了多维数据集合(即,所有单元的聚合函数已计算完毕),结果就能够可视化并分析。 为此目的,我们保留特殊的 “Display” 类。
若要将所有上述所有类组合成一个整体,我们应创建一种控制中心,即 Analyst 类。
这在 UML 表示法中如下所示(这可视为一个行动计划,可在任何开发阶段进行检查)。
MetaTrader 中的在线分析处理
此处省略了一些类。 然而,它反映了超立方体构造的常规基础,并且它展示了可用于计算超立方体单元的聚合函数。
基类实现
现在我们将继续实现上述类。 我们从 Record 类开始。
class Record { private: double data[]; public: Record(const int length) { ArrayResize(data, length); ArrayInitialize(data, 0); } void set(const int index, double value) { data[index] = value; } double get(const int index) const { return data[index]; } };
它只简单地将任意值存储在 ‘data’ 数组(向量)中。 向量长度在构造函数中设置。
利用 DataAdapter 读取不同来源的记录。
class DataAdapter { public: virtual Record *getNext() = 0; virtual int reservedSize() = 0; };
必须在循环中调用 getNext 方法,直到它返回 NULL(这意味着没有更多记录)。 所有收到的记录都应保存在某处(此任务稍后将会讨论)。 reservedSize 方法支持优化的内存分配(如果提前知道源中的记录数量)。
每个超立方体维度基于一个或多个记录字段计算。 出于便利,将每个字段用一个枚举元素标记。 例如,为了分析账户交易历史,可以使用以下枚举。
// MT4 和 MT5 对冲账户 enum TRADE_RECORD_FIELDS { FIELD_NONE, // 无 FIELD_NUMBER, // 序列号 FIELD_TICKET, // 票据 FIELD_SYMBOL, // 品种 FIELD_TYPE, // 类型 (OP_BUY/OP_SELL) FIELD_DATETIME1, // 开单时间 FIELD_DATETIME2, // 平单时间 FIELD_DURATION, // 持续时间 FIELD_MAGIC, // 魔幻数字 FIELD_LOT, // 手数 FIELD_PROFIT_AMOUNT, // 盈利额 FIELD_PROFIT_PERCENT,// 盈利百分比 FIELD_PROFIT_POINT, // 赢利点数 FIELD_COMMISSION, // 佣金 FIELD_SWAP, // 隔夜利息 FIELD_CUSTOM1, // 自定义 1 FIELD_CUSTOM2 // 自定义 2 };
最后两个字段可用于计算非标准变量。
建议以下枚举用于 MetaTrader 优化结果的分析。
enum OPTIMIZATION_REPORT_FIELDS { OPTIMIZATION_PASS, OPTIMIZATION_PROFIT, OPTIMIZATION_TRADE_COUNT, OPTIMIZATION_PROFIT_FACTOR, OPTIMIZATION_EXPECTED_PAYOFF, OPTIMIZATION_DRAWDOWN_AMOUNT, OPTIMIZATION_DRAWDOWN_PERCENT, OPTIMIZATION_PARAMETER_1, OPTIMIZATION_PARAMETER_2, //... };
应为每个实际应用案例准备独立的枚举。 然后它可以作为 Selector 模板类的参数。
template<typename E> class Selector { protected: E selector; string _typename; public: Selector(const E field): selector(field) { _typename = typename(this); } // 返回单元索引以存储记录中的值 virtual bool select(const Record *r, int &index) const = 0; virtual int getRange() const = 0; virtual float getMin() const = 0; virtual float getMax() const = 0; virtual E getField() const { return selector; } virtual string getLabel(const int index) const = 0; virtual string getTitle() const { return _typename + "(" + EnumToString(selector) + ")"; } };
selector 字段只存储一个值,即枚举的一个元素。 例如,如果使用了 TRADE_RECORD_FIELDS,则可以按如下方式创建买/卖操作的选择器:
new Selector<TRADE_RECORD_FIELDS>(FIELD_TYPE);
_typename 字段作为辅助。 它将在所有派生类中被重写,以便识别选择器,这在可视化结果时很有用。 该字段在虚拟 getTitle 方法当中会用到。
操作的主要部分由 “select” 方法中的类执行。 在此,每个输入记录被映射为沿坐标轴的特定索引值,该坐标轴由当前选择器形成。 索引必须在 getMin 和 getMax 方法返回数值之间的范围内,而索引的总数等于 getRange 返回的数字。
如果出于某种原因,记录无法在段落定义区域中正确映射,’select’ 方法返回 false。 如果映射已正确执行,则返回 true。
getLabel 方法返回一段用户友好的特定索引描述。 例如,对于买/卖操作,索引 0 必须生成 “buy”,而索引 1 必须生成 “sell”。
为交易历史实现特殊的选择器和数据适配器类
由于我们即将分析交易历史,因此我们将根据 TRADE_RECORD_FIELDS 枚举引入一组中间选择器。
class TradeSelector: public Selector<TRADE_RECORD_FIELDS> { public: TradeSelector(const TRADE_RECORD_FIELDS field): Selector(field) { _typename = typename(this); } virtual bool select(const Record *r, int &index) const { index = 0; return true; } virtual int getRange() const { return 1; // 默认情况下,这是一个标量,返回数值 1 } virtual double getMin() const { return 0; } virtual double getMax() const { return (double)(getRange() - 1); } virtual string getLabel(const int index) const { return EnumToString(selector) + "[" + (string)index + "]"; } };
默认情况下,它将所有记录映射到同一个单元。 例如,使用此选择器,您可以获取总利润数据。
现在,基于此选择器,我们可以轻松判定选择器的特定衍生类型。 这也用于按操作类型(买/卖)对记录进行分组。
class TypeSelector: public TradeSelector { public: TypeSelector(): TradeSelector(FIELD_TYPE) { _typename = typename(this); } virtual bool select(const Record *r, int &index) const { ... } virtual int getRange() const { return 2; // OP_BUY, OP_SELL } virtual double getMin() const { return OP_BUY; } virtual double getMax() const { return OP_SELL; } virtual string getLabel(const int index) const { const static string types[2] = {"buy", "sell"}; return types[index]; } };
我们在构造函数中用 FIELD_TYPE 元素定义了类。 getRange 方法返回 2,因为这里我们只有 2 种可能的类型:OP_BUY 或 OP_SELL。 getMin 和 getMax 方法返回相应的常量。 ‘select’ 方法应该包含什么?
首先,我们需要决定在每条记录中将存储哪些信息。 这可以利用 TradeRecord 类来完成,该类源自 Record,并适用于交易历史。
class TradeRecord: public Record { private: static int counter; protected: void fillByOrder() { set(FIELD_NUMBER, counter++); set(FIELD_TICKET, OrderTicket()); set(FIELD_TYPE, OrderType()); set(FIELD_DATETIME1, OrderOpenTime()); set(FIELD_DATETIME2, OrderCloseTime()); set(FIELD_DURATION, OrderCloseTime() - OrderOpenTime()); set(FIELD_MAGIC, OrderMagicNumber()); set(FIELD_LOT, (float)OrderLots()); set(FIELD_PROFIT_AMOUNT, (float)OrderProfit()); set(FIELD_PROFIT_POINT, (float)((OrderType() == OP_BUY ? +1 : -1) * (OrderClosePrice() - OrderOpenPrice()) / SymbolInfoDouble(OrderSymbol(), SYMBOL_POINT))); set(FIELD_COMMISSION, (float)OrderCommission()); set(FIELD_SWAP, (float)OrderSwap()); } public: TradeRecord(): Record(TRADE_RECORD_FIELDS_NUMBER) { fillByOrder(); } };
辅助的 fillByOrder 方法演示了如何根据当前订单填充大多数记录字段。 当然,必须在代码中的其他位置预先选择订单。 在此我们使用 MetaTrader 4 交易函数的表示法。 MetaTrader 5 的支持将通过包含 MT4Orders
函数库来实现(其中一个版本附在下面,应始终检查并下载当前版本)。 因此,我们可以创建跨平台代码。
TRADE_RECORD_FIELDS_NUMBER 字段的数量可以用宏定义硬编码,也可以根据 TRADE_RECORD_FIELDS 枚举动态计算。 第二种方法在附带代码中实现,其中使用了特殊模板化的 EnumToArray 函数。
正如从 fillByOrder 方法里看到的,FIELD_TYPE 字段由 OrderType 中的操作类型填充。 现在我们可以回到 TypeSelector 类并实现它的 ‘select’ 方法。
virtual bool select(const Record *r, int &index) const { index = (int)r.get(selector); return index >= getMin() && index <= getMax(); }
此处我们从输入记录(r)中读取字段值(selector)并将其值(可以是 OP_BUY 或 OP_SELL)分配给索引输出参数。 计算仅包括入场订单,因此对所有其他类型返回 false。 稍后我们将考虑其他选择器类型。
现在是时候为交易历史开发数据适配器了。 根据账户的真实交易历史,生成 TradeRecord 记录的类。
class HistoryDataAdapter: public DataAdapter { private: int size; int cursor; protected: void reset() { cursor = 0; size = OrdersHistoryTotal(); } public: HistoryDataAdapter() { reset(); } virtual int reservedSize() { return size; } virtual Record *getNext() { if(cursor < size) { while(OrderSelect(cursor++, SELECT_BY_POS, MODE_HISTORY)) { if(OrderType() < 2) { return new TradeRecord(); } } return NULL; } return NULL; } };
适配器按顺序传递历史记录中可用的所有订单,并为每笔入场订单创建 TradeRecord 实例。 此处呈现的代码只是简化形式。 在实际运用过程中,我们可能需要创建不属于 TradeRecord 类的对象,而是创建派生类的对象:我们为 TRADE_RECORD_FIELDS 枚举保留了两个自定义字段。
所以,HistoryDataAdapter 是模板类,而 template 参数是所生成记录对象的实际类。 Record 类必须包含一个用于填充自定义字段的空虚拟方法:
virtual void fillCustomFields() {/* does nothing */};
您可以自行分析完整的实现方法:在核心中使用 CustomTradeRecord 类。 在 fillCustomFields 中,此类(它是 TradeRecord 的子代)计算每笔仓位的 MFE(最大有利偏移)和 MAE(最大不利偏移),并将这些值记录到 FIELD_CUSTOM1 和
FIELD_CUSTOM2 字段中。
实现聚合器和控制类
我们需要一个地方来创建适配器,并调用其 getNext 方法。 现在我们将处理“控制中心”,即 Analyst 类。 除了启动适配器之外,该类还必须将接收到的记录存储在内部数组中。
template<typename E> class Analyst { private: DataAdapter *adapter; Record *data[]; public: Analyst(DataAdapter &a): adapter(&a) { ArrayResize(data, adapter.reservedSize()); } ~Analyst() { int n = ArraySize(data); for(int i = 0; i < n; i++) { if(CheckPointer(data[i]) == POINTER_DYNAMIC) delete data[i]; } } void acquireData() { Record *record; int i = 0; while((record = adapter.getNext()) != NULL) { data[i++] = record; } ArrayResize(data, i); } };
该类不会创建适配器,但它接收一个准备好的适配器作为构造函数参数。 这是一个众所周知的设计原则 — 依赖注入。
它允许从特定的 DataAdapter 实现中隔离 Analyst。 换言之,我们可以轻松替换各种适配器变体,而无需在 Analyst 类中进行修改。
Analyst 类现在能够填充内部记录数组,但它仍然不知道如何执行主函数,即如何聚合数据。 此任务将由聚合器实现。
聚合器是可以计算所选记录字段的预定义变量(统计数据)的类。 聚合器的基类是 MetaCube,它是基于多维数组的存储。
class MetaCube { protected: int dimensions[]; int offsets[]; double totals[]; string _typename; public: int getDimension() const { return ArraySize(dimensions); } int getDimensionRange(const int n) const { return dimensions[n]; } int getCubeSize() const { return ArraySize(totals); } virtual double getValue(const int &indices[]) const = 0; };
‘dimensions’ 数组描述了超立方体结构。 它的大小等于所用选择器的数量,即维度。 ‘dimensions’ 数组的每个元素都包含此维度中的多维数据集合大小,该大小取决于相应选择器的数值范围。
例如,为了按星期值查看利润,我们需要创建一个选择器,根据订单(仓位)的开单或平单时间,将星期编号从 0 到 6 作为索引返回。 由于这是唯一的选择器,’dimensions’ 数组将包含 1 个元素,其值将为 7。 如果我们添加另一个选择器,例如前面描述的
TypeSelector,按星期值和操作类型查看利润,’dimensions’ 数组将包含 2 个元素,其值为 7 和 2。 这也意味着超立方体将包含 14 个带统计数据的单元。
含有所有数值的数组(在我们的示例中为 14)包含在 “totals” 中。 由于超立方体是多维的,因此也许看起来数组只是被声明为只有一个维度。 这是因为我们事先并不知道用户需要添加的超立方体维度。 此外,MQL 不支持多维数组,其中所有绝对维度都将动态分布。
所以,使用通常的“平面”数组(矢量)。 在此数组中使用特殊索引在多个维度上存储单元。 接下来,我们研究每个维度的偏移计算。
基类不分配也不初始化数组,这些是由派生类执行的。
由于预期所有聚合器都具有许多共同特征,因此我们将它们打包在一个中间类中。
template<typename E> class Aggregator: public MetaCube { protected: const E field;
每个聚合器处理特定的记录字段。 该字段在类中的 “field” 变量中指定,该变量在构造函数中填充(参见下文)。 例如,这可以是利润(FIELD_PROFIT_AMOUNT)。
const int selector