preview
Создаем объемные 3D бары на MQL5

Создаем объемные 3D бары на MQL5

MetaTrader 5Индикаторы |
68 0
Yevgeniy Koshtenko
Yevgeniy Koshtenko

Введение

Около года назад в статье «Создаем 3D-бары на основе времени, цены и объема» я построил трёхмерное представление рынка на Python. Связка MetaTrader 5 и plotly выгружала котировки, считала объёмный профиль и рисовала интерактивную сцену. По трём осям в ней откладывались время, цена и объём. Подход работал и дал интересные наблюдения — те самые жёлтые объёмно-волатильные кластеры перед разворотами. Но у него была встроенная цена: сцена жила вне терминала. Нужен был Python-мост, выгрузка истории, отдельный процесс, а результат открывался в браузере как статичный снимок, оторванный от живого графика.

В этой статье мы возвращаем идею в MetaTrader 5. Сцена строится на CCanvas3D и DirectX 11, без внешних зависимостей. Она живёт поверх графика, вращается мышью, приближается колесом и перестраивается по кнопке на свежей истории. Ни Python, ни plotly, ни браузер — только штатные средства платформы.

В этой статье мы переносим базовые оси 3D-баров — время, цену и объём — и разбираем их реализацию до деталей. Цена ложится по вертикали, время уходит вглубь сцены, а третья горизонтальная ось отдаётся тиковому объёму. Бар перестаёт быть свечой и становится параллелепипедом: высота — это диапазон High-Low, длина по третьей оси — объём бара, цвет — направление. Ось волатильности и её связь с кластерами разворотов из Python-версии мы рассмотрим в следующей статье.

В статье последовательно рассматривается:

  • что переносится из Python-версии в нативный MQL5 и зачем
  • DirectX-сцена в MQL5: класс CCanvas3D, инициализация, проекция и освещение
  • построение геометрии вручную: вершина, грань, параллелепипед
  • загрузка истории и нормализация цены, объёма и времени в пространство сцены
  • орбитальная камера, управление мышью и цикл рендеринга
  • как читать сцену тикового объёма и куда её развивать




Что переносим из Python в нативный MQL5

В Python-версии каждый 3D-бар был объектом, который собирал профиль объёма по тикам, считал импульс и волатильность, а затем нормализовался в общий масштаб. Plotly раскладывал точки по трём осям: номер бара по времени, объём и цена. Сцена получалась богатой, но статичной — это был HTML-файл, который открывался отдельно от терминала и не обновлялся вместе с рынком.

Концептуально мы сохраняем ту же тройку осей, но меняем всё под капотом. Вместо выгрузки в pandas — прямой вызов CopyRates. Вместо MinMaxScaler — ручная нормализация в координаты сцены. Вместо точек plotly — настоящие трёхмерные тела, собранные из вершин и треугольников. И главное: вместо статичного снимка в браузере — живая интерактивная сцена прямо на графике, которую можно крутить и перестраивать в реальном времени.

Соответствие осей почти прямое. Там, где в Python по горизонтали шёл номер бара, у нас вглубь сцены уходит ось времени Z. Где в plotly по одной оси откладывался объём, у нас он становится длиной бара по оси X. Цена в обоих случаях — вертикаль. Разница в том, что теперь это не разбросанные точки, а сплошная стена баров, в которой видна форма и рельеф.



DirectX-сцена в MQL5: класс CCanvas3D

Программа — эксперт, а не индикатор. Это даёт полный контроль над мышью, таймером и графическими объектами. CCanvas3D создаётся один раз и используется на всём протяжении работы. Эксперт здесь не торгует — он рисует, и DirectX-холст ему нужен как полотно, а не как источник сигналов.

Вся 3D-функциональность подключается тремя заголовочными файлами из стандартной поставки терминала. На всю сцену приходится один-единственный меш CDXMesh: и оси, и все бары — это одна геометрия в общих массивах вершин и индексов. Это принципиально для производительности — один меш означает один вызов отрисовки на GPU.

