在交易中应用 OLAP(第 3 部分):为开发交易策略而分析报价(基础篇)
📘

在交易中应用 OLAP(第 3 部分):为开发交易策略而分析报价(基础篇)

第 1/3 篇

用 OLAP 思路拆解报价数据

OLAP(在线分析处理)不止能回测复盘,也能直接铺到原始报价上做多维切片。前两篇已经把汇集多源数据、图形化呈现结果的基础类讲透了,覆盖策略测试器、实盘历史、HTML/CSV 甚至信号源。 把报价按时间、品种、周期维度叠进立方体,比单纯看 K 线更容易暴露分布偏向。外汇与贵金属杠杆高、滑点跳空频繁,这类分析只能给出概率倾向,不能直接当进场指令。 实际落地时,先想清楚要切哪几个轴——比如小时段 × 波动率分桶,再去喂数据,否则维度一多 MT5 终端内存占用会先顶不住。

◍ OLAP 引擎的三层文件骨架

上一篇文章落地的分析内核,实际由三个头文件拼成。OLAPcube.mqh 是底座,里面塞了选择器与聚合器的全部基类、抽象 Record 类及其成交专用的 TradeRecord 子类,还有把任意数据源读成记录数组的适配器基类,以及直连账户历史记录的 HistoryDataAdapter。 报告解析被拆到两个独立文件:HTMLcube.mqh 用 HTMLTradeRecord 加 HTMLReportAdapter 从网页报告抽成交;CSVcube.mqh 则提供 CSVTradeRecord 与 CSVReportAdapter。两者结构对称,换源不改核。 为了不让 EA 每次都重写胶水代码,OLAPcore.mqh 里的 OLAPWrapper 类把示范项目要用的功能整包封装。要验证可直接在 MT5 建个脚本 include 这三个文件,实例化 OLAPWrapper 看能否拉出历史记录数组。 新任务要把 OLAP 从“只看成交”扩到报价或其他流,所以下一步得把 cube 里通用部分抽出来重构,避免每接一种源就复制一遍适配器。

「把交易 OLAP 从通用内核里拆出来」

原 OLAPcube.mqh 被拆成 OLAPCommon.mqh 与 OLAPTrades.mqh 两个头文件。通用部分只留基本类型和模板化后的 OLAPEngine(原 OLAPWrapper 重命名而来),而 SELECTORS、TRADE_RECORD_FIELDS 等枚举,以及 TradeSelector、TradeRecord 等交易类全部迁到 OLAPTrades.mqh,专门处理交易历史和报告。 模板化是关键改动:OLAPEngine<TRADE_RECORD_FIELDS> 这种特化方式,让同一套引擎能套不同数据字段。DaysRangeSelector 现在已是标准选择器,早先它只是 OLAPcore.mqh 里的自定义示例,说明框架在收敛常用分析维度。 文件末尾直接给了默认适配器与引擎实例,MT5 里包含头文件就能用,不用每次手写构造。HTMLcube.mqh 和 CSVcube.mqh 基本没动,旧的交易分析功能全保留。 想验证拆分是否影响老逻辑,可把新的 OLAPRPRT.mq5 和第二篇的 GUI 部分拼起来跑;外汇/贵金属历史回测涉及杠杆与滑点,高风险,结论仅代表代码层面可用、不预示实盘表现。

MQL5 / C++
HistoryDataAdapter<RECORD_CLASS> _defaultHistoryAdapter;
  OLAPEngineTrade _defaultEngine;

给行情切片加上月度和方差维度

做报价行为分析时,只按旧有的手数或星期分组往往不够。把 MonthSelector、WorkWeekDaySelector 和 VarianceAggregator 塞进基础头文件后,任何分析模块都能直接调用,不用重复造轮子。 MonthSelector 按 1–12 月切分数据,弥补了之前实现里缺失的按月视角;WorkWeekDaySelector 只取周一至周五(编号 1 到 5),因为外汇和贵金属周末无交易,留着周六日只会占用超块单元且数值恒为零。 VarianceAggregator 算的是样本方差再开方,本质是标准差,可和 ATR 做横向比对。它不挑数据源——按日内小时、按星期、甚至交易历史里的回报变化都能跑。下面代码里 update() 用 (Σx² − (Σx)²/n)/(n−1) 再开根号,n 为 1 时分母兜底为 1,避免除零。 QuantizationSelector 新增了 granularity 参数(默认 0 表示按字段值精确分组)。比如历史里手数有 0.01、0.1 等一堆值,设步长后就能按 0.1 一档归并,报告可读性立刻上来。 [CODE] 里的 MonthSelector 继承 DateTimeSelector,select() 取记录的日期字段、用 TimeMonth(t)-1 映射到 0–11 的 index;getLabel() 回查英文月份名。VarianceAggregator 的 setSelectorBounds() 把 counters/sumx/sumx2 三个数组同步resize并清零,update() 实时累方与累加。QuantizationSelector 构造函数末尾的 cell(granularity) 就是新加的粒度成员。 Filter 和 FilterRange 现在支持按字段值比较,不再强依赖单元索引;配合返回 -1 的 FilterSelector,就能走字段值过滤分支。多维数据块也解锁了数值排序——WorkWeekDaySelector 的标签写成 "1`Monday" 这种前缀编号格式,字典序即时间序,IdentityAggregator 之类顺序聚合器要注意把记录序号的优先级降下来。

