在交易中应用 OLAP(第 1 部分):在线分析多维数据(基础篇)
把交易数据当成超立方体来切
有经验的交易者手里的数字从来不是单维的:报价、指标、成交记录、优化输出,每一项都挂在若干参数上。把这些参数各自当成一条互相垂直的轴,整体就构成一个虚拟超立方体,任一组合对应一个单元。OLAP(在线分析处理)的本意不是联网,而是预先把单元算好,之后随取随看任意横截面。 MetaTrader 5 从 Build 1860 起允许切换优化条件动态查看结果,这已经贴近 OLAP 思路,但原生只给了有限切面。若要按品种、星期几、买卖方向三层叠加去拆利润,或按魔幻数字比对不同 EA 表现,原生报告做不到。 这类需求的高频场景是交易报告多维细分:比如黄金在周三的卖单盈亏分布,或某 EA 在欧美品种上按交易时段聚合的胜率。外汇与贵金属杠杆高、滑点跳空频繁,这类切片只能揭示历史概率倾向,不能直接推导下一笔结果。 我们后续会用 MQL 写一套通用面向对象引擎,不绑定某类数据,但先拿交易报告开刀:品种 × 星期值 × 买卖操作,就是一组可立刻在 MT5 账户历史里验证的切面。
「把交易数据拆成可聚合的超立方体」
面向对象思路下,大型分析任务要先拆成逻辑独立的部件,每个部件只管自己的输入、内部状态和执行规则。我们这套 MT5 分析框架的第一层是 Record 类:它是一条抽象记录,能装一次交易操作或一次优化结果,本质是个字段数量任意的向量,字段含义由具体派生类去解释。 读取端交给 DataAdapter,它不关心数据来自账户历史、CSV 还是 WebRequest 抓回来的网页,只负责逐条吐出 Record。后续每个实盘场景写个派生类,把对应来源填进记录数组即可。 所有记录最终要落进一个超立方体(MetaCube)的单元里。基类只暴露维度数、各维名称和大小;具体统计由派生类用聚合函数算,比如求某单元内所有记录某字段之和或均值。 记录进哪个单元,由 Selector 类决定:一个 Selector 对应超立方体的一条轴,把每条记录映射到该轴的一个合法索引。按星期切片时,派生 Selector 返回 0~6,这一维大小就是固定的 7。Filter 类与之类似但多一道门槛,可把某些星期值直接剔出计算。 等单元聚合完成,Display 类负责可视化,Analyst 类做总调度把上述部件捏在一起。UML 里能看到部分类省略了,但超立方体构造基础和单元聚合函数的计算路径已经完整。
◍ 从 Record 到 Selector 的类骨架
先把最底层的 Record 类落地:它只干一件事,把任意值塞进构造函数里定好长度的 double 数组。向量长度在初始化时由 ArrayResize 锁死,之后用 set/get 按索引读写,不负责任何业务逻辑。 数据从外部进来靠 DataAdapter。它只有两个纯虚方法:getNext 在循环里反复调用,返回 NULL 表示源头耗尽;reservedSize 在已知记录总数时帮着预分配内存,减少运行中反复扩容的损耗。 每个超立方体维度都绑定一个或多个记录字段,用枚举元素做标签最省事。下面这段代码里 TRADE_RECORD_FIELDS 覆盖了 MT4/MT5 对冲账户常用的 17 个字段,最后两个 FIELD_CUSTOM1/2 留给非标准变量;OPTIMIZATION_REPORT_FIELDS 则专门对应策略优化报告的关键列。 Selector 是模板类,入参就是上面那种枚举。selector 成员只存一个枚举值,比如用 TRADE_RECORD_FIELDS 时传 FIELD_TYPE 就能筛买/卖。核心在 select 方法:把一条 Record 映射成坐标轴索引,索引必须落在 getMin 和 getMax 之间、总数等于 getRange;映射失败返回 false。getLabel 负责把索引 0 翻成 "buy"、1 翻成 "sell" 这类可读串,_typename 配合 getTitle 在可视化时区分选择器。 开 MT5 建个 EA 把这段骨架贴进去,把 TRADE_RECORD_FIELDS 接上历史订单缓存,就能验证 select 返回的索引分布是否符合预期。外汇和贵金属杠杆高、滑点随机,映射逻辑要先拿历史数据跑通再谈实盘。
class Record { class="kw">private: class="type">class="kw">double data[]; class="kw">public: Record(class="kw">const class="type">int length) { ArrayResize(data, length); ArrayInitialize(data, class="num">0); } class="type">void set(class="kw">const class="type">int index, class="type">class="kw">double value) { data[index] = value; } class="type">class="kw">double get(class="kw">const class="type">int index) class="kw">const { class="kw">return data[index]; } }; class DataAdapter { class="kw">public: class="kw">virtual Record *getNext() = class="num">0; class="kw">virtual class="type">int reservedSize() = class="num">0; }; class=class="str">"cmt">// MT4 和 MT5 对冲账户 enum TRADE_RECORD_FIELDS { FIELD_NONE, class=class="str">"cmt">// 无 FIELD_NUMBER, class=class="str">"cmt">// 序列号 FIELD_TICKET, class=class="str">"cmt">// 票据 FIELD_SYMBOL, class=class="str">"cmt">// 品种 FIELD_TYPE, class=class="str">"cmt">// 类型 (OP_BUY/OP_SELL) FIELD_DATETIME1, class=class="str">"cmt">// 开单时间 FIELD_DATETIME2, class=class="str">"cmt">// 平单时间 FIELD_DURATION, class=class="str">"cmt">// 持续时间 FIELD_MAGIC, class=class="str">"cmt">// 魔幻数字 FIELD_LOT, class=class="str">"cmt">// 手数 FIELD_PROFIT_AMOUNT, class=class="str">"cmt">// 盈利额 FIELD_PROFIT_PERCENT,class=class="str">"cmt">// 盈利百分比 FIELD_PROFIT_POINT, class=class="str">"cmt">// 赢利点数 FIELD_COMMISSION, class=class="str">"cmt">// 佣金 FIELD_SWAP, class=class="str">"cmt">// 隔夜利息 FIELD_CUSTOM1, class=class="str">"cmt">// 自定义 class="num">1 FIELD_CUSTOM2 class=class="str">"cmt">// 自定义 class="num">2 }; 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, class=class="str">"cmt">//... }; class="kw">template<class="kw">typename E> class Selector { class="kw">protected: E selector; class="type">class="kw">string _typename; class="kw">public: Selector(class="kw">const E field): selector(field) { _typename = class="kw">typename(this); } class=class="str">"cmt">// 返回单元索引以存储记录中的值 class="kw">virtual class="type">bool select(class="kw">const Record *r, class="type">int &index) class="kw">const = class="num">0; class="kw">virtual class="type">int getRange() class="kw">const = class="num">0; class="kw">virtual class="type">class="kw">float getMin() class="kw">const = class="num">0; class="kw">virtual class="type">class="kw">float getMax() class="kw">const = class="num">0; class="kw">virtual E getField() class="kw">const { class="kw">return selector; } class="kw">virtual class="type">class="kw">string getLabel(class="kw">const class="type">int index) class="kw">const = class="num">0; class="kw">virtual class="type">class="kw">string getTitle() class="kw">const { class="kw">return _typename + "(" + EnumToString(selector) + ")"; } }; new Selector<TRADE_RECORD_FIELDS>(FIELD_TYPE);
用选择器与适配器切分历史成交
分析 MT5 交易历史时,先按 TRADE_RECORD_FIELDS 枚举做一层中间选择器,默认把所有记录映射到同一个单元,便于直接抽取总利润这类标量数据。 基于这个基类可以派生出按操作类型分组的子类,例如 TypeSelector 的 getRange 返回 2,对应 OP_BUY 与 OP_SELL 两种可能,getMin/getMax 直接回传这两个常量边界。 TradeRecord 继承自 Record,fillByOrder 方法用 OrderTicket、OrderType、OrderProfit 等把订单字段填进记录;文中用 MT4 交易函数写法,靠 MT4Orders 库在 MT5 下跑通,所以代码可跨平台。 TRADE_RECORD_FIELDS_NUMBER 字段数可用宏硬编码,也可靠 EnumToArray 模板从枚举动态算,附带实现取了后者。 HistoryDataAdapter 是模板类,参数即生成的记录类型;Record 基类的 fillCustomFields 是空虚函数,派生类 CustomTradeRecord 在里面算每笔仓位的 MFE 与 MAE,写进 FIELD_CUSTOM1 / FIELD_CUSTOM2。 外汇与贵金属杠杆高、滑点跳空频繁,历史统计只反映过去概率,不能直接当成未来分布。
class TradeSelector: class="kw">public Selector<TRADE_RECORD_FIELDS> { class="kw">public: TradeSelector(class="kw">const TRADE_RECORD_FIELDS field): Selector(field) { _typename = class="kw">typename(this); } class="kw">virtual class="type">bool select(class="kw">const Record *r, class="type">int &index) class="kw">const { index = class="num">0; class="kw">return true; } class="kw">virtual class="type">int getRange() class="kw">const { class="kw">return class="num">1; class=class="str">"cmt">// 默认情况下,这是一个标量,返回数值 class="num">1 } class="kw">virtual class="type">class="kw">double getMin() class="kw">const { class="kw">return class="num">0; } class="kw">virtual class="type">class="kw">double getMax() class="kw">const { class="kw">return (class="type">class="kw">double)(getRange() - class="num">1); } class="kw">virtual class="type">class="kw">string getLabel(class="kw">const class="type">int index) class="kw">const { class="kw">return EnumToString(selector) + "[" + (class="type">class="kw">string)index + "]"; } }; class TypeSelector: class="kw">public TradeSelector { class="kw">public: TypeSelector(): TradeSelector(FIELD_TYPE) { _typename = class="kw">typename(this); } class="kw">virtual class="type">bool select(class="kw">const Record *r, class="type">int &index) class="kw">const { ... } class="kw">virtual class="type">int getRange() class="kw">const { class="kw">return class="num">2; class=class="str">"cmt">// OP_BUY, OP_SELL } class="kw">virtual class="type">class="kw">double getMin() class="kw">const { class="kw">return OP_BUY; } class="kw">virtual class="type">class="kw">double getMax() class="kw">const { class="kw">return OP_SELL; } class="kw">virtual class="type">class="kw">string getLabel(class="kw">const class="type">int index) class="kw">const { class="kw">const class="kw">static class="type">class="kw">string types[class="num">2] = {"buy", "sell"}; class="kw">return types[index]; } }; class TradeRecord: class="kw">public Record { class="kw">private: class="kw">static class="type">int counter; class="kw">protected: class="type">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, (class="type">class="kw">float)OrderLots()); set(FIELD_PROFIT_AMOUNT, (class="type">class="kw">float)OrderProfit()); set(FIELD_PROFIT_POINT, (class="type">class="kw">float)((OrderType() == OP_BUY ? +class="num">1 : -class="num">1) * (OrderClosePrice() - OrderOpenPrice()) / SymbolInfoDouble(OrderSymbol(), SYMBOL_POINT))); set(FIELD_COMMISSION, (class="type">class="kw">float)OrderCommission()); set(FIELD_SWAP, (class="type">class="kw">float)OrderSwap()); } class="kw">public: TradeRecord(): Record(TRADE_RECORD_FIELDS_NUMBER) { fillByOrder(); } }; class="kw">virtual class="type">bool select(class="kw">const Record *r, class="type">int &index) class="kw">const { index = (class="type">int)r.get(selector); class="kw">return index >= getMin() && index <= getMax(); } class HistoryDataAdapter: class="kw">public DataAdapter { class="kw">private: class="type">int size;
「用游标逐个捞历史订单」
上面这段类把 MT5 历史池封装成迭代器,核心是一个 int 型 cursor 当指针。reset() 里把 cursor 归零,并用 OrdersHistoryTotal() 拿到历史订单总数赋给 size,构造时自动跑一次,保证每次新建适配器都从头部开始。 getNext() 是取数主逻辑:只要 cursor 小于 size,就按位置 OrderSelect(cursor++, SELECT_BY_POS, MODE_HISTORY) 逐条选单。这里有个过滤——OrderType() < 2 才 new 一个 TradeRecord() 返回,也就是说只认市价单(OP_BUY=0、OP_SELL=1),挂单和待成交类全部跳过。 reservedSize() 直接回传 size,给上层预留内存用;fillCustomFields() 是空实现,说明这个适配器暂不给记录补自定义字段。跑之前注意:外汇和贵金属历史订单受券商保留策略影响,OrdersHistoryTotal() 返回值可能只有最近几百条,回测和实盘不一致属高风险现象,验证时先打印 size 确认覆盖区间。
class="type">int cursor; class="kw">protected: class="type">void reset() { cursor = class="num">0; size = OrdersHistoryTotal(); } class="kw">public: HistoryDataAdapter() { reset(); } class="kw">virtual class="type">int reservedSize() { class="kw">return size; } class="kw">virtual Record *getNext() { if(cursor < size) { class="kw">while(OrderSelect(cursor++, SELECT_BY_POS, MODE_HISTORY)) { if(OrderType() < class="num">2) { class="kw">return new TradeRecord(); } } class="kw">return NULL; } class="kw">return NULL; } }; class="kw">virtual class="type">void fillCustomFields() {class=class="str">"cmt">/* does nothing */};