//--- три заголовка дают всю 3D-функциональность терминала
#include <Canvas\Canvas3D.mqh>
#include <Canvas\DX\DXMath.mqh>
#include <Canvas\DX\DXMesh.mqh>

input int InpBarsToShow = 80;   // число рыночных баров в 3D-сцене

CCanvas3D canvas;   // холст с аппаратным DirectX-контекстом
CDXMesh   mesh;     // единый меш на всю сцену: оси и все бары сразу



Инициализация 3D-сцены

Вся настройка трёхмерного контекста умещается в несколько строк OnInit. Холст создаётся как растровый объект графика фиксированного размера, после чего задаётся перспективная проекция и параметры освещения. Без освещения грани параллелепипеда были бы залиты одним плоским цветом и куб выглядел бы силуэтом — именно направленный свет в связке с нормалями граней даёт объём.

//+------------------------------------------------------------------+
//| Expert initialization                                            |
//+------------------------------------------------------------------+
int OnInit()
  {
   //--- включаем события мыши и колеса для управления камерой
   ChartSetInteger(0, CHART_EVENT_MOUSE_MOVE, 1);
   ChartSetInteger(0, CHART_EVENT_MOUSE_WHEEL, 1);
   EventSetMillisecondTimer(250);   // таймер для отложенной пересборки сцены

   //--- создаём DirectX-холст как растровый объект графика
   if(!canvas.CreateBitmapLabel("DX_3D_Bars", 20, 20, CANVAS_WIDTH, CANVAS_HEIGHT, COLOR_FORMAT_ARGB_NORMALIZE))
     {
      Print("Canvas create error: ", GetLastError());
      return(INIT_FAILED);
     }

   //--- перспектива: угол обзора 30°, соотношение сторон холста, ближняя/дальняя плоскости
   canvas.ProjectionMatrixSet((float)DX_PI / 6, (float)CANVAS_WIDTH / (float)CANVAS_HEIGHT, 0.1f, 80.0f);

   //--- тёплый направленный свет даёт объём граням, холодная подсветка смягчает тени
   canvas.LightColorSet(DXColor(1.0f, 0.96f, 0.86f, 1.0f));
   canvas.LightDirectionSet(DXVector3(0.45f, -0.85f, 0.35f));
   canvas.AmbientColorSet(DXColor(0.46f, 0.48f, 0.52f, 1.0f));

   CreateUi();          // плоский интерфейс поверх холста
   BuildMarketBars();   // первичная сборка геометрии сцены
   UpdateCamera();
   RedrawScene();
   return(INIT_SUCCEEDED);
  }

Угол обзора DX_PI/6 — это 30 градусов, достаточно узкий конус, чтобы перспективные искажения не «разваливали» дальние бары. Ближняя плоскость отсечения стоит на 0.1, дальняя на 80 — наша сцена целиком влезает в этот диапазон по глубине. Цвет направленного света сделан тёплым, а ambient — нейтрально-холодным: освещённые грани слегка золотистые, затенённые уходят в серо-голубой, и куб читается объёмно.



Система координат сцены

Прежде чем класть бары, фиксируется система координат. Она задаётся набором констант: точка отсчёта ORIGIN и длины трёх осей. Сцена живёт в кубическом пространстве примерно от −3 до +6 единиц, центрированном перед камерой. Ось времени самая длинная, потому что вдоль неё выстраиваются десятки баров, и им нужно место, чтобы не слипаться.

//--- начало координат сцены в единицах DirectX
const float ORIGIN_X = -3.2f;
const float ORIGIN_Y = -3.0f;
const float ORIGIN_Z = -6.2f;

//--- длины трёх осей сцены
const float VOLUME_AXIS_LENGTH = 6.4f;    // ось X — тиковый объём
const float PRICE_AXIS_LENGTH  = 6.0f;    // ось Y — цена High-Low
const float TIME_AXIS_LENGTH   = 12.4f;   // ось Z — время, самая длинная



