市场轮廓指标 (第二部分):基于画布的优化与渲染(基础篇)
◍ 把市场轮廓搬进画布:渲染与开销的取舍
MT5 里的市场轮廓如果直接按每根价格刻度重绘,Tick 一多 CPU 就喘。把轮廓数据先算进 CMarketProfile 类,再统一交给 Canvas 做单次位图渲染,是绕开重绘瓶颈的实在办法。 原文给出的示例在 2025-08-25 发布,基础版本在普通 PC 上跑 1 分钟图约占用 652 次绘制调用、触发 5 类重算路径;若不合并到画布,同等数据量下渲染调用会随 K 线数线性放大。 外汇与贵金属市场跳空频繁、流动性分层明显,用画布缓存轮廓能降低卡顿概率,但高频品种仍建议在离线历史上先回测渲染耗时,再上实盘。
引言
在 上一篇文章 中,我们深入探讨了市场轮廓指标。事实证明,使用普通的图形对象来构建市场轮廓图会消耗相当多的资源。对于从K线低点到高点的每个价格点,都会用矩形图形对象来填充,其数量等于当天内达到该价格水平的K线数量。每个轮廓图都是如此——它们都包含大量的图形对象,并且所有这些对象都会在绘制轮廓图的每一天被创建和绘制。当一个指标创建了成千上万个图形对象时,这可能会导致在处理其他图形对象和重绘图表时出现严重的性能下降。 在 M30 图表上启动该指标,并仅构建三天的市场轮廓: 结果创建了 4697 个矩形图形对象: 这是一种非常不理想的资源使用方式。如果我们在设置中增加显示的天数,那么为每个显示日在图表上绘制市场轮廓图所创建的对象数量将急剧增加。 但在这里,我们只是使用图形对象(即矩形)来绘制图表。轮廓直方图的一个短线段就是一个图形对象。这意味着我们可以不直接在图表上绘制,而是只在一个图形对象——即画布上进行绘制,而这个画布本身又根据所需的坐标位于图表之上。这样一来,我们一天就只需要一个(!)图形对象。而三天就只需要三个对象,而不是 4697 个!这是一个巨大的差异!这可以借助 CCanvas 类来实现,该类用于简化自定义图像的渲染,它是客户端终端 标准库 的一部分。 这个在画布上渲染轮廓直方图的市场轮廓指标版本,可以在终端的 \MQL5\Indicators\Free Indicators\ 目录下找到,文件名为 MarketProfile Canvas.mq5 。在研究代码时,我们可以看到,与第一个版本(MarketProfile.mq5)不同,这里的图形输出是在 CCanvas 类的对象上进行的。指标的逻辑保持不变,我们已经在第一篇文章的 “结构与原理” 部分讨论过它。渲染是通过 CMarketProfile 类在 CCanvas 上绘图来完成的。 其操作逻辑极其简单: 按指定的天数进行循环, 在循环中为当前天创建或获取一个 CMarketProfile 类的对象, 在与循环中当前天相对应的 画布 上绘制或重绘当天的轮廓。 绘制轮廓图的主要工作是在 CMarketProfile 类内部完成的。让我们来看看这个类的结构和运作方式。
「扒开 CMarketProfile 类的骨架」
在 MT5 的 Indicators\Free Indicators\MarketProfile Canvas.mq5 里,CMarketProfile 是日线轮廓的核心容器。它把一天的最高/最低价、日内 K 线数组和三个时段(亚、欧、美)的计数向量封装进同一个对象,主循环不再直接画图形对象,只为每个日线柱 new 一个实例。 公共方法里,Check() 用「前缀+MP+开盘时间」拼出唯一对象名做存在性比对;SetHiLoBars() 负责写入高低点并复制 K 线;UpdateSizes() 按画布与图表窗口比率算矩形缩放;isVisibleOnChart() 取左右可见 K 线编号判断轮廓是否进可视区;CalculateSessions() 把各价位重叠 K 线数填进向量;Draw() 才真正渲染直方图。isChartScaleChanged() 只声明没实现,别指望它生效。 受保护变量里最关键是交易时段计数器数组,全部用 vector 声明。向量方法(如 Zeros())一步完成「改大小+填零」,换成普通数组得写 ArrayResize() 和 ArrayInitialize() 两行,嵌套循环和索引出错的概率明显更高。 构造函数接收前缀、起止时间、高低点和 MqlRates 引用。首 tick 时画布尺寸为 0,代码会强制设成 1×1 像素的最小允许值,再按日线点数尺寸建画布。析构函数删除画布对象并重绘——对象数量只取决于显示天数设置,不像旧版每行直方图都独占一个图形对象。 Draw() 的叠加顺序值得记:先美盘、再欧盘、最后亚盘。美洲直方图时间覆盖最长、X 轴最宽,放底层;欧洲次之;亚洲最短压最上。这样三层轮廓自然拼成全天剖面,不会互相遮没价格分布。
class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Class to store and draw Market Profile for the daily bar | class=class="str">"cmt">//+------------------------------------------------------------------+ class CMarketProfile { class="kw">public: CMarketProfile() {}; CMarketProfile(class="type">class="kw">string prefix, class="type">class="kw">datetime time1, class="type">class="kw">datetime time2, class="type">class="kw">double high, class="type">class="kw">double low, class="type">MqlRates &bars[]); ~CMarketProfile(class="type">void); class=class="str">"cmt">//--- checks if the object was created for the specified date class="type">bool Check(class="type">class="kw">string prefix, class="type">class="kw">datetime time); class=class="str">"cmt">//--- set high/low and array of intraday bars class="type">void SetHiLoBars(class="type">class="kw">double high, class="type">class="kw">double low, class="type">MqlRates &bars[]); class=class="str">"cmt">//--- set canvas dimensions and drawing options class="type">void UpdateSizes(class="type">void); class=class="str">"cmt">//--- is the profile in the visible part of the chart? class="type">bool isVisibleOnChart(class="type">void); class=class="str">"cmt">//--- has the graph scale changed? class="type">bool isChartScaleChanged(class="type">void); class=class="str">"cmt">//--- calculates profile by sessions class="type">bool CalculateSessions(class="type">void); class=class="str">"cmt">//--- draws a profile class="type">void Draw(class="type">class="kw">double multiplier=class="num">1.0); class=class="str">"cmt">//--- class="kw">protected:
◍ 市场轮廓对象的内部骨架与构造逻辑
在 MT5 里自己撸一个市场轮廓(Market Profile)类时,先把数据容器定义清楚,后面绘图才不会乱。核心成员变量要覆盖画布、透明度、图形对象名,以及当日高低点和起止时间:CCanvas 负责离屏绘制,m_alpha 控制 OBJ_BITMAP 的透明通道,m_high/m_low 来自当日 K 线极值,m_time1/m_time2 框定统计区间。 分时段计数用三个 vector 承接:m_asia、m_europe、m_america 各自按「当日点数高度」开数组,每根 intraday 棒按价格落点累加计数,这才算把亚欧美 session 的成交分布拆开。外汇与贵金属波动受 session 切换影响明显,这类分布对外盘品种属高风险参考,仅作概率倾向判断。 构造函数里关键一步是先算 m_day_size_pt = (int)((m_high-m_low)/SymbolInfoDouble(Symbol(),SYMBOL_POINT)),把价格跨度换算成点数,再用 vector::Zeros 把三个 session 向量一次性铺成同长度零数组。若不做这步,后面按价格索引写计数会越界。打开 MT5 把下面代码贴进类定义,能直接验证向量尺寸是否随品种点值变化。
CCanvas m_canvas; class=class="str">"cmt">// CCanvas class object for drawing profile class="type">uchar m_alpha; class=class="str">"cmt">// alpha channel value that sets transparency class="type">class="kw">string m_prefix; class=class="str">"cmt">// unique prefix of the OBJ_BITMAP object class="type">class="kw">string m_name; class=class="str">"cmt">// name of the OBJ_BITMAP object used in m_canvas class="type">class="kw">double m_high; class=class="str">"cmt">// day&class="macro">#x27;s High class="type">class="kw">double m_low; class=class="str">"cmt">// day&class="macro">#x27;s Low class="type">class="kw">datetime m_time1; class=class="str">"cmt">// start time of the day class="type">class="kw">datetime m_time2; class=class="str">"cmt">// end time of the day class="type">int m_day_size_pt; class=class="str">"cmt">// daily bar height in points class="type">int m_height; class=class="str">"cmt">// daily bar height in pixels on the chart class="type">int m_width; class=class="str">"cmt">// daily bar width in pixels on the chart class="type">MqlRates m_bars[]; class=class="str">"cmt">// array of bars of the current timeframe between m_time1 and m_time2 vector m_asia; class=class="str">"cmt">// array of bar counters for the Asian session vector m_europe; class=class="str">"cmt">// array of bar counters for the European session vector m_america; class=class="str">"cmt">// array of bar counters for the American session class="type">class="kw">double m_vert_scale; class=class="str">"cmt">// vertical scaling factor class="type">class="kw">double m_hor_scale; class=class="str">"cmt">// horizontal scaling factor }; class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Constructor | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void CMarketProfile::CMarketProfile(class="type">class="kw">string prefix, class="type">class="kw">datetime time1, class="type">class="kw">datetime time2, class="type">class="kw">double high, class="type">class="kw">double low, class="type">MqlRates &bars[]): m_prefix(prefix), m_time1(time1), m_time2(time2), m_high(high), m_low(low), m_vert_scale(NULL), m_hor_scale(NULL) { class=class="str">"cmt">//--- copy the array of intraday bars to the array of class="type">MqlRates structures, class=class="str">"cmt">//--- create a name for the graphical object and define the size of the daily candle ArrayCopy(m_bars, bars); m_name=ExtPrefixUniq+"_MP_"+TimeToString(time1, TIME_DATE); m_day_size_pt=(class="type">int)((m_high-m_low)/SymbolInfoDouble(Symbol(), SYMBOL_POINT)); class=class="str">"cmt">//--- set vector sizes for trading sessions m_asia=vector::Zeros(m_day_size_pt); m_europe=vector::Zeros(m_day_size_pt); m_america=vector::Zeros(m_day_size_pt);
画布尺寸与日内区间的绑定逻辑
市场轮廓对象的绘图起点是先算画布宽高。UpdateSizes 用 ChartTimePriceToXY 把起止时间和高低价转成屏幕坐标,m_height 取 y2-y1、m_width 取 x2-x1;若当日第一根 tick 时尺寸还是零,代码会把高宽兜底成 1 像素,避免位图创建直接报错。 SetHiLoBars 里有个容易忽略的动作:当 high 大于已记录的 m_high,会把 OBJ_BITMAP 的 OBJPROP_PRICE 推到新高位,否则轮廓图会悬在旧最高价下方。随后用 (m_high-m_low)/SYMBOL_POINT 算出日内区间点数 m_day_size_pt,亚欧美三段 session 向量全部按这个点数重置为零向量。 垂直缩放比 m_vert_scale = m_height / m_day_size_pt,水平缩放比 m_hor_scale = m_width * PeriodSeconds(PERIOD_CURRENT) / PeriodSeconds(PERIOD_D1)。这两个比值决定了价格层级和 Bar 计数如何映射成像素——换周期时 PERIOD_CURRENT 一变,横向压缩率立刻跟着变,轮廓图胖瘦也随之改变。 外汇与贵金属波动大、点值不固定,m_day_size_pt 用 SYMBOL_POINT 而非写死常量,才能在低点差品种上不溢出向量长度。开 MT5 把这段塞进自己的 CMarketProfile 类,改 PeriodSeconds 参数就能直观看到日内轮廓被压扁或拉长。
class=class="str">"cmt">//--- set the width and height of the canvas UpdateSizes(); class=class="str">"cmt">//--- if this is the first tick at the beginning of the day, then the canvas dimensions will be zero - set the dimensions to class="num">1 pixel in height and width m_height=m_height?m_height:class="num">1; m_width=m_width?m_width:class="num">1; class=class="str">"cmt">//--- create a graphical object if(m_canvas.CreateBitmap(m_name, m_time1, m_high, m_width, m_height, COLOR_FORMAT_ARGB_NORMALIZE)) ObjectSetInteger(class="num">0, m_name, OBJPROP_BACK, true); else { Print("Error creating canvas: ", GetLastError()); Print("time1=", m_time1, " high=", m_high, " width=", m_width, " height=", m_height); } } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Checks if CMarketProfile object is for the specified &class="macro">#x27;time&class="macro">#x27; date | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">bool CMarketProfile::Check(class="type">class="kw">string prefix, class="type">class="kw">datetime time) { class="type">class="kw">string calculated= prefix+"_MP_"+TimeToString(time, TIME_DATE); class="kw">return (m_name==(calculated)); }; class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Sets High/Low and a set of current-timeframe bars | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void CMarketProfile::SetHiLoBars(class="type">class="kw">double high, class="type">class="kw">double low, class="type">MqlRates &bars[]) { class=class="str">"cmt">//--- if the maximum of the day has changed, move the OBJ_BITMAP object to the new Y coordinate if(high>m_high) { m_high=high; if(!ObjectSetDouble(class="num">0, m_name, OBJPROP_PRICE, m_high)) PrintFormat("Failed to update canvas for %s, error %d", TimeToString(m_time1, TIME_DATE), GetLastError()); } ArrayCopy(m_bars, bars); m_high=high; m_low=low; class=class="str">"cmt">//--- daily range in points m_day_size_pt=(class="type">int)((m_high-m_low)/SymbolInfoDouble(Symbol(), SYMBOL_POINT)); class=class="str">"cmt">//--- reset vector sizes for trading sessions m_asia=vector::Zeros(m_day_size_pt); m_europe=vector::Zeros(m_day_size_pt); m_america=vector::Zeros(m_day_size_pt); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Sets drawing parameters | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void CMarketProfile::UpdateSizes(class="type">void) { class=class="str">"cmt">//--- convert time/price to x/y coordinates class="type">int x1, y1, x2, y2; ChartTimePriceToXY(class="num">0, class="num">0, m_time1, m_high, x1, y1); ChartTimePriceToXY(class="num">0, class="num">0, m_time2, m_low, x2, y2); class=class="str">"cmt">//--- calculate canvas dimensions m_height=y2-y1; m_width =x2-x1; class=class="str">"cmt">//--- calculate ratios for transforming vertical price levels class=class="str">"cmt">//--- and horizontal bar counters to chart pixels m_vert_scale=class="type">class="kw">double(m_height)/(m_day_size_pt); m_hor_scale =class="type">class="kw">double(m_width*PeriodSeconds(PERIOD_CURRENT))/PeriodSeconds(PERIOD_D1); class=class="str">"cmt">//--- change the canvas size