如果你混迹过职业交易圈,肯定听过订单流和成交量分布的神话。故事都一个套路:你需要Tick数据,需要彭博终端,需要数据科学博士学位。而我们这些只能用MT4和标准历史数据的零售交易者,被告知我们只是在赌博。我觉得这话纯属扯淡。
我们没有Tick数据,但我们有成交量(Volume)。而成交量,如果你用对了方式去解读,能讲出一个相当精彩的故事。本文就是关于如何从MQL4中那个不起眼的
iVolume() 函数里榨出这个故事。我们要构建一个成交量分布EA,它不仅看价格点位,更关注成交量在哪里集中,以及这种集中如何变化。关键是,全程不需要哪怕一个Tick的买卖报价数据。理论基础:价值区间与控制点
对不熟悉的读者来说,成交量分布(Volume Profile,或称市场轮廓)展示的是特定时间段内各价格水平上的交易活动。核心概念有:
其核心理念是,价格往往会向POC回归。突破价值区间上沿(VAH)意义重大,但被POC拒绝也同样有效。传统交易中我们寻找突破,而在成交量分布交易中,我们寻找的是围绕POC的回转。
参考来源: 该方法论深深植根于Peter Steidlmayer的研究,他是市场轮廓的创始人,其著作以及CBOT早期关于拍卖市场理论的教育资料都有详细阐述。
MQL4实现中的陷阱
这里是大多数开发者容易忽视的第一个重大"坑"。在MQL4中使用
iVolume(NULL, 0, i) 时,获取的是该K线的总Tick数。这没问题。但标准MQL4中用于构建自定义指标的 iCustom() 函数,在处理复杂计算时以极其缓慢和笨拙著称。我们不打算构建一个指标然后再调用它。我们将在EA的
OnTick() 或 OnTimer() 内部完成所有分布计算,并将分布存储在动态数组中。这能让我们完全掌控过程,并避免自定义指标缓冲区带来的性能瓶颈。这是一个计算POC的朴素方法:
``
mql4
//+------------------------------------------------------------------+
//| 计算POC和价值区间 |
//+------------------------------------------------------------------+
void CalculateProfile(int start_bar, int end_bar, double &levels[], double &volumes[]) {
ArrayResize(levels, 0);
ArrayResize(volumes, 0);
// 步骤1:收集唯一价格水平及其成交量
for(int bar = start_bar; bar >= end_bar; bar--) {
double price = (High[bar] + Low[bar]) / 2.0; // 用均价作为代表水平
// 警告:这是软肋。使用(H+L)/2是一种过度简化。
// 它无法捕捉K线内部的波动范围。
int index = -1;
for(int i = 0; i < ArraySize(levels); i++) {
if(MathAbs(levels[i] - price) < Point 0.5) {
index = i;
break;
}
}
if(index == -1) {
ArrayResize(levels, ArraySize(levels) + 1);
ArrayResize(volumes, ArraySize(volumes) + 1);
levels[ArraySize(levels)-1] = price;
volumes[ArraySize(volumes)-1] = Volume[bar];
} else {
volumes[index] += Volume[bar];
}
}
// 步骤2:找到POC(成交量最大的索引)
int poc_index = -1;
double max_vol = 0;
for(int i = 0; i < ArraySize(volumes); i++) {
if(volumes[i] > max_vol) {
max_vol = volumes[i];
poc_index = i;
}
}
// POC 位于 levels[poc_index]
}
`
看出问题了吗?我们用的是 (High + Low) / 2。这是个致命缺陷。一个波动100点的K线和一个波动5点的K线被简化为一个点。这等于说所有成交量都发生在中点,这完全是胡说八道。这会彻底扭曲整个分布形态。
Tick数据的替代方案:成交量分配
我们没有Tick数据,但可以做一个合理的推测。与其把全部成交量分配给中点,不如将其分散到K线的整个波动范围内,基于一个简单的假设:成交量更多地偏向于K线收盘价附近*。这是基于观察到的机构活动常在收盘前后加剧的现象。
这就是所谓的成交量加权平均价格(VWAP)分配,但应用在了分布上。它仍然是一种估计,但比中点法更好。
`mql4
//+------------------------------------------------------------------+
//| 改进的成交量分配方法 |
//+------------------------------------------------------------------+
void DistributeVolume(int bar, double &levels[], double &volumes[]) {
double high = High[bar];
double low = Low[bar];
double close = Close[bar];
double vol = Volume[bar];
// 定义一个简单的三角分布,60%的成交量集中在收盘价附近
double range = high - low;
if(range == 0) return;
// 在K线内部创建5个子级别
double sub_levels[5];
double weights[5];
// 将权重重心放在收盘价附近
for(int i = 0; i < 5; i++) {
double weight_factor = (i == 2) ? 0.4 : 0.15; // 中心40%,边缘各15%
weights[i] = weight_factor;
// 将子级别从低到高排列,但向收盘价倾斜
double position = (double)i / 4.0; // 0.0 到 1.0
// 调整位置使其更接近收盘价
double close_pos = (close - low) / range;
double adjusted_pos = position close_pos + (1 - position) 0.5; // 混合
sub_levels[i] = low + adjusted_pos range;
}
// 将分配后的成交量加入主分布数组
for(int i = 0; i < 5; i++) {
double price_level = NormalizeDouble(sub_levels[i], Digits);
double vol_fraction = vol weights[i];
// 查找或创建水平
int idx = -1;
for(int j = 0; j < ArraySize(levels); j++) {
if(MathAbs(levels[j] - price_level) < Point * 0.1) {
idx = j;
break;
}
}
if(idx == -1) {
ArrayResize(levels, ArraySize(levels) + 1);
ArrayResize(volumes, ArraySize(volumes) + 1);
levels[ArraySize(levels)-1] = price_level;
volumes[ArraySize(volumes)-1] = vol_fraction;
} else {
volumes[idx] += vol_fraction;
}
}
}
`
这是一个巨大的进步。它并不完美,但在无法获取Tick数据时,这是许多专业零售量化交易者使用的务实方案。
圣杯:伪Delta背离
接下来是真正的干货。在专业订单流中,"Delta"是指在每个价格水平上买入量与卖出量的差值。我们无法直接测量这个值。
但我们可以计算一个伪Delta。逻辑如下:
如果一根K线收在中点之上,我们假设买方力量占优。将该K线的成交量标记为"买入量"。
如果一根K线收在中点之下,我们假设卖方力量占优。将其标记为"卖出量"。
然后,对于分布中的每个价格水平,我们汇总触及该水平的所有K线的买入量和卖出量。其差值就是我们的伪Delta。
为什么这很有威力?当价格接近历史POC水平,但该水平的伪Delta显示出背离(例如,价格接近POC,但伪Delta变得越来越负),这表明"聪明钱"并未捍卫该水平,即将向下破位。
这是我的核心优势所在。这是一个我在别处未见发表的原创过滤器。多数零售交易者只看价格相对于POC的位置。我则关注成交量动量相对于POC的表现。
`mql4
//+------------------------------------------------------------------+
//| 计算伪Delta |
//+------------------------------------------------------------------+
void CalcPseudoDelta(int start_bar, int end_bar) {
ArrayResize(delta_levels, 0);
ArrayResize(delta_values, 0);
for(int bar = start_bar; bar >= end_bar; bar--) {
double mid = (High[bar] + Low[bar]) / 2.0;
bool is_buy = (Close[bar] > mid); // 简化假设
double vol = Volume[bar];
// 将该成交量分配到K线范围内(类似于DistributeVolume)
// ... 并加入delta_levels,买入为正,卖出为负。
}
// 聚合后,某水平的delta_values代表该价格上的净成交量。
}
`
完整的EA框架
下面是如何将所有内容整合到一个可用的EA中。该EA按每日定时器运行,计算前一日时段的分布,然后根据当前价格相对于VAH、VAL和POC的位置进行交易,同时用伪Delta背离作为过滤条件。
`mql4
//+------------------------------------------------------------------+
//| VolumeEA.mq4 |
//+------------------------------------------------------------------+
#property copyright "FXEAR.com"
#property link "https://www.fxear.com"
#property version "1.00"
extern int ProfileBars = 100; // 构建分布的K线数量
extern double RiskPercent = 1.0; // 每笔交易风险
extern int MagicNumber = 202404;
double POC_Level = 0;
double VAH_Level = 0;
double VAL_Level = 0;
double PseudoDelta_POC = 0;
//+------------------------------------------------------------------+
//| 专家初始化函数 |
//+------------------------------------------------------------------+
int OnInit() {
return(INIT_SUCCEEDED);
}
//+------------------------------------------------------------------+
//| 专家Tick函数 |
//+------------------------------------------------------------------+
void OnTick() {
static datetime last_calc = 0;
// 每小时重新计算一次分布以节省CPU
if(TimeCurrent() - last_calc >= 3600) {
CalculateDailyProfile();
last_calc = TimeCurrent();
}
// 检查入场信号
if(!IsNewBar()) return;
double current_price = Ask;
bool is_above_VAH = (current_price > VAH_Level);
bool is_below_VAL = (current_price < VAL_Level);
bool near_POC = (MathAbs(current_price - POC_Level) < (VAH_Level - VAL_Level) 0.2);
// 独有信号:POC处的伪Delta背离
if(near_POC) {
// 获取当前POC水平的伪Delta值(在分布中计算)
double current_delta = GetDeltaAtLevel(POC_Level);
// 如果伪Delta显著为负且价格在POC之上,则为卖出信号
if(current_delta < -Volume[0] 0.3 && current_price > POC_Level) {
OpenSell();
}
// 如果伪Delta显著为正且价格在POC之下,则为买入信号
if(current_delta > Volume[0] * 0.3 && current_price < POC_Level) {
OpenBuy();
}
}
// 传统突破信号(加入Delta确认)
if(is_above_VAH && GetDeltaAtLevel(VAH_Level) > 0) {
// 突破VAH且Delta为正 = 强势突破
OpenBuy();
}
if(is_below_VAL && GetDeltaAtLevel(VAL_Level) < 0) {
OpenSell();
}
}
//+------------------------------------------------------------------+
//| 计算每日分布 |
//+------------------------------------------------------------------+
void CalculateDailyProfile() {
// 此函数填充POC_Level, VAH_Level, VAL_Level
// 以及delta数组,使用上述逻辑。
// 为简洁起见,实现细节省略,但遵循上述代码结构。
}
`
隐藏的杀手:非标准交易时段
这是一个会搞垮你EA的技术细节,如果你忽略它的话。分布计算假定了一个固定时段(例如,"每日"时段从00:00到23:59)。但如果你在像外汇这样24/5的市场交易,"时段"的概念是模糊的。如果你在服务器时间00:00重新计算分布,你会把亚洲、伦敦和纽约时段(如果它们落在这个时间段内)混在一起。
我的解决方案: 不要在午夜重新计算,而是基于成交量间隔来重新计算。找出1小时成交量持续降至最低的时间段(通常是GMT时间22:00-23:00左右)。将其作为你的"时段边界"。这能动态定义分布周期,使你的EA在不同服务器时间的经纪商之间更具稳健性。
参考来源: 这种方法与国际清算银行(BIS)季度评论中讨论的概念一致,该评论强调了外汇三大主要交易时段的鲜明特征。
关于性能的一点说明
如果不小心,所有这些在 OnTick() 中的动态数组调整会拖垮你的CPU。在上面的代码中,我们每小时重新计算一次。这没问题。但 DistributeVolume 函数会循环遍历数组,如果不优化,复杂度可能达到O(n^2)。
优化技巧: 每次计算后对 levels 数组进行排序。这样你就可以使用二分查找来查找或创建水平,而不是线性查找。这将复杂度从O(n^2)降低到O(n log n)。这里给出一个快速但不优雅的排序方法:
`mql4
void SortLevels(double &levels[], double &volumes[]) {
// 简单冒泡排序,仅作演示
int size = ArraySize(levels);
for(int i = 0; i < size - 1; i++) {
for(int j = 0; j < size - i - 1; j++) {
if(levels[j] > levels[j + 1]) {
double temp_level = levels[j];
double temp_vol = volumes[j];
levels[j] = levels[j + 1];
volumes[j] = volumes[j + 1];
levels[j + 1] = temp_level;
volumes[j + 1] = temp_vol;
}
}
}
}
`
总结
这个EA不是一个"设置好就不管"的黑盒系统。它是一个需要解读的框架。伪Delta背离过滤器是我对这个领域的原创贡献。它不是圣杯,但它能给你一个概率上的优势。你不再仅仅盯着价格;你看到的是价格变动背后的信念强度。
代码只是一个起点。你需要根据具体交易品种的波动性来调整分配模型、时段边界和Delta阈值。但一旦你完成了这些调整,你会发现MQL4中那个不起眼的 iVolume()` 函数,是零售交易者武器库中最被低估的武器之一。本文首发于FXEAR.com,原创内容,未经授权禁止转载。