Геометрия: вершина, грань, параллелепипед

В Python-версии бар был точкой в пространстве plotly — за отрисовку отвечала библиотека. Здесь библиотеки нет, и тело бара мы собираем сами. DirectX не знает, что такое «параллелепипед»; он знает только треугольники, заданные вершинами и индексами. Поэтому геометрия строится снизу вверх: вершина → грань → коробка. Вершина описывается структурой DXVertex; нам важны четыре её поля — положение, нормаль (куда «смотрит» поверхность, это нужно освещению), цвет и текстурные координаты.

//+------------------------------------------------------------------+
//| Fill vertex                                                      |
//+------------------------------------------------------------------+
void SetVertex(DXVertex &vertex, const float x, const float y, const float z,
               const float nx, const float ny, const float nz, const DXColor &clr,
               const float u, const float v)
  {
   vertex.position = DXVector4(x, y, z, 1.0f);   // положение вершины в пространстве сцены
   vertex.vcolor   = clr;                       // цвет вершины (зелёный/красный бар)
   vertex.normal   = DXVector4(nx, ny, nz, 0.0f); // нормаль грани для расчёта освещения
   vertex.tcoord   = DXVector2(u, v);             // текстурные координаты, текстуру не используем
  }

Одна квадратная грань — это четыре вершины и два треугольника. Все четыре вершины грани получают одну нормаль, потому что плоский четырёхугольник смотрит в одну сторону. Индексы описывают, как из четырёх вершин собрать два треугольника: 0-1-2 и 0-2-3.

//+------------------------------------------------------------------+
//| Add one quad face                                                |
//+------------------------------------------------------------------+
void AddFace(DXVertex &vertices[], uint &indices[], int &vertex_count, int &index_count,
             const float x0, const float y0, const float z0,
             const float x1, const float y1, const float z1,
             const float x2, const float y2, const float z2,
             const float x3, const float y3, const float z3,
             const float nx, const float ny, const float nz, const DXColor &clr)
  {
   int base = vertex_count;
   ArrayResize(vertices, vertex_count + 4);   // четыре новые вершины на грань
   ArrayResize(indices,  index_count  + 6);   // шесть индексов = два треугольника

   //--- четыре угла грани с общей нормалью
   SetVertex(vertices[base],   x0, y0, z0, nx, ny, nz, clr, 0.0f, 0.0f);
   SetVertex(vertices[base + 1], x1, y1, z1, nx, ny, nz, clr, 1.0f, 0.0f);
   SetVertex(vertices[base + 2], x2, y2, z2, nx, ny, nz, clr, 1.0f, 1.0f);
   SetVertex(vertices[base + 3], x3, y3, z3, nx, ny, nz, clr, 0.0f, 1.0f);

   //--- два треугольника на квадрат: 0-1-2 и 0-2-3
   indices[index_count++] = (uint)base;
   indices[index_count++] = (uint)(base + 1);
   indices[index_count++] = (uint)(base + 2);
   indices[index_count++] = (uint)base;
   indices[index_count++] = (uint)(base + 2);
   indices[index_count++] = (uint)(base + 3);
   vertex_count += 4;
  }

Коробка AddBox — это шесть граней. Функция принимает два противоположных угла параллелепипеда и собирает переднюю, заднюю, правую, левую, верхнюю и нижнюю грани. У каждой грани своя нормаль: передняя смотрит в +Z, задняя в −Z, и так далее. Сортировка координат через MathMin/MathMax в начале — защита от того, что углы передадут в любом порядке.