MQL5 / C++
<span class="keyword">class="kw">template</span>&lt;<span class="keyword">class="kw">typename</span> E&gt;
<span class="keyword">class </span>MonthSelector: <span class="keyword">class="kw">public</span> DateTimeSelector&lt;E&gt;
{
<span class="keyword">class="kw">public</span>:
MonthSelector(<span class="keyword">class="kw">const</span> E f): DateTimeSelector(f, class="num">12)
{
_typename = <span class="keyword">class="kw">typename</span>(<span class="keyword">this</span>);
}
<span class="keyword">class="kw">virtual</span> <span class="keyword">class="type">bool</span> select(<span class="keyword">class="kw">const</span> Record *r, <span class="keyword">class="type">int</span> &amp;index) <span class="keyword">class="kw">const</span>
{
<span class="keyword">class="type">class="kw">double</span> d = r.get(selector);
<span class="keyword">class="type">class="kw">datetime</span> t = (<span class="keyword">class="type">class="kw">datetime</span>)d;
index = TimeMonth(t) - class="num">1;
<span class="keyword">class="kw">return</span> <span class="macro">true</span>;
}
<span class="keyword">class="kw">virtual</span> <span class="keyword">class="type">class="kw">string</span> getLabel(<span class="keyword">class="kw">const</span> <span class="keyword">class="type">int</span> index) <span class="keyword">class="kw">const</span>
{
<span class="keyword">class="kw">static</span> <span class="keyword">class="type">class="kw">string</span> months[class="num">12] = {"<span class="class="type">class="kw">string">January</span>", "<span class="class="type">class="kw">string">February</span>", "<span class="class="type">class="kw">string">March</span>", "<span class="class="type">class="kw">string">April</span>", "<span class="class="type">class="kw">string">May</span>", "<span class="class="type">class="kw">string">June</span>", "<span class="class="type">class="kw">string">July</span>", "<span class="class="type">class="kw">string">August</span>", "<span class="class="type">class="kw">string">September</span>", "<span class="class="type">class="kw">string">October</span>", "<span class="class="type">class="kw">string">November</span>", "<span class="class="type">class="kw">string">December</span>"};
<span class="keyword">class="kw">return</span> months[index];
}
};
<span class="keyword">class="kw">template</span>&lt;<span class="keyword">class="kw">typename</span> E&gt;
<span class="keyword">class </span>VarianceAggregator: <span class="keyword">class="kw">public</span> Aggregator&lt;E&gt;
{
<span class="keyword">class="kw">protected</span>:
<span class="keyword">class="type">int</span> counters[];
<span class="keyword">class="type">class="kw">double</span> sumx[];
<span class="keyword">class="type">class="kw">double</span> sumx2[];
<span class="keyword">class="kw">public</span>:
VarianceAggregator(<span class="keyword">class="kw">const</span> E f, <span class="keyword">class="kw">const</span> Selector&lt;E&gt; *&amp;s[], <span class="keyword">class="kw">const</span> Filter&lt;E&gt; *&amp;t[]): Aggregator(f, s, t)
{
_typename = <span class="keyword">class="kw">typename</span>(<span class="keyword">this</span>);
}
<span class="keyword">class="kw">virtual</span> <span class="keyword">class="type">void</span> setSelectorBounds(<span class="keyword">class="kw">const</span> <span class="keyword">class="type">int</span> length = class="num">0) <span class="keyword">class="kw">override</span>
{
Aggregator&lt;E&gt;::setSelectorBounds();
<span class="functions">ArrayResize</span>(counters, <span class="functions">ArraySize</span>(totals));
<span class="functions">ArrayResize</span>(sumx, <span class="functions">ArraySize</span>(totals));
<span class="functions">ArrayResize</span>(sumx2, <span class="functions">ArraySize</span>(totals));
<span class="functions">ArrayInitialize</span>(counters, class="num">0);
<span class="functions">ArrayInitialize</span>(sumx, class="num">0);
<span class="functions">ArrayInitialize</span>(sumx2, class="num">0);
}
<span class="keyword">class="kw">virtual</span> <span class="keyword">class="type">void</span> update(<span class="keyword">class="kw">const</span> <span class="keyword">class="type">int</span> index, <span class="keyword">class="kw">const</span> <span class="keyword">class="type">class="kw">double</span> value) <span class="keyword">class="kw">override</span>
{
counters[index]++;
sumx[index] += value;
sumx2[index] += value * value;
<span class="keyword">class="kw">const</span> <span class="keyword">class="type">int</span> n = counters[index];
<span class="keyword">class="kw">const</span> <span class="keyword">class="type">class="kw">double</span> variance = (sumx2[index] - sumx[index] * sumx[index] / n) / <span class="functions">MathMax</span>(n - class="num">1, class="num">1);
totals[index] = <span class="functions">MathSqrt</span>(variance);
}
};
<span class="keyword">class="kw">template</span>&lt;<span class="keyword">class="kw">typename</span> T&gt;
<span class="keyword">class </span>QuantizationSelector: <span class="keyword">class="kw">public</span> BaseSelector&lt;T&gt;
{
<span class="keyword">class="kw">protected</span>:
Vocabulary&lt;<span class="keyword">class="type">class="kw">double</span>&gt; quants;
<span class="keyword">class="type">uint</span> cell;                 <span class="comment">class=class="str">"cmt">// +</span>
<span class="keyword">class="kw">public</span>:
QuantizationSelector(<span class="keyword">class="kw">const</span> T field, <span class="keyword">class="kw">const</span> <span class="keyword">class="type">uint</span> granularity = class="num">0 <span class="comment">class=class="str">"cmt">/* + */</span>): BaseSelector&lt;T&gt;(field), cell(granularity)
{
_typename = <span class="keyword">class="kw">typename</span>(<span class="keyword">this</span>);
}

◍ 分箱与过滤器的代码骨架

这段实现把连续字段先离散化再过滤,核心在 BaseSelector 派生类的 prepare 与 select。两者都用同一套向下取整逻辑:若 cell 不为 0,就把数值按 cell 步长截断到区间下界,例如 cell=0.5 时 1.23 会变成 1.0、−0.8 会变成 −0.5,回测里区间数会随 cell 缩小而指数膨胀。 [CODE]virtual void prepare(const Record *r) override { double value = r.get(selector); if(cell != 0) value = MathSign(value) * MathFloor(MathAbs(value) / cell) * cell; // 按 cell 向下分箱 quants.add(value); } virtual bool select(const Record *r, int &index) const override { double value = r.get(selector); if(cell != 0) value = MathSign(value) * MathFloor(MathAbs(value) / cell) * cell; // 同上截断 index = quants.get(value); return (index >= 0); } [/CODE] Filter 类的 matches 是另一处易踩坑点:当 selector.select 返回 index==−1 且 selector 实际是 FilterSelector 时,它退化为直接比较 r.get(field)==filter,这意味着等值过滤走的是原始字段而非分箱值。若你在 MT5 里用 FilterSelector 包了一层价格字段又设了 cell,过滤结果可能和直觉相反——它根本没分箱。 FilterSelector 自身把 getRange 写死返回 0、select 永远返回 index=−1,相当于一个“透传选择器”。它存在的意义是把字段名喂给 Filter 拼标题(如 field[1.0]),真正取值逻辑外包给外部 Filter 的 matches。外汇与贵金属行情跳空频繁,这种透传在 tick 级回测里可能因浮点精度导致 == 比较失效,实盘前务必用 Print 打几组样本确认。

MQL5 / C++
class="kw">virtual class="type">void prepare(class="kw">const Record *r) class="kw">override
{
class="type">class="kw">double value = r.get(selector);
if(cell != class="num">0) value = MathSign(value) * MathFloor(MathAbs(value) / cell) * cell; class=class="str">"cmt">// +
quants.add(value);
}
class="kw">virtual class="type">bool select(class="kw">const Record *r, class="type">int &index) class="kw">const class="kw">override
{
class="type">class="kw">double value = r.get(selector);
if(cell != class="num">0) value = MathSign(value) * MathFloor(MathAbs(value) / cell) * cell; class=class="str">"cmt">// +
index = quants.get(value);
class="kw">return (index >= class="num">0);
}
class="kw">virtual class="type">int getRange() class="kw">const class="kw">override
{
class="kw">return quants.size();
}
class="kw">virtual class="type">class="kw">string getLabel(class="kw">const class="type">int index) class="kw">const class="kw">override
{
class="kw">return (class="type">class="kw">string)(class="type">class="kw">float)quants[index];
}
};
class="kw">template<class="kw">typename E>
class Filter
{
class="kw">protected:
Selector<E> *selector;
class="type">class="kw">double filter;
class="kw">public:
Filter(Selector<E> &s, class="kw">const class="type">class="kw">double value): selector(&s), filter(value)
{
}
class="kw">virtual class="type">bool matches(class="kw">const Record *r) class="kw">const
{
class="type">int index;
if(selector.select(r, index))
{
if(index == -class="num">1)                                             class=class="str">"cmt">// +
{                                                           class=class="str">"cmt">// +
if(class="kw">dynamic_cast<FilterSelector<E> *> (selector) != NULL)   class=class="str">"cmt">// +
{                                                         class=class="str">"cmt">// +
class="kw">return r.get(selector.getField()) == filter;            class=class="str">"cmt">// +
}                                                         class=class="str">"cmt">// +
}                                                           class=class="str">"cmt">// +
else                                                        class=class="str">"cmt">// +
{                                                           class=class="str">"cmt">// +
if(index == (class="type">int)filter) class="kw">return true;
}                                                           class=class="str">"cmt">// +
}
class="kw">return class="kw">false;
}
Selector<E> *getSelector() class="kw">const
{
class="kw">return selector;
}
class="kw">virtual class="type">class="kw">string getTitle() class="kw">const
{
class="kw">return selector.getTitle() + "[" + (class="type">class="kw">string)filter + "]";
}
};
class="kw">template<class="kw">typename T>
class FilterSelector: class="kw">public BaseSelector<T>
{
class="kw">public:
FilterSelector(class="kw">const T field): BaseSelector(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 class="kw">override
{
index = -class="num">1;
class="kw">return true;
}
class="kw">virtual class="type">int getRange() class="kw">const class="kw">override
{
class="kw">return class="num">0;
}
class="kw">virtual class="type">class="kw">double getMin() class="kw">const class="kw">override
{
class="kw">return class="num">0;
}
class="kw">virtual class="type">class="kw">double getMax() class="kw">const class="kw">override
{
class="kw">return class="num">0;
}
class="kw">virtual class="type">class="kw">string getLabel(class="kw">const class="type">int index) class="kw">const class="kw">override
{
class="kw">return EnumToString(selector);
}
};

「把 OLAP 引擎接上 K 线报价」

在 OLAPCommon.mqh 的基类之上,新建 OLAPQuotes.mqh 即可把多维分析从成交历史搬到报价数据。核心是先定义两个枚举:QUOTE_SELECTORS 管切片维度(形态、序号、月、星期、小时等),QUOTE_RECORD_FIELDS 管字段(开高低收、点差、成交量及 4 个自定义槽),二者用 SELECTORS 与 ENUM_FIELDS 宏统一暴露,方便后续再挂别的头文件。 ShapeSelector 把柱线按收盘对开盘的方向分成三类:下行走势记 -1、横盘 0、上行走势 +1,索引偏移后落进 0~2 区间,getRange 固定返回 3。也就是说,一段行情里任意一根 bar 都能被自动归类到 bearish / flat / bullish 之一,供后续交叉统计。 QuotesRecord 从 MqlRates 结构取数,FIELD_SHAPE 的填法是 close>open 置 +1、close<open 置 -1、否则 0;所有字段技术上都塞进双精度数组,整数、实数、日期型的应用在同一类里区分。适配器按时间从旧到新遍历柱线,FIELD_INDEX 走普通数组下标而非时间序列倒序。 目前一次只能加载一个应用领域(OLAPTrades 或 OLAPQuotes),不能同时混跑;若要跨元数据块交叉分析得自己重构。外汇与贵金属报价的高频跳空和点差漂移会让形态切片分布明显偏移,接上后建议先用 EURUSD 的 M1 历史跑一遍验证计数。 默认适配器和 OLAP 引擎实例已经是可以直接用的对象,基于这两套类,把 OLAP 扩到优化结果或外部数据源也只是再写一个头文件的事。

MQL5 / C++
enum QUOTE_SELECTORS
{
SELECTOR_NONE,       class=class="str">"cmt">// none
SELECTOR_SHAPE,      class=class="str">"cmt">// type
SELECTOR_INDEX,      class=class="str">"cmt">// ordinal number
class=class="str">"cmt">/* below class="type">class="kw">datetime field assumed */
SELECTOR_MONTH,      class=class="str">"cmt">// month-of-year
SELECTOR_WEEKDAY,    class=class="str">"cmt">// day-of-week
SELECTOR_DAYHOUR,    class=class="str">"cmt">// hour-of-day
SELECTOR_HOURMINUTE, class=class="str">"cmt">// minute-of-hour
class=class="str">"cmt">/* the next require a field as parameter */
SELECTOR_SCALAR,     class=class="str">"cmt">// scalar(field)
SELECTOR_QUANTS,     class=class="str">"cmt">// quants(field)
SELECTOR_FILTER      class=class="str">"cmt">// filter(field)
};
enum QUOTE_RECORD_FIELDS
{
FIELD_NONE,          class=class="str">"cmt">// none
FIELD_INDEX,         class=class="str">"cmt">// index(bar number)
FIELD_SHAPE,         class=class="str">"cmt">// type(bearish/flat/bullish)
FIELD_DATETIME,      class=class="str">"cmt">// class="type">class="kw">datetime
FIELD_PRICE_OPEN,    class=class="str">"cmt">// open price
FIELD_PRICE_HIGH,    class=class="str">"cmt">// high price
FIELD_PRICE_LOW,     class=class="str">"cmt">// low price
FIELD_PRICE_CLOSE,   class=class="str">"cmt">// close price
FIELD_PRICE_RANGE_OC,class=class="str">"cmt">// price range(OC)
FIELD_PRICE_RANGE_HL,class=class="str">"cmt">// price range(HL)
FIELD_SPREAD,        class=class="str">"cmt">// spread
FIELD_TICK_VOLUME,   class=class="str">"cmt">// tick volume
FIELD_REAL_VOLUME,   class=class="str">"cmt">// real volume
FIELD_CUSTOM1,       class=class="str">"cmt">// custom class="num">1
FIELD_CUSTOM2,       class=class="str">"cmt">// custom class="num">2
FIELD_CUSTOM3,       class=class="str">"cmt">// custom class="num">3
FIELD_CUSTOM4,       class=class="str">"cmt">// custom class="num">4
QUOTE_RECORD_FIELDS_LAST
};
class="macro">#define SELECTORS QUOTE_SELECTORS
class="macro">#define ENUM_FIELDS QUOTE_RECORD_FIELDS
class QuoteSelector: class="kw">public BaseSelector<QUOTE_RECORD_FIELDS>
{
class="kw">public:
QuoteSelector(class="kw">const QUOTE_RECORD_FIELDS field): BaseSelector(field)
{
}
};
class ShapeSelector: class="kw">public QuoteSelector
{
class="kw">public:
ShapeSelector(): QuoteSelector(FIELD_SHAPE)
{
_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="type">int)r.get(selector);
index += class="num">1; class=class="str">"cmt">// shift from -class="num">1, class="num">0, +class="num">1 to [class="num">0..class="num">2]
class="kw">return index >= getMin() && index <= getMax();
}
class="kw">virtual class="type">int getRange() class="kw">const
{
class="kw">return class="num">3; class=class="str">"cmt">// class="num">0 through class="num">2
}
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">3] = {"bearish", "flat", "bullish"};
class="kw">return types[index];
}
};
class QuotesRecord: class="kw">public Record
{
class="kw">protected:
class="kw">static class="type">int counter; class=class="str">"cmt">// number of bars
class="type">void fillByQuotes(class="kw">const class="type">MqlRates &rate)
{
set(FIELD_INDEX, counter++);
set(FIELD_SHAPE, rate.close > rate.open ? +class="num">1 : (rate.close < rate.open ? -class="num">1 : class="num">0));
set(FIELD_DATETIME, (class="type">class="kw">double)rate.time);
set(FIELD_PRICE_OPEN, rate.open);

常见问题

用 OLAP 思路建三层文件骨架:原始报价、聚合中间表、展示层;给行情加月度与方差维度后,按箱格分组就能看出不同月份的波动偏好。
先定义分箱边界数组,再遍历报价按阈值归入箱格,并用过滤器剔除异常方差样本;基础骨架约几十行,重点在边界与过滤条件可调。
可以,小布盯盘的 AIGC 已内置这类诊断,打开对应品种页就能看到按月度和方差切分的行情特征,不用自己接数据引擎。
交易场景只需特定维度和过滤逻辑,拆出后加载更快、参数更聚焦,也避免通用计算拖慢回测与实时看盘。
方差受极端行情影响大,需先过滤异常样本再分箱;贵金属杠杆高、风险大,任何切片结论都只是概率倾向,不能直接当开仓依据。