在交易中应用 OLAP(第 1 部分):在线分析多维数据(基础篇)
📘

在交易中应用 OLAP(第 1 部分):在线分析多维数据(基础篇)

第 1/3 篇

把交易数据当成超立方体来切

有经验的交易者手里的数字从来不是单维的:报价、指标、成交记录、优化输出,每一项都挂在若干参数上。把这些参数各自当成一条互相垂直的轴,整体就构成一个虚拟超立方体,任一组合对应一个单元。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 返回的索引分布是否符合预期。外汇和贵金属杠杆高、滑点随机,映射逻辑要先拿历史数据跑通再谈实盘。

MQL5 / C++
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。 外汇与贵金属杠杆高、滑点跳空频繁,历史统计只反映过去概率,不能直接当成未来分布。

MQL5 / C++
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 确认覆盖区间。

MQL5 / C++
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 */};

常见问题

先把每笔成交抽象成带维度(品种、时间、方向)的记录,再用选择器按维度过滤,就能分块聚合查看,避免数据搅成一团。
用游标逐个捞历史订单,配合适配器只取黄金多单且时间落在本周的记录,跑一遍即可汇总,不必人工翻。
小布可把你的成交按品种、周期自动切成多维视图,直接标出哪块盈利哪块拖后腿,你只看结论就行。
普通表只有行列,超立方体允许按多个维度交叉切,比如同时看时段和品种,遗漏的概率更低。
从 Record 到 Selector 做清晰类骨架,选择器管过滤、适配器管取数,历史订单用游标遍历,结构稳也好扩。