//+------------------------------------------------------------------+
//| Add a rectangular prism                                          |
//+------------------------------------------------------------------+
void AddBox(DXVertex &vertices[], uint &indices[], int &vertex_count, int &index_count,
           const float x0, const float x1, const float y0, const float y1, const float z0, const float z1,
           const DXColor &clr)
  {
   //--- упорядочиваем углы, чтобы не зависеть от порядка аргументов
   float xa = MathMin(x0, x1); float xb = MathMax(x0, x1);
   float ya = MathMin(y0, y1); float yb = MathMax(y0, y1);
   float za = MathMin(z0, z1); float zb = MathMax(z0, z1);

   //--- шесть граней куба, каждая со своей нормалью
   AddFace(vertices, indices, vertex_count, index_count, xa,ya,zb, xb,ya,zb, xb,yb,zb, xa,yb,zb,  0,0, 1, clr); // передняя
   AddFace(vertices, indices, vertex_count, index_count, xb,ya,za, xa,ya,za, xa,yb,za, xb,yb,za,  0,0,-1, clr); // задняя
   AddFace(vertices, indices, vertex_count, index_count, xb,ya,zb, xb,ya,za, xb,yb,za, xb,yb,zb,  1,0, 0, clr); // правая
   AddFace(vertices, indices, vertex_count, index_count, xa,ya,za, xa,ya,zb, xa,yb,zb, xa,yb,za, -1,0, 0, clr); // левая
   AddFace(vertices, indices, vertex_count, index_count, xa,yb,zb, xb,yb,zb, xb,yb,za, xa,yb,za,  0,1, 0, clr); // верхняя
   AddFace(vertices, indices, vertex_count, index_count, xa,ya,za, xb,ya,za, xb,ya,zb, xa,ya,zb,  0,-1,0, clr); // нижняя
  }

Эти три функции — SetVertex, AddFace, AddBox — образуют весь конструктор геометрии. Всё, что есть в сцене, в итоге сводится к вызовам AddBox: и оси, и засечки времени, и каждый бар.



Оси и засечки времени

Оси рисуются как тонкие длинные коробки. Красная тянется по X (объём), зелёная по Y (цена), синяя по Z (время). На синей оси дополнительно расставляются пять кубических засечек — визуальные якоря для пяти временных меток интерфейса.

//+------------------------------------------------------------------+
//| Add colored axes and time ticks                                  |
//+------------------------------------------------------------------+
void AddAxes(DXVertex &vertices[], uint &indices[], int &vertex_count, int &index_count)
  {
   //--- красная ось объёма вдоль X
   AddBox(vertices, indices, vertex_count, index_count,
          ORIGIN_X, ORIGIN_X + VOLUME_AXIS_LENGTH,
          ORIGIN_Y - AXIS_THICKNESS, ORIGIN_Y + AXIS_THICKNESS,
          ORIGIN_Z - AXIS_THICKNESS, ORIGIN_Z + AXIS_THICKNESS,
          DXColor(0.90f, 0.18f, 0.12f, 1.0f));

   //--- зелёная ось цены вдоль Y и синяя ось времени вдоль Z строятся аналогично
   //--- ...

   //--- пять засечек-кубиков вдоль оси времени как якоря для дат
   for(int i = 0; i < 5; i++)
     {
      float z = ORIGIN_Z + TIME_AXIS_LENGTH * (float)i / 4.0f;
      AddBox(vertices, indices, vertex_count, index_count,
             ORIGIN_X - 0.18f, ORIGIN_X + 0.18f,
             ORIGIN_Y - 0.18f, ORIGIN_Y + 0.18f,
             z - AXIS_THICKNESS, z + AXIS_THICKNESS,
             DXColor(0.05f, 0.16f, 0.72f, 1.0f));
     }
  }



Загрузка истории и нормализация

Сердце программы — BuildMarketBars(). Это нативный аналог Python-пайплайна с copy_rates и MinMaxScaler. Функция копирует историю, находит границы данных и переводит цены, объёмы и время в координаты сцены. Только здесь нормализация ручная и прозрачная: рыночные величины в любых масштабах сжимаются в фиксированный куб. Количество баров жёстко ограничено диапазоном 10…250, чтобы и сцена не пустовала, и геометрия не разрасталась бесконтрольно.

//+------------------------------------------------------------------+
//| Build 3D bars from chart history                                 |
//+------------------------------------------------------------------+
void BuildMarketBars()
  {
   MqlRates rates[];
   int bars_to_copy = MathMax(10, MathMin(InpBarsToShow, 250));   // зажимаем число баров в безопасный диапазон
   int copied = CopyRates(_Symbol, _Period, 0, bars_to_copy, rates);
   if(copied <= 0)
     {
      Print("CopyRates error: ", GetLastError());
      return;
     }

   //--- границы данных за один проход: диапазон цены и максимум объёма
   double min_price = DBL_MAX, max_price = -DBL_MAX;
   long   max_volume = 1;
   for(int i = 0; i < copied; i++)
     {
      min_price  = MathMin(min_price, rates[i].low);
      max_price  = MathMax(max_price, rates[i].high);
      max_volume = (long)MathMax((double)max_volume, (double)rates[i].tick_volume);
     }

   double price_range = max_price - min_price;
   if(price_range <= 0.0)
      price_range = _Point;   // защита от деления на ноль на плоской истории

   //--- направление сортировки массива: раскладываем бары всегда старые -> новые
   bool time_ascending = (rates[0].time <= rates[copied - 1].time);
   UpdateTimeLabels(rates, copied, time_ascending);
   //--- ... далее главный цикл построения баров
  }

Флаг time_ascending важен: CopyRates в зависимости от настроек может вернуть бары как от старых к новым, так и наоборот. Направление определяется один раз, и дальше бары всегда раскладываются по оси Z строго от старых к новым.

Главный цикл превращает каждый бар в коробку. Позиция по Z — порядковый номер на оси времени. Низ и верх по Y — нормализованные low и high. Длина по X — тиковый объём, делённый на максимальный объём в выборке. Цвет — зелёный для бычьего бара, красный для медвежьего.

//--- ширина временного слота и глубина одного бара по оси Z
float slot  = TIME_AXIS_LENGTH / (float)copied;
float depth = MathMax(0.045f, slot * 0.70f);

for(int i = 0; i < copied; i++)
  {
   //--- выбираем исходный бар с учётом направления массива
   int source_index = (time_ascending ? i : copied - 1 - i);
   MqlRates rate = rates[source_index];

   //--- Z: позиция бара во времени, старые -> новые
   float z0 = ORIGIN_Z + (float)i * slot + slot * 0.15f;
   float z1 = z0 + depth;

   //--- Y: диапазон High-Low, нормированный в высоту сцены
   float y0 = ORIGIN_Y + (float)((rate.low  - min_price) / price_range) * PRICE_AXIS_LENGTH;
   float y1 = ORIGIN_Y + (float)((rate.high - min_price) / price_range) * PRICE_AXIS_LENGTH;
   if(y1 - y0 < 0.035f)
      y1 = y0 + 0.035f;   // доджи остаётся видимым тонким срезом

   //--- X: тиковый объём, нормированный в длину оси объёма
   float x1 = ORIGIN_X + (float)((double)rate.tick_volume / (double)max_volume) * VOLUME_AXIS_LENGTH;
   if(x1 - ORIGIN_X < 0.06f)
      x1 = ORIGIN_X + 0.06f;   // бар с нулевым объёмом не исчезает полностью

   //--- цвет по направлению бара: бычий зелёный, медвежий красный
   DXColor bar_color = (rate.close >= rate.open)
                       ? DXColor(0.08f, 0.66f, 0.36f, 1.0f)
                       : DXColor(0.86f, 0.18f, 0.16f, 1.0f);

   AddBox(vertices, indices, vertex_count, index_count, ORIGIN_X, x1, y0, y1, z0, z1, bar_color);
  }

Две страховки в цикле заслуживают внимания. Условие y1 − y0 < 0.035 гарантирует, что даже доджи с почти нулевым диапазоном останется видимым тонким срезом. Аналогично x1 − ORIGIN_X < 0.06 не даёт бару с нулевым объёмом полностью исчезнуть. Без них в сцене появлялись бы дыры.

[Рис. 1. Сцена тикового объёма, вид под углом — вставьте скриншот терминала]



Как читать сцену тикового объёма

В программе DX_3D_Bars_TickVolume.mq5 третьей осью служит tick_volume, нормированный на максимальный объём в выборке. Чем больше тиков прошло за бар — тем длиннее «лежит» его коробка вдоль красной оси. В результате периоды активного рынка визуально выпирают вперёд, а тихие участки прижимаются к плоскости цена-время.

Главная ценность подхода — цена и подтверждающий объём видны одновременно, без переключения внимания. Зелёно-красная стена баров — это привычный график цены, который читается по форме верхней кромки. Но теперь каждый бар ещё и выдаётся вперёд на величину своего объёма, и в профиль сцена превращается в рельеф активности вдоль времени.

Ракурс камеры Какая плоскость видна Что читается
Вид сбоку Цена × Время Классический ценовой график, привычная форма движения
Вид сверху Объём × Время Рельеф активности независимо от направления цены
Промежуточный Все три оси Подтверждён ли ход цены объёмом — видно как форма

Импульсные движения с подтверждающим объёмом выглядят как длинные зелёные или красные клинья, выезжающие из стены. Движения «на пустоте» — высокие по цене, но короткие по объёму бары — это, как правило, ложные проколы. То, что в Python-версии приходилось разглядывать на статичном снимке plotly, здесь читается формой рельефа за одно движение камеры, прямо на живом графике.



Орбитальная камера и управление мышью

Именно интерактивность отличает эту сцену от статичного снимка Python-версии. Камера реализована как орбитальная: она всегда смотрит в центр сцены, а её положение задаётся двумя углами и расстоянием — это классические сферические координаты.

//+------------------------------------------------------------------+
//| Camera                                                           |
//+------------------------------------------------------------------+
void UpdateCamera()
  {
   //--- цель камеры — геометрический центр сцены
   float target_x = ORIGIN_X + VOLUME_AXIS_LENGTH * 0.45f;
   float target_y = ORIGIN_Y + PRICE_AXIS_LENGTH  * 0.45f;
   float target_z = ORIGIN_Z + TIME_AXIS_LENGTH   * 0.50f;

   //--- позиция на сфере радиуса cam_dist вокруг цели
   float x = target_x + (float)(cam_dist * MathSin(cam_angle_y) * MathCos(cam_angle_x));
   float y = target_y + (float)(cam_dist * MathSin(cam_angle_x));
   float z = target_z + (float)(cam_dist * MathCos(cam_angle_y) * MathCos(cam_angle_x));

   canvas.ViewPositionSet(DXVector3(x, y, z));
   canvas.ViewTargetSet(DXVector3(target_x, target_y, target_z));
   canvas.ViewUpDirectionSet(DXVector3(0.0f, 1.0f, 0.0f));
  }

Угол cam_angle_y отвечает за вращение вокруг вертикали (азимут), cam_angle_x — за подъём и спуск (зенит), cam_dist — за дистанцию. Управление вешается на события мыши: перетаскивание с зажатой левой кнопкой меняет углы, колесо меняет дистанцию. Углы и дистанция ограничены, чтобы камера не переворачивалась через полюс и не улетала в бесконечность.

//+------------------------------------------------------------------+
//| Mouse move                                                       |
//+------------------------------------------------------------------+
void OnMouseMove(int x, int y, uint flags)
  {
   if(prev_mouse_x == -1)
     { prev_mouse_x = x; prev_mouse_y = y; return; }   // первый кадр: запоминаем позицию

   if((flags & 1) == 1)   // бит 1 флагов = зажата левая кнопка мыши
     {
      cam_angle_y += (x - prev_mouse_x) / 300.0f;
      cam_angle_x += (y - prev_mouse_y) / 300.0f;

      //--- не даём камере перевалить через полюс
      if(cam_angle_x < -DX_PI_DIV2 * 0.88f) cam_angle_x = -DX_PI_DIV2 * 0.88f;
      if(cam_angle_x >  DX_PI_DIV2 * 0.88f) cam_angle_x =  DX_PI_DIV2 * 0.88f;

      UpdateCamera();
      RedrawScene();
     }
   prev_mouse_x = x; prev_mouse_y = y;
  }

//+------------------------------------------------------------------+
//| Mouse wheel                                                      |
//+------------------------------------------------------------------+
void OnMouseWheel(double delta)
  {
   cam_dist *= 1.0f - (float)delta * 0.001f;
   cam_dist = MathMax(7.0f, MathMin(45.0f, cam_dist));   // зум в разумных пределах
   UpdateCamera();
   RedrawScene();
  }



Рендеринг и пересборка меша

Отрисовка предельно лаконична. RedrawScene() очищает буферы цвета и глубины, рендерит сцену и обновляет холст. Очистка буфера глубины критична — без неё ближние и дальние бары перекрывались бы неправильно.

//+------------------------------------------------------------------+
//| Render                                                           |
//+------------------------------------------------------------------+
void RedrawScene()
  {
   canvas.Render(DX_CLEAR_COLOR | DX_CLEAR_DEPTH, ColorToARGB(clrWhiteSmoke));
   canvas.Update();
   ChartRedraw();
  }

При обновлении меш пересобирается целиком. Старый меш выгружается, создаётся новый из свежих массивов вершин и индексов, и только при самой первой сборке он добавляется в сцену. Приём с флагом mesh_initialized избавляет от дублирования объекта при повторных перестройках.

//--- пересоздание меша из новых массивов геометрии
if(mesh_initialized)
   mesh.Shutdown();   // выгружаем старую геометрию с GPU

if(!mesh.Create(canvas.DXDispatcher(), canvas.InputScene(), vertices, indices))
  {
   Print("Mesh create error: ", GetLastError());
   return;
  }
mesh.DiffuseColorSet(DXColor(1.0f, 1.0f, 1.0f, 1.0f));

//--- добавляем меш в сцену только при первой сборке
if(!mesh_initialized)
  {
   canvas.ObjectAdd(&mesh);
   mesh_initialized = true;
  }

Перестройка вынесена в таймер через флаг need_rebuild, а не выполняется прямо в обработчике клика. Это сознательный приём: тяжёлую операцию пересоздания геометрии не стоит запускать внутри OnChartEvent. Клик лишь поднимает флаг, а OnTimer подхватывает его на следующем тике и выполняет работу, не блокируя обработку событий.

//+------------------------------------------------------------------+
//| Timer                                                            |
//+------------------------------------------------------------------+
void OnTimer()
  {
   if(need_rebuild)
     {
      need_rebuild = false;
      BuildMarketBars();
      RedrawScene();
     }
  }



Временные метки на оси Z

Чтобы синяя ось времени читалась не абстрактно, а в реальных датах, поверх холста выводятся пять подписей в опорных точках: START, 25%, 50%, 75% и NOW. Функция берёт соответствующий бар по доле и подставляет его реальное время, учитывая направление сортировки массива.

//+------------------------------------------------------------------+
//| Update one Z axis time label                                     |
//+------------------------------------------------------------------+
void UpdateOneTimeLabel(const string name, const MqlRates &rates[], const int copied,
                        const bool time_ascending, const double part, const string prefix)
  {
   //--- индекс бара в опорной точке оси (0, 25, 50, 75, 100 %)
   int bar_index = (int)MathRound((copied - 1) * part);
   bar_index = MathMax(0, MathMin(copied - 1, bar_index));

   //--- учитываем порядок массива при выборе исходного бара
   int source_index = (time_ascending ? bar_index : copied - 1 - bar_index);
   string text = prefix + TimeToString(rates[source_index].time, TIME_DATE) + " " +
                 TimeToString(rates[source_index].time, TIME_MINUTES);
   ObjectSetString(0, name, OBJPROP_TEXT, text);
  }

Это тот случай, когда плоский интерфейс MQL5 и 3D-холст работают вместе: объёмная сцена даёт форму, а обычные объекты OBJ_LABEL поверх неё дают точную привязку к датам, которую в трёхмерном пространстве рисовать текстом было бы сложно.



Что дальше

Мы перенесли базовую тройку 3D-баров — цена, время, объём — из Python в нативную среду MetaTrader 5 и собрали полноценный интерактивный движок на DirectX. Сцена больше не живёт в браузере отдельным снимком: она вращается мышью, приближается колесом и перестраивается на свежей истории прямо на графике, без Python-моста и внешних библиотек.

В Python-версии самым интересным наблюдением были жёлтые объёмно-волатильные кластеры, которые с высокой точностью предшествовали разворотам. Объём — лишь одна из двух величин, формировавших те кластеры; вторая — волатильность. В следующей статье мы заменим метрику третьей оси: вместо тикового объёма на ось X ляжет волатильность по True Range. Это потребует изменить ровно одну функцию во всём движке — и даст вторую сцену, которую можно будет сопоставить с этой и приблизиться к воспроизведению эффекта кластеров уже в нативном MQL5.

Название файла Описание файла
DX_3D_Bars_TickVolume.mq5 Эксперт-визуализатор: трёхмерная сцена цена / время / тиковый объём на DirectX
Прикрепленные файлы |
Моделирование рынка: Первые шаги на SQL в MQL5 (III) Моделирование рынка: Первые шаги на SQL в MQL5 (III)
В предыдущей статье мы рассмотрели пример реализации класса на MQL5 для обеспечения базовой поддержки. Его цель заключается именно в том, чтобы позволить хранить SQL-код в отдельном файле скрипта. Таким образом, нам не потребуется писать тот же SQL-код в виде строки внутри кода MQL5. Хотя данное решение функционально, в нём есть некоторые детали, которые мы можем и должны улучшить.
Возможности Мастера MQL5, которые вам нужно знать (Часть 76): Использование паттернов Awesome Oscillator и каналов конвертов с обучением с учителем Возможности Мастера MQL5, которые вам нужно знать (Часть 76): Использование паттернов Awesome Oscillator и каналов конвертов с обучением с учителем
В продолжение нашей предыдущей статьи о паре индикаторов Awesome Oscillator и каналов конвертов (Envelope Channels), мы рассмотрим, как эту пару можно улучшить с помощью обучения с учителем. Awesome Oscillator и канал конвертов — это взаимодополняющее сочетание инструментов, позволяющих выявлять тренды и создавать уровни поддержки/сопротивления. Наш подход к обучению с учителем представляет собой сверточную нейронную сеть (CNN), которая использует ядро скалярного произведения (Dot Product Kernel) с механизмом внимания во времени (Cross-Time-Attention) для определения размеров своих ядер и каналов. Как обычно, это делается в пользовательском файле класса сигналов (signal class), который взаимодействует с Мастером MQL5 для сборки советника.
Особенности написания экспертов Особенности написания экспертов
Написание и тестирование экспертов в торговой системе MetaTrader 4.
Торговые инструменты MQL5 (Часть 25): Расширяем поддержку нескольких распределений с интерактивным переключением Торговые инструменты MQL5 (Часть 25): Расширяем поддержку нескольких распределений с интерактивным переключением
В этой статье мы расширим инструмент построения графиков на MQL5 для поддержки семнадцати статистических распределений с циклическим перебором распределений с помощью значка переключения в заголовке. Мы добавим загрузку данных для каждого типа, дискретное и непрерывное вычисление гистограмм и теоретические функции распределения вероятностей/плотности для каждой модели, а также динамические заголовки, метки осей и панели параметров, которые автоматически адаптируются. Результат позволяет накладывать кривые разных распределений на данные одной и той же выборки и сравнивать качество соответствия моделей из разных семейств распределений.