English
preview
Трейдинг с экономическим календарем MQL5 (Часть 11): Модульная новостная панель на Canvas

Трейдинг с экономическим календарем MQL5 (Часть 11): Модульная новостная панель на Canvas

MetaTrader 5 — Торговые системы |
262 0
Allan Munene Mutiiria
Allan Munene Mutiiria

Введение

В алгоритмической торговле инструменты, которые мы используем для отслеживания экономических новостей и реагирования на них, должны быть одновременно надёжными и удобными в сопровождении. Программа в одном файле, где в одном месте реализованы построение пользовательского интерфейса, фильтрация событий, торговая логика и взаимодействие с графиком, поначалу работает хорошо, но по мере расширения функционала её становится всё сложнее дорабатывать. В части 10 этой серии мы создали перетаскиваемую информационную панель, основанную на объектах, в одном файле языка MetaQuotes Language 5 (MQL5). Она включала эффекты при наведении курсора, полосу прокрутки, фильтры по валютам и важности, а также механизм исполнения сделок перед выходом новостей. Этот подход выполнил свою задачу, но не оставил чётких границ между областями ответственности: логика отрисовки соседствовала с торговой логикой, и добавление одной функции могло нарушить работу другой.

В этой статье мы устраняем этот недостаток. Мы перестраиваем информационную панель с нуля. Мы заменяем объекты графика canvas-рендерером и разбиваем программу на четыре специализированных модуля, каждый из которых отвечает за отдельную область ответственности. Основы рисования на Canvas, включая отрисовку скруглённых прямоугольников, суперсэмплинг, сглаженные примитивы и альфа-композицию, подробно рассматриваются в нашей серии MQL5 Trading Tools. Читатели, желающие подробно разобраться в теме, могут обратиться непосредственно к этой серии публикаций. Здесь мы полностью сосредоточимся на нововведениях: модульной архитектуре, системе двух тем, макете с изменяемым размером, сворачиваемых группах дней, отслеживании пересмотренных значений и обратном отсчёте в реальном времени со всплывающими уведомлениями. Мы рассмотрим следующие темы:

  1. От монолитной панели к модульной информационной панели на Canvas
  2. Модуль ядра и структуры данных
  3. Модули логики, отрисовки и взаимодействия
  4. Визуализация
  5. Заключение

К концу этой статьи у нас будет полностью модульная информационная панель новостей на MQL5, которую проще расширять, которая визуально богаче и отзывчивее своей предшественницы.


От монолитной панели к модульной информационной панели на Canvas

В части 10 вся программа размещалась в одном файле. Все аспекты — построение пользовательского интерфейса, фильтрация событий, управление прокруткой, определение наведения курсора, перетаскивание панели и торговая логика — были реализованы в одном месте. В информационной панели использовались объекты графика MetaTrader 5. Каждая строка события требовала отдельного создания и размещения нескольких объектов-меток; при перетаскивании панели при каждом движении мыши приходилось перемещать каждый объект, а полоса прокрутки состояла из трёх объектов, управляемых независимо друг от друга. Цвета были жёстко заданы, а размер макета был фиксированным.

В части 11 всё это заменяется системой отрисовки на основе Canvas и архитектурой, состоящей из четырёх модулей. Объекты графика больше не используются. Вместо них используется один Canvas-bitmap, на котором программа рисует напрямую, а поверх него расположен второй Canvas в качестве разделительного оверлея. Вся информационная панель — фон, заголовок, чипы фильтров, строки событий, полоса прокрутки, всплывающее уведомление и баннер обратного отсчёта — отрисовывается за один проход. Поскольку все элементы рисуются, а не создаются из объектов, макет адаптируется к изменениям во время выполнения: размеры информационной панели являются переменными, ширина столбцов масштабируется пропорционально, а полная перерисовка выполняется одним вызовом функции.

Программа состоит из четырёх специализированных модулей. Модуль ядра содержит все общие данные — палитру темы, структуры событий, состояние прокрутки и константы макета. Логический модуль отвечает за все операции с данными — загрузку событий, применение фильтров, построение плана строк и управление поиском кандидатов для сделок. Модуль рендеринга отвечает за всю отрисовку — он читает данные из слоя данных и рисует на Canvas, не изменяя состояние. Модуль взаимодействия отвечает за весь пользовательский ввод — преобразует движения мыши, щелчки, перетаскивания и события колёсика в изменения состояния и запускает перерисовку. Сторожевой макрос, определённый в основной точке входа, предотвращает дублирование объявлений при совместной компиляции всех четырёх модулей. Вкратце, вот чего мы намерены достичь.

ЦЕЛИ АРХИТЕКТУРЫ


Модуль ядра и структуры данных

Константы макета и состояние изменения размера во время выполнения

Теперь панель работает с шириной и высотой, определяемыми во время выполнения, вместо фиксированных размеров. Ограничения предотвращают появление непригодных макетов, а небольшой набор переменных состояния отслеживает активные перетаскивания для изменения размера от правого или нижнего края.

//+------------------------------------------------------------------+
//|                                                    News Core.mqh |
//|                           Copyright 2026, Allan Munene Mutiiria. |
//|                                   https://t.me/Forex_Algo_Trader |
//+------------------------------------------------------------------+
#property copyright "Copyright 2026, Allan Munene Mutiiria."
#property link      "https://t.me/Forex_Algo_Trader"

//--- Include guard
#ifndef NEWS_CORE_MQH
#define NEWS_CORE_MQH

//--- Include canvas library
#include <Canvas/Canvas.mqh>

//+------------------------------------------------------------------+
//| Layout Constants                                                 |
//+------------------------------------------------------------------+
//--- Runtime dashboard width driven by g_news_dashW; bounds prevent unusable layouts
#define NEWS_DASHBOARD_W_DEFAULT  700  // Default starting width
#define NEWS_DASHBOARD_W_MIN      400  // Minimum allowed width
#define NEWS_DASHBOARD_W_MAX      1400 // Maximum allowed width

//--- Vertical bounds; flexible section is the events table
#define NEWS_DASHBOARD_H_DEFAULT  420  // Default starting height
#define NEWS_DASHBOARD_H_MIN      320  // Minimum height for at least 3 rows
#define NEWS_DASHBOARD_H_MAX      900  // Maximum allowed height

#define NEWS_HEADER_H        40 // Header strip height
#define NEWS_FILTER_H        36 // Filter toggle row height
#define NEWS_CURR_ROW_H      32 // Currency chip row height
#define NEWS_IMPACT_ROW_H    32 // Impact pill row height
#define NEWS_TABLE_HDR_H     28 // Table header row height
#define NEWS_ROW_H           26 // Event row height
#define NEWS_VISIBLE_ROWS     8 // Default visible row count (computed at runtime)
#define NEWS_SIDE_PAD        10 // Horizontal padding inside dashboard
#define NEWS_VERT_GAP         6 // Vertical gap between sections
#define NEWS_RESIZE_HOT_W     6 // Right-edge resize hot zone width
#define NEWS_RESIZE_HOT_H     6 // Bottom-edge resize hot zone height
#define NEWS_RESIZE_HANDLE_H 30 // Visible right-edge handle length (centered)
#define NEWS_RESIZE_HANDLE_W 30 // Visible bottom-edge handle length (centered)

//+------------------------------------------------------------------+
//| Runtime Dashboard Size and Resize State                          |
//+------------------------------------------------------------------+
int  g_news_dashW             = NEWS_DASHBOARD_W_DEFAULT; // Current dashboard width
int  g_news_dashH             = NEWS_DASHBOARD_H_DEFAULT; // Current dashboard height
bool g_news_resizing          = false;                    // Right-edge drag active flag
int  g_news_resizeStartMouseX = 0;                        // Mouse X at resize start
int  g_news_resizeStartW      = 0;                        // Dashboard width at resize start
bool g_news_resizingV         = false;                    // Bottom-edge drag active flag
int  g_news_resizeStartMouseY = 0;                        // Mouse Y at resize start
int  g_news_resizeStartH      = 0;                        // Dashboard height at resize start

//--- Backwards-compatibility aliases
#define NEWS_DASHBOARD_W g_news_dashW
#define NEWS_DASHBOARD_H g_news_dashH

//+------------------------------------------------------------------+
//| Table Column Reference Widths                                    |
//+------------------------------------------------------------------+
//--- Reference widths at default dashboard width 700; all columns scale proportionally
const int    NEWS_COL_W_REF[]  = {68, 48, 42, 28, 215, 52, 60, 60, 70};
const string NEWS_COL_LABELS[] = {"Date", "Time", "Cur.", "Imp.", "Event", "Actual", "Forecast", "Previous", "Remain"};
#define NEWS_COL_COUNT 9

//--- Actual current column widths after proportional scaling; filled by News_ComputeColumnWidths
int g_news_colW[NEWS_COL_COUNT];

//+------------------------------------------------------------------+
//| Font Size Constants                                              |
//+------------------------------------------------------------------+
#define NEWS_FONT_TITLE     12 // Title text size
#define NEWS_FONT_HEADING   10 // Heading text size
#define NEWS_FONT_BODY       9 // Body text size
#define NEWS_FONT_LABEL      9 // Label text size
#define NEWS_FONT_BUTTON     9 // Button text size
#define NEWS_FONT_TIMESTAMP  8 // Timestamp text size

//+------------------------------------------------------------------+
//| Glyph Codes                                                      |
//+------------------------------------------------------------------+
#define NEWS_GLYPH_CLOSE      "r"     // Webdings X close glyph
#define NEWS_GLYPH_CHECK      "\x6FC" // Wingdings checkmark glyph
#define NEWS_GLYPH_CROSS      "\x71B" // Wingdings X mark glyph
#define NEWS_GLYPH_DOT        "l"     // Wingdings filled dot glyph
#define NEWS_GLYPH_ARROW_UP   "5"     // Webdings up arrow glyph
#define NEWS_GLYPH_ARROW_DOWN "6"     // Webdings down arrow glyph

//+------------------------------------------------------------------+
//| Canvas Object Names                                              |
//+------------------------------------------------------------------+
#define NEWS_CANVAS_NAME "NewsCanvasMain" // Main canvas object name

//+------------------------------------------------------------------+
//| Theme Color Globals                                              |
//+------------------------------------------------------------------+
color g_news_bg;                 // Dashboard background
color g_news_panelAlt;           // Alternate panel color
color g_news_headerBg;           // Header strip background
color g_news_border;             // Subtle border color
color g_news_borderAccent;       // Strong outer border color
color g_news_titleText;          // Primary text color
color g_news_subText;            // Secondary text color
color g_news_accent;             // Accent blue color

//--- Filter chip colors
color g_news_chipOnBg;           // Active chip background
color g_news_chipOnText;         // Active chip text
color g_news_chipOffBg;          // Inactive chip background
color g_news_chipOffText;        // Inactive chip text
color g_news_chipHoverTint;      // Hover overlay tint

//--- Currency chip colors
color g_news_currOnBg;           // Selected currency background
color g_news_currOnText;         // Selected currency text
color g_news_currOffBg;          // Unselected currency background
color g_news_currOffText;        // Unselected currency text

//--- Impact pill colors (semantic; same in both themes)
color g_news_impNone;            // Impact none (gray)
color g_news_impLow;             // Impact low (yellow)
color g_news_impMed;             // Impact medium (orange)
color g_news_impHigh;            // Impact high (red)

//--- Table colors
color g_news_tableHdrBg;         // Table header strip background
color g_news_tableHdrText;       // Table header text
color g_news_rowAlt;             // Alternating row background
color g_news_rowText;            // Row text color
color g_news_rowHover;           // Row hover background

//--- Actual value direction colors
color g_news_actualUp;           // Actual beat forecast (green)
color g_news_actualDown;         // Actual missed forecast (red)
color g_news_revisedMark;        // Revised previous indicator (gold)
color g_news_remainSoon;         // Remain color when event is within 30 min

//--- Day separator row colors
color g_news_dayHeaderBg;        // Day separator background
color g_news_dayHeaderText;      // Day separator text

//--- Close button colors
color g_news_closeColor;         // Close glyph idle color
color g_news_closeColorHover;    // Close glyph hover color
color g_news_closeBgHover;       // Close button hover background

//--- Toast colors
color g_news_toastBg;            // Toast box background
color g_news_toastBorder;        // Toast box border
color g_news_toastSuccess;       // Toast success text
color g_news_toastError;         // Toast error text

//--- Countdown banner colors
color g_news_countdownBg;        // Active countdown background
color g_news_countdownReleaseBg; // Released countdown background
color g_news_countdownText;      // Countdown text color

//--- Scrollbar thumb colors
color g_news_scrollSlider;       // Idle thumb color
color g_news_scrollSliderHover;  // Hover thumb color
color g_news_scrollSliderDrag;   // Dragging thumb color

//--- Theme state
bool g_news_darkTheme = true; // Current theme flag

//+------------------------------------------------------------------+
//| Apply theme palette - dark or light                              |
//+------------------------------------------------------------------+
void News_ApplyTheme(bool dark)
  {
//--- Store current theme flag
   g_news_darkTheme = dark;
//--- Branch on theme mode
   if(dark)
     {
      //--- Apply dark theme: charcoal with blue accent
      g_news_bg              = (color)C'24,26,32';
      g_news_panelAlt        = (color)C'30,33,40';
      g_news_headerBg        = (color)C'18,20,24';
      g_news_border          = (color)C'48,52,60';
      g_news_borderAccent    = (color)C'72,78,90';
      g_news_titleText       = (color)C'232,234,240';
      g_news_subText         = (color)C'148,152,162';
      g_news_accent          = (color)C'68,138,255';
      g_news_chipOnBg        = (color)C'68,138,255';
      g_news_chipOnText      = (color)C'255,255,255';
      g_news_chipOffBg       = (color)C'40,44,52';
      g_news_chipOffText     = (color)C'180,184,194';
      g_news_chipHoverTint   = (color)C'255,255,255';
      g_news_currOnBg        = (color)C'48,80,128';
      g_news_currOnText      = (color)C'255,255,255';
      g_news_currOffBg       = (color)C'36,40,48';
      g_news_currOffText     = (color)C'160,164,174';
      g_news_impNone         = (color)C'120,124,132';
      g_news_impLow          = (color)C'230,200,80';
      g_news_impMed          = (color)C'240,150,60';
      g_news_impHigh         = (color)C'225,80,80';
      g_news_tableHdrBg      = (color)C'36,40,48';
      g_news_tableHdrText    = (color)C'200,204,214';
      g_news_rowAlt          = (color)C'28,30,36';
      g_news_rowText         = (color)C'220,222,232';
      g_news_rowHover        = (color)C'70,76,92';
      g_news_actualUp        = (color)C'120,210,130';
      g_news_actualDown      = (color)C'235,100,100';
      g_news_revisedMark     = (color)C'250,200,60';
      g_news_remainSoon      = (color)C'255,90,90';
      g_news_dayHeaderBg     = (color)C'48,80,128';
      g_news_dayHeaderText   = (color)C'235,238,245';
      g_news_closeColor      = (color)C'180,184,194';
      g_news_closeColorHover = (color)C'255,255,255';
      g_news_closeBgHover    = (color)C'180,60,60';
      g_news_toastBg         = (color)C'36,40,48';
      g_news_toastBorder     = (color)C'72,78,90';
      g_news_toastSuccess    = (color)C'120,200,120';
      g_news_toastError      = (color)C'225,100,100';
      g_news_countdownBg        = (color)C'48,80,128';
      g_news_countdownReleaseBg = (color)C'140,60,60';
      g_news_countdownText      = (color)C'255,255,255';
      g_news_scrollSlider       = (color)C'90,100,120';
      g_news_scrollSliderHover  = (color)C'140,150,170';
      g_news_scrollSliderDrag   = (color)C'88,160,255';
     }
   else
     {
      //--- Apply light theme: white with blue accent
      g_news_bg              = (color)C'248,249,251';
      g_news_panelAlt        = (color)C'255,255,255';
      g_news_headerBg        = (color)C'238,240,244';
      g_news_border          = (color)C'218,222,228';
      g_news_borderAccent    = (color)C'180,186,196';
      g_news_titleText       = (color)C'24,28,36';
      g_news_subText         = (color)C'108,114,124';
      g_news_accent          = (color)C'40,110,220';
      g_news_chipOnBg        = (color)C'40,110,220';
      g_news_chipOnText      = (color)C'255,255,255';
      g_news_chipOffBg       = (color)C'232,236,242';
      g_news_chipOffText     = (color)C'70,76,86';
      g_news_chipHoverTint   = (color)C'0,0,0';
      g_news_currOnBg        = (color)C'200,220,250';
      g_news_currOnText      = (color)C'30,60,140';
      g_news_currOffBg       = (color)C'238,240,244';
      g_news_currOffText     = (color)C'90,96,106';
      g_news_impNone         = (color)C'140,144,152';
      g_news_impLow          = (color)C'220,180,40';
      g_news_impMed          = (color)C'230,130,40';
      g_news_impHigh         = (color)C'210,60,60';
      g_news_tableHdrBg      = (color)C'232,236,242';
      g_news_tableHdrText    = (color)C'40,46,56';
      g_news_rowAlt          = (color)C'248,250,253';
      g_news_rowText         = (color)C'30,34,42';
      g_news_rowHover        = (color)C'232,238,248';
      g_news_actualUp        = (color)C'30,140,60';
      g_news_actualDown      = (color)C'200,40,40';
      g_news_revisedMark     = (color)C'200,110,15';
      g_news_remainSoon      = (color)C'205,40,40';
      g_news_dayHeaderBg     = (color)C'215,228,245';
      g_news_dayHeaderText   = (color)C'30,60,140';
      g_news_closeColor      = (color)C'120,126,136';
      g_news_closeColorHover = (color)C'255,255,255';
      g_news_closeBgHover    = (color)C'200,60,60';
      g_news_toastBg         = (color)C'255,255,255';
      g_news_toastBorder     = (color)C'180,186,196';
      g_news_toastSuccess    = (color)C'40,140,60';
      g_news_toastError      = (color)C'200,60,60';
      g_news_countdownBg        = (color)C'40,110,220';
      g_news_countdownReleaseBg = (color)C'200,80,80';
      g_news_countdownText      = (color)C'255,255,255';
      g_news_scrollSlider       = (color)C'170,178,190';
      g_news_scrollSliderHover  = (color)C'120,128,142';
      g_news_scrollSliderDrag   = (color)C'40,110,220';
     }
  }

Мы начинаем реализацию с определения шести констант допустимых границ для габаритов панели. Диапазон ширины позволяет изменять размер в пределах от 400 до 1400 пикселей, а диапазон высоты — от 320 до 900 пикселей. Эти ограничения не позволяют пользователю сжать панель до нечитаемого состояния или развернуть её за пределы canvas графика. Как и раньше, ещё два значения по умолчанию задают начальный размер панели 700×420 пикселей при первом запуске.

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

Состояние и вспомогательные функции полосы прокрутки на основе пикселей

Полоса прокрутки в предыдущей части работала с индексами элементов, при этом каждый клик по стрелке перемещал видимое окно на одну строку события. Здесь мы переходим к прокрутке на основе пикселей, при которой смещение измеряется в пикселях, а не в строках, что позволяет прокручивать частичные строки и плавно перетаскивать ползунок.

//+------------------------------------------------------------------+
//| Scroll state structure                                           |
//+------------------------------------------------------------------+
struct NewsScrollState
  {
   int  scrollPx;        // Current scroll offset in pixels
   int  totalH;          // Total content height in pixels
   int  viewportH;       // Visible viewport height
   int  trackL;          // Track left X
   int  trackT;          // Track top Y
   int  trackR;          // Track right X
   int  trackB;          // Track bottom Y
   bool dragging;        // Drag in progress flag
   int  dragStartY;      // Mouse Y at drag start
   int  dragStartScroll; // scrollPx value at drag start
   bool hover;           // Cursor over track or thumb
   bool hoveredArea;     // Cursor over track area
   bool hoveredThumb;    // Cursor over thumb specifically
  };

//+------------------------------------------------------------------+
//| Initialize scroll state to defaults                              |
//+------------------------------------------------------------------+
void News_ScrollInit(NewsScrollState &s)
  {
//--- Zero all scroll fields
   s.scrollPx        = 0;
   s.totalH          = 0;
   s.viewportH       = 0;
   s.trackL          = 0;
   s.trackT          = 0;
   s.trackR          = 0;
   s.trackB          = 0;
   s.dragging        = false;
   s.dragStartY      = 0;
   s.dragStartScroll = 0;
   s.hover           = false;
   s.hoveredArea     = false;
   s.hoveredThumb    = false;
  }

//+------------------------------------------------------------------+
//| Compute maximum scroll position                                  |
//+------------------------------------------------------------------+
int News_ScrollMax(NewsScrollState &s)
  {
//--- Return positive overflow or zero
   const int m = s.totalH - s.viewportH;
   return (m > 0) ? m : 0;
  }

//+------------------------------------------------------------------+
//| Clamp current scroll position to valid range                     |
//+------------------------------------------------------------------+
void News_ScrollClamp(NewsScrollState &s)
  {
//--- Enforce lower and upper scroll bounds
   const int m = News_ScrollMax(s);
   if(s.scrollPx < 0) s.scrollPx = 0;
   if(s.scrollPx > m) s.scrollPx = m;
  }

//+------------------------------------------------------------------+
//| Test if scrollbar should be visible                              |
//+------------------------------------------------------------------+
bool News_ScrollVisible(NewsScrollState &s)
  {
//--- Scrollbar appears only when content exceeds viewport
   return s.totalH > s.viewportH;
  }

//+------------------------------------------------------------------+
//| Compute thumb rectangle within track                             |
//+------------------------------------------------------------------+
void News_ScrollThumbRect(NewsScrollState &s, int &outT, int &outB)
  {
//--- Handle degenerate track
   const int trackH = s.trackB - s.trackT;
   if(trackH <= 0 || s.totalH <= 0)
     {
      outT = s.trackT; outB = s.trackB;
      return;
     }
//--- Compute thumb height proportional to viewport/total ratio
   const double ratio = (double)s.viewportH / (double)s.totalH;
   int thumbH = (int)(trackH * ratio);
   if(thumbH < 20) thumbH = 20;
   if(thumbH > trackH) thumbH = trackH;
//--- Position thumb proportional to current scroll offset
   const int m = News_ScrollMax(s);
   const double scrollRatio = (m > 0) ? ((double)s.scrollPx / (double)m) : 0.0;
   const int avail = trackH - thumbH;
   outT = s.trackT + (int)(avail * scrollRatio);
   outB = outT + thumbH;
  }

//+------------------------------------------------------------------+
//| Hit-test scrollbar thumb                                         |
//+------------------------------------------------------------------+
bool News_ScrollHitThumb(NewsScrollState &s, int x, int y)
  {
//--- Reject points outside the track lane
   if(x < s.trackL - 2 || x > s.trackR + 2) return false;
//--- Test Y against current thumb bounds
   int tT, tB;
   News_ScrollThumbRect(s, tT, tB);
   return (y >= tT && y < tB);
  }

//+------------------------------------------------------------------+
//| Begin scroll drag from mouse y position                          |
//+------------------------------------------------------------------+
void News_ScrollBeginDrag(NewsScrollState &s, int y)
  {
//--- Record drag start state
   s.dragging        = true;
   s.dragStartY      = y;
   s.dragStartScroll = s.scrollPx;
  }

//+------------------------------------------------------------------+
//| Update drag with current mouse y                                 |
//+------------------------------------------------------------------+
void News_ScrollUpdateDrag(NewsScrollState &s, int y)
  {
//--- Abort if not in drag state
   if(!s.dragging) return;
   const int trackH = s.trackB - s.trackT;
   if(trackH <= 0) return;
//--- Convert pixel delta to scroll units and apply
   const int dy = y - s.dragStartY;
   const double scale = (double)s.totalH / (double)trackH;
   const int newScroll = s.dragStartScroll + (int)(dy * scale);
   s.scrollPx = newScroll;
   News_ScrollClamp(s);
  }

//+------------------------------------------------------------------+
//| End scroll drag                                                  |
//+------------------------------------------------------------------+
void News_ScrollEndDrag(NewsScrollState &s)
  {
//--- Clear drag flag
   s.dragging = false;
  }

//+------------------------------------------------------------------+
//| Apply mouse wheel delta to scroll position                       |
//+------------------------------------------------------------------+
void News_ScrollByWheel(NewsScrollState &s, int delta, int step)
  {
//--- Scroll up when delta is positive, down when negative
   if(delta > 0) s.scrollPx -= step;
   else          s.scrollPx += step;
   News_ScrollClamp(s);
  }

//+------------------------------------------------------------------+
//| Draw scrollbar with fully-rounded thumb onto canvas              |
//+------------------------------------------------------------------+
void News_ScrollDraw(CCanvas &canv, NewsScrollState &s)
  {
//--- Skip when scrollbar is not needed
   if(!News_ScrollVisible(s)) return;
   const int trackW = s.trackR - s.trackL;
   if(trackW <= 0) return;
//--- Compute thumb position
   int tT, tB;
   News_ScrollThumbRect(s, tT, tB);
   const int thumbH = tB - tT;
   if(thumbH <= 0) return;
//--- Pick thumb color based on current interaction state
   color thumbColor = g_news_scrollSlider;
   if(s.dragging)                       thumbColor = g_news_scrollSliderDrag;
   else if(s.hoveredThumb || s.hover)   thumbColor = g_news_scrollSliderHover;
//--- Draw fully-rounded thumb with radius equal to half track width
   const int radius = trackW / 2;
   News_FillRoundRect(canv, s.trackL, tT, trackW, thumbH, radius, ColorToARGB(thumbColor, 255));
  }

//+------------------------------------------------------------------+
//| Global Scroll State                                              |
//+------------------------------------------------------------------+
NewsScrollState g_news_tableScroll; // Scroll state for the event table

Здесь мы объявляем структуру «NewsScrollState», чтобы в одном месте хранить все данные о состоянии, необходимые полосе прокрутки. Смещение прокрутки, общая высота контента и высота области просмотра в совокупности определяют, какая часть контента в данный момент видна. Мы определяем четыре поля дорожки, описывающие прямоугольник, в котором находится ползунок; эти поля задаются во время отрисовки и затем повторно используются для проверки попадания. Поля перетаскивания фиксируют положение мыши и смещение прокрутки в момент начала перетаскивания ползунка, а три флага наведения различают наведение на область дорожки, наведение непосредственно на ползунок и общее состояние наведения, используемое для выбора цвета. Чтобы обнулить все поля и не допустить попадания мусорных данных в первую отрисовку, мы один раз при запуске вызываем функцию «News_ScrollInit».

Далее мы обрабатываем допустимые границы с помощью трёх вспомогательных функций. Мы используем функцию «News_ScrollMax», чтобы вернуть максимальное допустимое смещение прокрутки — то есть просто ту часть высоты контента, которая не помещается в области просмотра. Если содержимое короче области просмотра, функция возвращает ноль, поэтому положение прокрутки не может сместиться за верхнюю границу. После этого мы вызываем функцию News_ScrollClamp, чтобы применить обе допустимые границы к текущему положению прокрутки: при уходе ниже нуля оно возвращается к нулю, а при превышении максимума — к максимальному значению. Наконец, мы используем функцию «News_ScrollVisible», которая возвращает true только тогда, когда содержимое превышает область просмотра; слой отрисовки считывает это значение, чтобы решить, нужно ли вообще отрисовывать полосу прокрутки.

Для вычисления геометрии ползунка по требованию мы определяем функцию «News_ScrollThumbRect». Мы задаём высоту ползунка пропорционально отношению высоты области просмотра к высоте контента, поэтому, когда в области просмотра отображается половина контента, ползунок занимает половину дорожки. Минимальная высота ползунка в 20 пикселей не позволяет ему сжиматься до размера узкой полоски, с которой невозможно взаимодействовать, когда список содержимого становится длинным. Мы вычисляем вертикальную позицию на основе отношения текущего смещения прокрутки к максимальному смещению прокрутки, масштабируя это отношение на доступное пространство дорожки. Наряду с этим мы используем функцию «News_ScrollHitThumb», чтобы проверить, попадает ли заданная позиция мыши непосредственно на ползунок, применяя горизонтальный допуск в 2 пикселя с каждой стороны: так ползунок легче захватить, не увеличивая его видимые габариты.

Три вспомогательные функции управляют жизненным циклом перетаскивания: begin, update и end. При нажатии кнопки мыши мы вызываем функцию «News_ScrollBeginDrag», чтобы зафиксировать координату Y курсора и текущее смещение прокрутки. При последующих перемещениях мыши мы используем функцию «News_ScrollUpdateDrag», чтобы преобразовать разницу в пикселях в пространстве дорожки в новое смещение прокрутки в пространстве контента, а затем привести его к допустимым границам. При отпускании кнопки мыши мы вызываем функцию «News_ScrollEndDrag», чтобы сбросить флаг перетаскивания. Отдельно мы обрабатываем ввод с колёсика мыши с помощью функции «News_ScrollByWheel»: положительная дельта прокручивает вверх, отрицательная — вниз, а параметр step определяет, на сколько пикселей сдвигает прокрутку каждый щелчок колёсика.

В заключение мы рисуем ползунок на переданном Canvas с помощью функции «News_ScrollDraw». Мы сразу выходим, если полоса прокрутки не нужна, затем вычисляем прямоугольник ползунка и выбираем цвет в зависимости от того, перетаскивается ли ползунок сейчас, наведён ли на него курсор или он находится в состоянии покоя. Мы заливаем ползунок как полностью скруглённый прямоугольник с радиусом, равным половине ширины дорожки, благодаря чему он получает форму капсулы независимо от своей высоты. В конце фрагмента кода мы объявляем глобальный экземпляр «g_news_tableScroll» как единственное состояние прокрутки, используемое таблицей событий: все остальные модули читают данные из этой переменной и записывают их в неё. Следующим шагом мы будем динамически вычислять ширину столбцов, удалив статический расчёт из предыдущей части, поскольку теперь учитываем изменение размера панели.

Вычисление ширины столбцов, масштабируемой вместе с панелью

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

//+------------------------------------------------------------------+
//| Compute current column widths from runtime dashboard width       |
//+------------------------------------------------------------------+
void News_ComputeColumnWidths()
  {
//--- Compute proportional scale factor relative to default width
   const double scale = (double)g_news_dashW / (double)NEWS_DASHBOARD_W_DEFAULT;
//--- Scale each reference column width and enforce 20px floor
   for(int i = 0; i < NEWS_COL_COUNT; i++)
     {
      int w = (int)MathRound(NEWS_COL_W_REF[i] * scale);
      if(w < 20) w = 20;
      g_news_colW[i] = w;
     }
//--- Compute layout geometry to determine scrollbar reservation
   const int sidePad     = 10;
   const int xStart      = 6;
   const int totalRows   = ArraySize(g_news_rowPlan);
   const int rowsTop     = NEWS_HEADER_H + 36 + 32 + 32 + 6 + 28;
   const int footerTop   = g_news_dashH - 26 - 8;
   const int viewportH   = footerTop - rowsTop;
   const int contentH    = totalRows * 26;
   const bool scrollNeeded  = (contentH > viewportH);
   const int scrollReserve  = scrollNeeded ? 12 : 0;
   const int availW         = g_news_dashW - 2 * sidePad - xStart - scrollReserve;
//--- Sum all scaled column widths
   int sumW = 0;
   for(int i = 0; i < NEWS_COL_COUNT; i++) sumW += g_news_colW[i];
//--- Shrink the Event column (index 4) if total exceeds available width
   if(sumW > availW)
     {
      const int over = sumW - availW;
      int eventW = g_news_colW[4] - over;
      if(eventW < 80) eventW = 80;
      g_news_colW[4] = eventW;
     }
  }

Мы определяем функцию «News_ComputeColumnWidths», чтобы пересчитывать ширину каждого столбца на основе текущего размера панели. Сначала мы выводим коэффициент масштабирования как отношение текущей ширины к ширине по умолчанию: панель, растянутая до удвоенного размера по умолчанию, даёт коэффициент 2,0, а панель с размером по умолчанию — 1,0.

Далее мы проходим по всем девяти эталонным значениям ширины и умножаем каждое из них на коэффициент масштабирования, используя функцию MathRound для преобразования результата обратно в целое число. Мы задаём для каждого столбца нижний предел в 20 пикселей, чтобы при минимальном размере панели ни один столбец не схлопывался до нечитаемой узкой полоски.

Затем мы вычисляем доступное горизонтальное пространство для таблицы. Мы суммируем боковые отступы, небольшое смещение слева для содержимого строки и резерв в 12 пикселей для полосы прокрутки на случай, если содержимое выйдет за пределы области просмотра. Высота области просмотра получается вычитанием верхней позиции строк и полосы футера из высоты панели, а высота содержимого равна количеству строк в плане строк, умноженному на высоту строки. Если содержимое выходит за пределы области просмотра, нужна полоса прокрутки, и мы резервируем для неё место.

Наконец, мы суммируем все масштабированные значения ширины столбцов и сравниваем результат с доступным пространством. Когда итоговая ширина превышает доступное пространство, мы уменьшаем столбец Event с индексом 4 на величину переполнения, сохраняя минимальную ширину 80 пикселей, чтобы названия событий оставались читаемыми. Столбец Event принимает на себя это уменьшение, потому что содержит самый длинный текст, а остальные столбцы содержат короткие значения фиксированного формата, которые не следует сжимать. Далее мы расширим отображение значений календаря форматированием с учётом единиц измерения.

Форматирование значений календаря с отображением с учетом единиц измерения

В предыдущей части значения отрисовывались с помощью простого вызова функции DoubleToString, в результате чего получались «сырые» числа, лишённые какого-либо контекста. Показатель занятости вне сельского хозяйства в 250 000 рабочих мест выглядел точно так же, как процентная ставка в размере 250 000, — были утрачены единица измерения, масштаб множителя и значимая десятичная точность. Мы заменяем это форматтером, который считывает метаданные календаря и формирует вывод вида «$2,5K», «3.2 %» или «250 рабочих мест».

//+------------------------------------------------------------------+
//| Format calendar value with unit, multiplier, and digits metadata |
//+------------------------------------------------------------------+
string News_FormatValue(bool hasValue, double v, int unit, int multiplier, int digits)
  {
//--- Return dash when value is not published
   if(!hasValue) return "-";
//--- Determine multiplier suffix
   string suffix = "";
   if(multiplier == CALENDAR_MULTIPLIER_THOUSANDS) suffix = "K";
   else if(multiplier == CALENDAR_MULTIPLIER_MILLIONS)  suffix = "M";
   else if(multiplier == CALENDAR_MULTIPLIER_BILLIONS)  suffix = "B";
   else if(multiplier == CALENDAR_MULTIPLIER_TRILLIONS) suffix = "T";
//--- Clamp decimal places to valid range
   int d = digits;
   if(d < 0) d = 0;
   if(d > 6) d = 6;
   string num = DoubleToString(v, d);
//--- Apply currency prefix for USD unit
   if(unit == CALENDAR_UNIT_USD) return "$" + num + suffix;
//--- Apply percent suffix for percent unit
   if(unit == CALENDAR_UNIT_PERCENT) return num + "%" + suffix;
//--- Map remaining units to their textual suffix
   string post = "";
   if(unit == CALENDAR_UNIT_HOUR)       post = " h";
   else if(unit == CALENDAR_UNIT_JOB)       post = " jobs";
   else if(unit == CALENDAR_UNIT_RIG)       post = " rigs";
   else if(unit == CALENDAR_UNIT_PEOPLE)    post = " ppl";
   else if(unit == CALENDAR_UNIT_MORTGAGE)  post = " mtg";
   else if(unit == CALENDAR_UNIT_VOTE)      post = " votes";
   else if(unit == CALENDAR_UNIT_BARREL)    post = " bbl";
   else if(unit == CALENDAR_UNIT_CUBICFEET) post = " cf";
   else if(unit == CALENDAR_UNIT_POSITION)  post = " pos";
   else if(unit == CALENDAR_UNIT_BUILDING)  post = " bldg";
   else if(unit == CALENDAR_UNIT_CURRENCY)  post = "";
//--- Return number with suffix and unit postfix
   return num + suffix + post;
  }

Мы определяем функцию News_FormatValue, которая принимает пять входных параметров: флаг, указывающий, опубликовано ли значение, исходное значение типа double и три поля метаданных из события календаря. Если значение не опубликовано, мы возвращаем тире. Благодаря этому незаполненная ячейка фактического или прогнозного значения отображается в таблице как тире, а не как вводящий в заблуждение ноль.

После этого мы определяем суффикс множителя. Календарь MetaTrader 5 возвращает значения в их естественном масштабе вместе с перечислением множителей, которое указывает, нужно ли отображать исходное число или добавлять «K», «M», «B» либо «T» для обозначения тысяч, миллионов, миллиардов или триллионов. Мы сопоставляем каждый элемент перечисления множителей соответствующему символу суффикса, оставляя суффикс пустым, если множитель не применяется.

Далее мы ограничиваем десятичную точность диапазоном от нуля до шести знаков перед форматированием числа с помощью функции DoubleToString. Это защищает от некорректно сформированных метаданных, которые могут запрашивать отрицательное или абсурдно большое количество знаков.

После форматирования базового числа мы применяем префиксы и суффиксы, зависящие от конкретных единиц измерения. Для значений, номинированных в долларах США, например балансов центральных банков, мы добавляем знак доллара в начало и сразу возвращаем результат. Для процентных показателей, таких как процентные ставки или безработица, мы добавляем знак процента перед суффиксом множителя. Для всех остальных единиц измерения — часов, рабочих мест, буровых установок, людей, ипотечных кредитов, голосов, баррелей, кубических футов, позиций и зданий — мы сопоставляем каждой из них короткий текстовый суффикс, добавляемый после множителя. Для валютной единицы постфикс не добавляется, поскольку суффикс множителя сам по себе содержит достаточно информации о масштабе.

В заключение мы возвращаем собранную строку, состоящую из числа, суффикса множителя и постфикса единицы измерения. Это соответствует формату календаря MT5 и дает аналогичный результат. В результате мы получаем следующее.

СРАВНЕНИЕ ОТОБРАЖЕНИЯ С УЧЕТОМ ЕДИНИЦ ИЗМЕРЕНИЯ

Мы видим, что новая версия учитывает единицы измерения и отображает точные значения, что повышает наглядность. Для форматирования столбца оставшегося времени мы использовали следующую логику.

Форматирование времени, оставшегося до каждого события

Новому столбцу Remain нужна строка, которая будет читаться естественно независимо от того, сколько времени осталось до события. Нам также нужна быстрая проверка, чтобы отмечать события, которые вот-вот произойдут, чтобы слой отрисовки мог окрашивать их предупреждающим цветом.

//+------------------------------------------------------------------+
//| Format time-remaining string for an event row                    |
//+------------------------------------------------------------------+
string News_FormatRemain(datetime evTime, datetime now)
  {
//--- Compute signed difference in seconds
   const long diffSec = (long)evTime - (long)now;
//--- Show static label for past events
   if(diffSec < -60) return "Released";
//--- Show "now" within 60 seconds of release
   const long absSec = (diffSec < 0) ? -diffSec : diffSec;
   if(absSec <= 60) return "now";
//--- Decompose absolute seconds into days, hours, minutes, seconds
   const long mins  = absSec / 60;
   const long hours = mins / 60;
   const long days  = hours / 24;
   string body;
   if(days > 0)
     {
      const long remH = hours - days * 24;
      body = IntegerToString(days) + "d " + IntegerToString(remH) + "h";
     }
   else if(hours > 0)
     {
      const long remM = mins - hours * 60;
      body = IntegerToString(hours) + "h " + IntegerToString(remM) + "m";
     }
   else if(mins > 0)
     {
      const long remS = absSec - mins * 60;
      body = IntegerToString(mins) + "m " + IntegerToString(remS) + "s";
     }
   else
     {
      body = IntegerToString(absSec) + "s";
     }
//--- Prefix with "in " for future events
   return "in " + body;
  }

//+------------------------------------------------------------------+
//| Test if event is upcoming and within 30 minutes                  |
//+------------------------------------------------------------------+
bool News_RemainIsImminent(datetime evTime, datetime now)
  {
//--- Return true only when event is in the future and within 30 min
   const long diffSec = (long)evTime - (long)now;
   return (diffSec > 0 && diffSec <= 30 * 60);
  }

Мы определяем функцию «News_FormatRemain», чтобы преобразовать разницу между временем события и текущим временем в короткую строку для отображения. В первую очередь мы вычисляем разность со знаком в секундах — положительную для будущих событий и отрицательную для прошлых. События, произошедшие более 60 секунд назад, отображаются как «Released», поэтому исторические строки не привлекают лишнего внимания и не загромождают таблицу счетчиками секунд с момента публикации.

Затем мы берем абсолютное значение разности и рассматриваем все, что находится в 60-секундном окне, как «сейчас». Это позволяет обработать момент срабатывания в реальном времени, когда время события и текущее время фактически совпадают, включая события, которые только что были опубликованы и всё еще находятся в пределах первой минуты. Для этого вы можете задать собственное окно.

Во всех остальных случаях мы разбиваем секунды на дни, часы, минуты и оставшиеся секунды. Логика отображения выбирает две наиболее значимые единицы: дни и часы, если до события больше суток; часы и минуты, если оно произойдет в течение суток; минуты и секунды, если до него меньше часа; и только секунды, если оно ближе чем через минуту. Используя функцию IntegerToString для формирования каждой части, мы собираем компактную основную строку, например «2d 5h» или «14m 22s».

Наконец, мы добавляем к основной строке префикс «in », чтобы значение ячейки читалось как естественная английская фраза, например «in 14m 22s».

Далее функция «News_RemainIsImminent» выполняет одну проверку, которую слой отрисовки использует для выбора цвета. Она возвращает true только в том случае, если событие произойдет в будущем и в течение ближайших 30 минут — это окно мы считаем достаточно срочным, чтобы выделять ячейку Remain красным цветом, а не обычным акцентным цветом. Опять же, вы можете задать собственное окно предупреждения. Это просто произвольное окно, которое мы приняли за стандартное. Пример приведен ниже.

СЧЁТЧИК ВРЕМЕНИ ДО НОВОСТНЫХ СОБЫТИЙ

Теперь, когда это сделано, мы перейдем к организации модулей логики, отрисовки и взаимодействия.


Модули логики, отрисовки и взаимодействия

Загрузка событий из API календаря в реальном времени

В предыдущей части каждое событие загружалось только с необработанными числами типа double для фактического, прогнозного и предыдущего значений. Здесь мы расширяем загрузчик, чтобы он также собирал метаданные, необходимые для форматирования с учётом единиц измерения, отслеживания пересмотренных значений и устранения дубликатов сделок.

//+------------------------------------------------------------------+
//|                                                   News Logic.mqh |
//|                           Copyright 2026, Allan Munene Mutiiria. |
//|                                   https://t.me/Forex_Algo_Trader |
//+------------------------------------------------------------------+
#property copyright "Copyright 2026, Allan Munene Mutiiria."
#property link      "https://t.me/Forex_Algo_Trader"

//--- Include guard
#ifndef NEWS_LOGIC_MQH
#define NEWS_LOGIC_MQH

//--- Include MQL5 trading library
#include <Trade/Trade.mqh>
//--- Include core data definitions and state
#include "News Core.mqh"

//+------------------------------------------------------------------+
//| Forward Extern Declarations                                      |
//+------------------------------------------------------------------+
#ifndef NEWS_COMPILED_FROM_MAIN
extern ENUM_TIMEFRAMES start_time;         // Past time window for live mode
extern ENUM_TIMEFRAMES end_time;           // Future time window for live mode
extern ENUM_TIMEFRAMES range_time;         // Time filter range from now
extern bool            updateServerTime;   // Update server clock on header
extern bool            debugLogging;       // Print debug info to journal
extern datetime        StartDate;          // Tester window start date
extern datetime        EndDate;            // Tester window end date
extern double          tradeLotSize;       // Trade lot size
extern int             tradeOffsetHours;   // Trade offset hours
extern int             tradeOffsetMinutes; // Trade offset minutes
extern int             tradeOffsetSeconds; // Trade offset seconds
#endif

//+------------------------------------------------------------------+
//| Trade mode enumeration                                           |
//+------------------------------------------------------------------+
//--- Declared here for standalone compile; skipped when compiled from main
#ifndef NEWS_COMPILED_FROM_MAIN
enum ENewsTradeMode
  {
   NEWS_TRADE_BEFORE,  // Trade before news event
   NEWS_TRADE_AFTER,   // Trade after news event
   NEWS_NO_TRADE,      // Do not trade on news
   NEWS_PAUSE_TRADING  // Pause trading around news
  };
extern ENewsTradeMode tradeMode; // Active trade mode input
#endif

//+------------------------------------------------------------------+
//| Trade Helper and Resource Declarations                           |
//+------------------------------------------------------------------+
CTrade g_news_trade; // Trade execution helper instance

//--- Embedded CSV resource handle declared by main .mq5
#ifndef NEWS_COMPILED_FROM_MAIN
extern string EconomicCalendarData; // Embedded CSV resource string
#endif

//+------------------------------------------------------------------+
//| Load events from MetaTrader live calendar API                    |
//+------------------------------------------------------------------+
int News_LoadEventsFromLive(datetime startDt, datetime endDt)
  {
//--- Reset event array before loading
   ArrayResize(g_news_allEvents, 0);
//--- Query all calendar values in the requested time window
   MqlCalendarValue values[];
   const int total = CalendarValueHistory(values, startDt, endDt, NULL, NULL);
   if(total <= 0) return 0;
//--- Process each returned calendar value
   int eventIndex = 0;
   for(int i = 0; i < total; i++)
     {
      //--- Fetch the associated event and country metadata
      MqlCalendarEvent ev;
      if(!CalendarEventById(values[i].event_id, ev)) continue;
      MqlCalendarCountry ctry;
      CalendarCountryById(ev.country_id, ctry);
      MqlCalendarValue val;
      CalendarValueById(values[i].id, val);
      //--- Populate event record with time and identity fields
      ArrayResize(g_news_allEvents, eventIndex + 1);
      g_news_allEvents[eventIndex].eventDate  = TimeToString(values[i].time, TIME_DATE);
      g_news_allEvents[eventIndex].eventTime  = TimeToString(values[i].time, TIME_MINUTES);
      g_news_allEvents[eventIndex].currency   = ctry.currency;
      g_news_allEvents[eventIndex].event      = ev.name;
      g_news_allEvents[eventIndex].importance = News_GetImpactLabel(ev.importance);
      //--- Store has-value flags alongside doubles to distinguish missing from 0.0
      g_news_allEvents[eventIndex].hasActual   = val.HasActualValue();
      g_news_allEvents[eventIndex].hasForecast = val.HasForecastValue();
      g_news_allEvents[eventIndex].hasPrevious = val.HasPreviousValue();
      g_news_allEvents[eventIndex].hasRevised  = val.HasRevisedValue();
      //--- Store raw double values guarded by their has-value flags
      g_news_allEvents[eventIndex].actual          = val.HasActualValue()   ? val.GetActualValue()   : 0.0;
      g_news_allEvents[eventIndex].forecast        = val.HasForecastValue() ? val.GetForecastValue() : 0.0;
      g_news_allEvents[eventIndex].previous        = val.HasPreviousValue() ? val.GetPreviousValue() : 0.0;
      g_news_allEvents[eventIndex].revisedPrevious = val.HasRevisedValue()  ? val.GetRevisedValue()  : 0.0;
      //--- Store formatting metadata for unit-aware value display
      g_news_allEvents[eventIndex].unit          = (int)ev.unit;
      g_news_allEvents[eventIndex].multiplier    = (int)ev.multiplier;
      g_news_allEvents[eventIndex].digits        = (int)ev.digits;
      g_news_allEvents[eventIndex].eventDateTime = values[i].time;
      g_news_allEvents[eventIndex].eventId       = (long)values[i].event_id;
      eventIndex++;
     }
   return eventIndex;
  }

Для начала мы определяем функцию «News_LoadEventsFromLive», которая извлекает события из календаря MetaTrader 5 в заданном диапазоне дат. Для начала мы сбрасываем глобальный массив событий, чтобы при обновлении к устаревшим данным не добавлялись дубликаты. Затем мы запрашиваем все значения календаря в этом окне с помощью функции CalendarValueHistory, передавая массив для заполнения, временные метки начала и конца, а также NULL для фильтров по стране и валюте, поскольку позже применяем собственную фильтрацию. Если запрос возвращает ноль записей или меньше, мы досрочно выходим.

Далее мы проходим по каждому возвращенному значению. Для каждого из них мы получаем метаданные родительского события с помощью функции CalendarEventById, извлекаем информацию о стране с помощью функции CalendarCountryById и получаем саму запись значения с помощью функции CalendarValueById. Метаданные события дают нам имя, единицу измерения, множитель и количество знаков; данные о стране дают код валюты, а запись значения — фактическое, прогнозное, предыдущее и пересмотренное значения.

Имея все три компонента, мы расширяем массив и заполняем запись. Строки даты и времени получаются функцией TimeToString, примененной к одной и той же временной метке с форматированием даты и времени до минут. В поля идентификации записываются валюта, название события и метка важности. Мы используем функцию «News_GetImpactLabel», чтобы преобразовать перечисление важности в отображаемую строку, используемую нашей системой фильтров.

После полей идентификации мы сохраняем четыре флага наличия значения с помощью методов «HasActualValue», «HasForecastValue», «HasPreviousValue» и «HasRevisedValue» у записи значения. Это ключевое исправление из предыдущей части: без этих флагов опубликованное значение 0.0 было неотличимо от отсутствующего значения, поэтому панель не могла определить, показывать число или тире. После сохранения флагов мы считываем сами значения типа double с помощью методов «GetActualValue», «GetForecastValue», «GetPreviousValue» и «GetRevisedValue», проверяя соответствующий флаг наличия значения, поэтому отсутствующие значения по умолчанию становятся равными 0.0 и не засоряют отображение.

В заключение мы копируем метаданные форматирования для отображения с учетом единиц измерения, сохраняем временную метку события и фиксируем его идентификатор. Именно идентификатор (ID) обеспечивает стабильное устранение дубликатов сделок между перезагрузками — в предыдущей части для этого использовались индексы массива, что ломалось при каждой перестройке массива, тогда как идентификатор календарного события остается неизменным при каждом обновлении. Мы увеличиваем индекс и продолжаем работу, а по завершении цикла возвращаем общее количество загруженных событий. Далее мы формируем план строк с разделителями дней, включая форматирование меток дней с указанием дня недели, названия месяца и количества событий. Идея возникла под влиянием того, как MQL5 классифицирует свои события, поэтому мы решили, что лучше отображать события именно в таком структурированном виде, а еще лучше — сворачивать то, что не нужно.

Построение плана строк со сворачиваемыми группами дней

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

//+------------------------------------------------------------------+
//| Test whether a date string is in the collapsed day set           |
//+------------------------------------------------------------------+
bool News_IsDayCollapsed(string date)
  {
//--- Linear scan of collapsed days array
   const int n = ArraySize(g_news_collapsedDays);
   for(int i = 0; i < n; i++)
      if(g_news_collapsedDays[i] == date) return true;
   return false;
  }

//+------------------------------------------------------------------+
//| Toggle collapsed state for a date string                         |
//+------------------------------------------------------------------+
bool News_ToggleDayCollapsed(string date)
  {
   const int n = ArraySize(g_news_collapsedDays);
//--- Remove date from set if already present
   for(int i = 0; i < n; i++)
     {
      if(g_news_collapsedDays[i] == date)
        {
         for(int j = i; j < n - 1; j++)
            g_news_collapsedDays[j] = g_news_collapsedDays[j + 1];
         ArrayResize(g_news_collapsedDays, n - 1);
         return false;
        }
     }
//--- Append date to set if not present
   ArrayResize(g_news_collapsedDays, n + 1);
   g_news_collapsedDays[n] = date;
   return true;
  }

//+------------------------------------------------------------------+
//| Build row plan with day separators from displayable events       |
//+------------------------------------------------------------------+
void News_BuildRowPlan()
  {
//--- Reset row plan array
   ArrayResize(g_news_rowPlan, 0);
   const int n = ArraySize(g_news_displayableEvents);
   int    planIdx  = 0;
   string lastDate = "";
//--- Walk all displayable events and emit day headers and event rows
   for(int i = 0; i < n; i++)
     {
      const string evDate = g_news_displayableEvents[i].eventDate;
      //--- Emit a day separator row when the date changes
      if(evDate != lastDate)
        {
         //--- Count events belonging to this date for the header label
         int dayCount = 0;
         for(int k = i; k < n; k++)
           {
            if(g_news_displayableEvents[k].eventDate != evDate) break;
            dayCount++;
           }
         //--- Build friendly day label: "Monday, August 12, 2025  -  N events"
         const datetime evDt = g_news_displayableEvents[i].eventDateTime;
         MqlDateTime mdt;
         TimeToStruct(evDt, mdt);
         const string daysOfWeek[] = {"Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"};
         const string monthNames[] = {"January","February","March","April","May","June",
                                       "July","August","September","October","November","December"};
         const int    dow         = mdt.day_of_week;
         const int    mo          = mdt.mon - 1;
         const string countSuffix = "  -  " + IntegerToString(dayCount) + (dayCount == 1 ? " event" : " events");
         const string label       = daysOfWeek[dow] + ", " + monthNames[mo] + " "
                                    + IntegerToString(mdt.day) + ", " + IntegerToString(mdt.year) + countSuffix;
         //--- Append day separator row to plan
         ArrayResize(g_news_rowPlan, planIdx + 1);
         g_news_rowPlan[planIdx].kind     = NEWS_ROW_KIND_DAY;
         g_news_rowPlan[planIdx].eventIdx = -1;
         g_news_rowPlan[planIdx].label    = label;
         g_news_rowPlan[planIdx].dateKey  = evDate;
         planIdx++;
         lastDate = evDate;
        }
      //--- Skip event rows for collapsed day groups (header row still appears)
      if(News_IsDayCollapsed(evDate)) continue;
      //--- Append event row to plan
      ArrayResize(g_news_rowPlan, planIdx + 1);
      g_news_rowPlan[planIdx].kind     = NEWS_ROW_KIND_EVENT;
      g_news_rowPlan[planIdx].eventIdx = i;
      g_news_rowPlan[planIdx].label    = "";
      g_news_rowPlan[planIdx].dateKey  = evDate;
      planIdx++;
     }
  }

Мы определяем функцию «News_IsDayCollapsed» для проверки того, входит ли заданная строка даты в набор дней, свернутых в данный момент. Набор свернутых дней представляет собой плоский массив строк с датами, и мы выполняем по нему линейный проход. На практике массив остается небольшим — как правило, он содержит два или три элемента, обозначающих дни, которые пользователь скрыл, — поэтому линейный просмотр более чем достаточен по скорости и позволяет избежать накладных расходов на хеш-таблицу или отсортированный поиск.

Далее мы определяем функцию «News_ToggleDayCollapsed», которая переключает состояние свернутости для конкретной даты. Сначала мы просматриваем массив в поисках уже существующей записи. Если мы найдем такую запись, то сдвигаем все последующие записи на одну позицию вниз, чтобы перезаписать ее, а затем уменьшаем размер массива с помощью функции «ArrayResize». Это стандартный шаблон «удаления из массива», который сохраняет порядок элементов, не оставляя пробелов. Если запись отсутствует, мы увеличиваем массив на один элемент и добавляем дату. Функция возвращает true, когда дата становится свернутой, и false — когда она становится развернутой, чтобы слой взаимодействия мог при необходимости скорректировать визуальное представление.

После вспомогательных функций сворачивания мы определяем функцию «News_BuildRowPlan» для построения плана отрисовки. Именно здесь плоский список событий превращается в структурированную последовательность строк, по которой слой отрисовки проходит линейно. Мы сбрасываем массив плана строк и инициализируем текущий индекс плана вместе с трекером последней встреченной даты, который изначально пуст, поэтому первое событие всегда создает заголовок дня.

Мы просматриваем каждое событие в массиве, доступном для отображения. Если дата события отличается от последней встреченной даты, мы выводим строку-разделитель дня, прежде чем продолжить. Чтобы сформировать надпись заголовка, мы сначала подсчитываем, сколько событий относится к этой дате, просматривая данные вперёд до тех пор, пока дата снова не изменится, — именно этот подсчёт и формирует суффикс «N событий» в надписи дня. Затем мы разбиваем временную метку события на структуру даты с помощью функции TimeToStruct, чтобы можно было по отдельности извлечь индекс дня недели, индекс месяца, число и год.

Имея компоненты даты, мы формируем метку с помощью массивов соответствий для названий дней недели и месяцев, получая строки вроде «Понедельник, 12 августа 2025 г. — 4 события». Условное выражение в конце выбирает «event» или «events» в зависимости от количества, поэтому текст выглядит грамматически естественно как для дней с одним событием, так и для дней с несколькими событиями. Мы увеличиваем размер массива плана строк, устанавливаем тип записи равным константе разделителя дня, сохраняем метку и ключ даты, а затем увеличиваем индекс плана. Поле индекса события устанавливается равным -1, поскольку строки дат не соответствуют какому-либо конкретному событию — здесь важен ключ даты.

После разделителя дня мы проверяем, входит ли текущая дата в множество свернутых дат. В этом случае мы используем оператор continue, чтобы пропустить добавление строки события, благодаря чему в план попадает только заголовок этого дня. Сами события полностью исключаются из отрисовки и проверки попадания до тех пор, пока пользователь снова не развернет день. Если дата не свернута, мы увеличиваем размер массива плана строк, устанавливаем тип равным константе строки события, сохраняем индекс элемента в массиве отображаемых событий и переходим дальше. Вот пример того, как это выглядит при отрисовке.

Сворачиваемые строки со значениями, учитывающими дату и время

Мы видим, что строки событий теперь сгруппированы по дате и их можно сворачивать, оставляя видимыми только те, которые важны пользователю. После внедрения этой функции столбец даты теряет полезность и становится неоднозначным. Мы оставили этот столбец, но вы можете удалить его, чтобы привести таблицу к виду календаря MQL5, поскольку дата теперь отображается в разделителе плана строк — решать вам. Далее мы переработаем информационную панель так, чтобы все элементы динамически подстраивались под размер. В частности, мы перемещаем модуль отрисовки новостей из верхней части заголовка в футер, чтобы всё было упорядочено. Мы реализуем всю логику как модуль рендеринга в отдельном файле на случай, если в будущем понадобится его расширить.

Отрисовка заголовка панели

В полосе заголовка отображаются название панели, текущее серверное время, счётчики событий, переключатель темы и кнопка закрытия. Мы привязываем кнопки к правому краю, а заголовок размещаем слева; при этом центрированная полоса состояния заполняет всё оставшееся между ними пространство.

//+------------------------------------------------------------------+
//|                                                  News Render.mqh |
//|                           Copyright 2026, Allan Munene Mutiiria. |
//|                                   https://t.me/Forex_Algo_Trader |
//+------------------------------------------------------------------+
#property copyright "Copyright 2026, Allan Munene Mutiiria."
#property link      "https://t.me/Forex_Algo_Trader"

//--- Include guard
#ifndef NEWS_RENDER_MQH
#define NEWS_RENDER_MQH

//--- Include core data definitions and state
#include "News Core.mqh"
//--- Include event loading and filter logic
#include "News Logic.mqh"

//+------------------------------------------------------------------+
//| Global Canvas Instances                                          |
//+------------------------------------------------------------------+
CCanvas g_news_canv;    // Main dashboard canvas
CCanvas g_news_canvSep; // Separator lines overlay canvas (separate chart object)
bool    g_news_canvSepExists = false; // Separator canvas created flag
#define NEWS_CANVAS_NAME_SEP "NewsCanvasSeparators" // Separator canvas object name

//+------------------------------------------------------------------+
//| Header Button Rectangle Caches                                   |
//+------------------------------------------------------------------+
int g_news_closeL = 0, g_news_closeT = 0, g_news_closeR = 0, g_news_closeB = 0; // Close button bounds
int g_news_themeL = 0, g_news_themeT = 0, g_news_themeR = 0, g_news_themeB = 0; // Theme button bounds

//+------------------------------------------------------------------+
//| Filter Toggle Rectangle Caches                                   |
//+------------------------------------------------------------------+
int g_news_currTglL = 0, g_news_currTglT = 0, g_news_currTglR = 0, g_news_currTglB = 0; // Currency toggle bounds
int g_news_impTglL  = 0, g_news_impTglT  = 0, g_news_impTglR  = 0, g_news_impTglB  = 0; // Impact toggle bounds
int g_news_timeTglL = 0, g_news_timeTglT = 0, g_news_timeTglR = 0, g_news_timeTglB = 0; // Time toggle bounds

//+------------------------------------------------------------------+
//| Currency Chip Rectangle Caches                                   |
//+------------------------------------------------------------------+
int g_news_currL[NEWS_CURR_COUNT]; // Currency chip left bounds
int g_news_currT[NEWS_CURR_COUNT]; // Currency chip top bounds
int g_news_currR[NEWS_CURR_COUNT]; // Currency chip right bounds
int g_news_currB[NEWS_CURR_COUNT]; // Currency chip bottom bounds

//+------------------------------------------------------------------+
//| Impact Chip Rectangle Caches                                     |
//+------------------------------------------------------------------+
int g_news_impL[NEWS_IMPACT_COUNT]; // Impact chip left bounds
int g_news_impT[NEWS_IMPACT_COUNT]; // Impact chip top bounds
int g_news_impR[NEWS_IMPACT_COUNT]; // Impact chip right bounds
int g_news_impB[NEWS_IMPACT_COUNT]; // Impact chip bottom bounds

//+------------------------------------------------------------------+
//| Event Row Rectangle Caches                                       |
//+------------------------------------------------------------------+
//--- Sized for max dashboard height (~900px yields about 25 rows); keep headroom
#define NEWS_MAX_VISIBLE_ROWS 32
int g_news_rowL[NEWS_MAX_VISIBLE_ROWS];        // Row left bounds
int g_news_rowT[NEWS_MAX_VISIBLE_ROWS];        // Row top bounds
int g_news_rowR[NEWS_MAX_VISIBLE_ROWS];        // Row right bounds
int g_news_rowB[NEWS_MAX_VISIBLE_ROWS];        // Row bottom bounds
int g_news_rowEventIdx[NEWS_MAX_VISIBLE_ROWS]; // Row index into displayableEvents
int g_news_visibleRowCount = 0;                // Count of currently visible rows

//+------------------------------------------------------------------+
//| Revised Triangle Hot Zone Caches                                 |
//+------------------------------------------------------------------+
//--- cx == -1 means the row has no revised marker; interact layer sets the MT5 tooltip
int g_news_revTriCx[NEWS_MAX_VISIBLE_ROWS]; // Triangle center X per row (-1 if none)
int g_news_revTriCy[NEWS_MAX_VISIBLE_ROWS]; // Triangle center Y per row
int g_news_revisedHoverRow = -1;            // Visible row index currently hovered (-1 if none)

//+------------------------------------------------------------------+
//| Cursor Position State                                            |
//+------------------------------------------------------------------+
//--- Updated by interact layer on every mouse move; used to float resize handles
int g_news_cursorX = -1; // Last cursor X in dashboard-local coordinates
int g_news_cursorY = -1; // Last cursor Y in dashboard-local coordinates

//+------------------------------------------------------------------+
//| Remain Cell Fast-Path Cache                                      |
//+------------------------------------------------------------------+
//--- Lets the timer repaint only cells whose displayed string changed
int      g_news_remainCellL[NEWS_MAX_VISIBLE_ROWS];   // Remain cell left edge
int      g_news_remainCellT[NEWS_MAX_VISIBLE_ROWS];   // Remain cell top edge
int      g_news_remainCellW[NEWS_MAX_VISIBLE_ROWS];   // Remain cell width
int      g_news_remainCellH[NEWS_MAX_VISIBLE_ROWS];   // Remain cell height
color    g_news_remainCellBg[NEWS_MAX_VISIBLE_ROWS];  // Remain cell row background for repaint
string   g_news_remainLastStr[NEWS_MAX_VISIBLE_ROWS]; // Last displayed remain string
datetime g_news_remainEvTime[NEWS_MAX_VISIBLE_ROWS];  // Event datetime driving recompute

//+------------------------------------------------------------------+
//| Render dashboard header with title, server time, buttons         |
//+------------------------------------------------------------------+
void News_RenderHeader()
  {
//--- Fill header strip with flat background rectangle
   g_news_canv.FillRectangle(0, 0, NEWS_DASHBOARD_W - 1, NEWS_HEADER_H - 1,
                              ColorToARGB(g_news_headerBg, 255));
//--- Compute button dimensions anchored to the right edge
   const int btnH   = NEWS_HEADER_H - 7;
   const int themeW = btnH;
   const int closeW = (int)(btnH * 1.5);
//--- Set close button rect (rightmost, flush to dashboard right edge)
   g_news_closeR = NEWS_DASHBOARD_W;
   g_news_closeL = g_news_closeR - closeW;
   g_news_closeT = 0;
   g_news_closeB = g_news_closeT + btnH;
//--- Set theme toggle rect (immediately left of close button)
   g_news_themeR = g_news_closeL;
   g_news_themeL = g_news_themeR - themeW;
   g_news_themeT = 0;
   g_news_themeB = btnH;
//--- Compute title bounds and available width for truncation
   const string titleFull  = "MQL5 Economic Calendar";
   const int    titleStartX = NEWS_SIDE_PAD;
   const int    midGap      = 12;
   const int    rightAreaL  = g_news_themeL - 4;
   const int    titleMaxW   = (rightAreaL - titleStartX) / 2 - midGap;
   const int    titleH      = News_TextHeight("Arial Bold", NEWS_FONT_TITLE);
   const int    titleY      = (NEWS_HEADER_H - titleH) / 2;
//--- Stamp title with ellipsis fallback if space is tight
   const string titleFit = News_FitTextToWidth(titleFull, "Arial Bold", NEWS_FONT_TITLE, MathMax(0, titleMaxW));
   News_StampTextAA_Wrapper(titleFit, titleStartX, titleY, "Arial Bold", NEWS_FONT_TITLE, g_news_titleText);
   const int titleEndX = titleStartX + News_TextWidth(titleFit, "Arial Bold", NEWS_FONT_TITLE);
//--- Render middle status text (server time + event counts) when there is room
   const int midAvailL = titleEndX + midGap;
   const int midAvailR = rightAreaL - 4;
   const int midAvailW = midAvailR - midAvailL;
   if(midAvailW > 40)
     {
      //--- Build status string and center it in the available band
      string countsStr = "Total: " + IntegerToString(g_news_totalFiltered) + "/" + IntegerToString(g_news_totalConsidered);
      string serverStr = "Server: " + TimeToString(TimeCurrent(), TIME_DATE | TIME_SECONDS);
      string mid       = serverStr + "    |    " + countsStr;
      string midFit    = News_FitTextToWidth(mid, "Arial", NEWS_FONT_HEADING, midAvailW);
      const int midW   = News_TextWidth(midFit, "Arial", NEWS_FONT_HEADING);
      const int midX   = midAvailL + (midAvailW - midW) / 2;
      const int midY   = (NEWS_HEADER_H - News_TextHeight("Arial", NEWS_FONT_HEADING)) / 2;
      News_StampTextAA_Wrapper(midFit, midX, midY, "Arial", NEWS_FONT_HEADING, g_news_subText);
     }
//--- Draw theme button hover fill
   if(g_news_hover == NEWS_HOV_THEME)
     {
      const color hovBg = News_HoverForBg(g_news_headerBg);
      g_news_canv.FillRectangle(g_news_themeL, g_news_themeT, g_news_themeR - 1, g_news_themeB - 1,
                                 ColorToARGB(hovBg, 255));
     }
//--- Stamp theme toggle glyph centered in its button area
   const string themeGlyph = "\x5B";
   const int    thW         = News_TextWidth(themeGlyph, "Wingdings", 14);
   const int    thH         = News_TextHeight("Wingdings", 14);
   News_StampTextAA_Wrapper(themeGlyph, g_news_themeL + (themeW - thW) / 2,
                          g_news_themeT + (btnH - thH) / 2, "Wingdings", 14, g_news_titleText);
//--- Draw close button hover fill and set foreground color
   color cFg = (g_news_hover == NEWS_HOV_CLOSE) ? g_news_closeColorHover : g_news_closeColor;
   if(g_news_hover == NEWS_HOV_CLOSE)
      g_news_canv.FillRectangle(g_news_closeL, g_news_closeT, g_news_closeR - 1, g_news_closeB - 1,
                                 ColorToARGB(g_news_closeBgHover, 255));
//--- Stamp close glyph centered in its button area
   const int xW = News_TextWidth(NEWS_GLYPH_CLOSE, "Webdings", 14);
   const int xH = News_TextHeight("Webdings", 14);
   News_StampTextAA_Wrapper(NEWS_GLYPH_CLOSE, g_news_closeL + (closeW - xW) / 2,
                          g_news_closeT + (btnH - xH) / 2, "Webdings", 14, cFg);
//--- Draw header bottom border line
   g_news_canv.LineHorizontal(0, NEWS_DASHBOARD_W - 1, NEWS_HEADER_H - 1,
                               ColorToARGB(g_news_border, 255));
  }

Мы определяем функцию «News_RenderHeader» для отрисовки всей полосы заголовка. Для начала мы заполняем фон полосы плоским прямоугольником с помощью метода FillRectangle объекта canvas, передавая в него границы и цвет фона заголовка, преобразованный в формат ARGB с помощью функции ColorToARGB. В ходе преобразования добавляется альфа-канал, необходимый для пиксельного формата canvas.

Далее мы вычисляем размеры кнопок, привязанных к правому краю. Мы делаем кнопку темы квадратной, со стороной по высоте кнопки, а кнопке закрытия задаём ширину в 1,5 раза больше базовой, чтобы у значка закрытия было достаточно свободного пространства. Затем мы выравниваем прямоугольник кнопки закрытия вплотную к правому краю панели и размещаем прямоугольник переключателя темы непосредственно слева от него. Эти прямоугольники кэшируются в переменных уровня модуля, чтобы механизм проверки попадания в слое взаимодействия мог позже использовать их без повторного вычисления.

Затем мы вычисляем границы области заголовка. Полное название — это имя программы в виде строки, и мы измеряем его высоту с помощью функции «News_TextHeight», чтобы выровнять его по вертикали по центру. Заголовок может занимать не более половины пространства между своей начальной позицией и группой кнопок справа, за вычетом зазора, отделяющего его от центрированного текста состояния. Когда даже этого места не хватает, мы используем функцию «News_FitTextToWidth», чтобы обрезать заголовок с добавлением многоточия, и он аккуратно поместился. Мы выводим полученную строку с помощью вспомогательной функции «News_StampTextAA_Wrapper» — это процедура вывода жирного текста со сглаживанием, которую мы используем по всей панели.

После заголовка мы отрисовываем центрированную полосу состояния, если для неё достаточно места. Мы формируем две строки: одну — с количеством событий в формате «filtered/considered», а другую — с текущим серверным временем, полученным функцией «TimeToString» с форматированием даты и секунд; затем объединяем их вертикальной чертой-разделителем. Мы подгоняем объединённую строку под доступную среднюю полосу, измеряем её итоговую ширину и выводим её с центрированием по горизонтали и вертикали.

Далее следует переключатель темы. Когда код состояния наведения соответствует слоту кнопки темы, мы рисуем под ней фон с оттенком наведения, используя функцию «News_HoverForBg» для получения чуть более светлой или чуть более тёмной заливки. Затем мы размещаем глиф из набора Wingdings по центру области кнопки в качестве значка-индикатора темы.

Кнопка «Закрыть» повторяет эту схему. Мы выбираем цвет переднего плана глифа закрытия в зависимости от состояния наведения: яркий цвет наведения, когда курсор находится над ним, и приглушённый цвет покоя в остальных случаях; красный фон наведения рисуем только в активном состоянии. Глиф закрытия — это символ из набора Webdings, заданный в виде константы; он выводится по центру области кнопки выбранным цветом переднего плана.

В заключение мы рисуем горизонтальную линию толщиной в один пиксель по нижнему краю заголовка с помощью метода LineHorizontal на canvas. Этот разделитель задаёт чёткую визуальную границу между полосой заголовка и расположенной ниже строкой фильтров. Для отрисовки переключателей фильтров мы используем следующий подход.

Отрисовка главных переключателей фильтров

Строка фильтров расположена непосредственно под заголовком и содержит три главных переключателя: «Валюта», «Важность» и «Время». Каждый переключатель включает или выключает соответствующую группу фильтров, а надписи на кнопках адаптируются к доступной ширине, чтобы оставаться читаемыми при любом размере панели.

//+------------------------------------------------------------------+
//| Render three filter master toggle buttons                        |
//+------------------------------------------------------------------+
void News_RenderFilterToggles()
  {
   const int rowY    = NEWS_HEADER_H + 4;
   const int btnH    = NEWS_FILTER_H - 8;
   const int gap     = 6;
   const int btnMaxW = 110;
   const int btnMinW = 50;
//--- Stamp "Filters:" label on the left
   const int lblW = News_TextWidth("Filters:", "Arial Bold", NEWS_FONT_HEADING);
   News_StampTextAA_Wrapper("Filters:", NEWS_SIDE_PAD,
                          rowY + (btnH - News_TextHeight("Arial Bold", NEWS_FONT_HEADING)) / 2,
                          "Arial Bold", NEWS_FONT_HEADING, g_news_subText);
//--- Compute per-button width within available area, clamped to min/max
   const int areaL = NEWS_SIDE_PAD + lblW + 8;
   const int areaR = NEWS_DASHBOARD_W - NEWS_SIDE_PAD;
   const int areaW = areaR - areaL;
   int btnW = (areaW - 2 * gap) / 3;
   if(btnW > btnMaxW) btnW = btnMaxW;
   if(btnW < btnMinW) btnW = btnMinW;
//--- Right-align the three-button cluster within the available area
   const int totalW = btnW * 3 + gap * 2;
   int startX = areaR - totalW;
//--- Define per-button label sets and state flags
   const string nounsOn[]  = {"ON Currency", "ON Impact", "ON Time"};
   const string nounsOff[] = {"OFF Currency", "OFF Impact", "OFF Time"};
   const string shortOn[]  = {"Currency", "Impact", "Time"};
   const string shortOff[] = {"Currency", "Impact", "Time"};
   const bool   onFlags[]  = {g_news_filterCurrencyOn, g_news_filterImpactOn, g_news_filterTimeOn};
   const int    hovCodes[] = {NEWS_HOV_FILTER_CURR, NEWS_HOV_FILTER_IMP, NEWS_HOV_FILTER_TIME};
//--- Render each of the three toggle buttons
   for(int i = 0; i < 3; i++)
     {
      const int bL = startX;
      const int bT = rowY;
      const int bR = startX + btnW;
      const int bB = rowY + btnH;
      //--- Cache button rect for hit-tester
      if(i == 0) { g_news_currTglL = bL; g_news_currTglT = bT; g_news_currTglR = bR; g_news_currTglB = bB; }
      if(i == 1) { g_news_impTglL  = bL; g_news_impTglT  = bT; g_news_impTglR  = bR; g_news_impTglB  = bB; }
      if(i == 2) { g_news_timeTglL = bL; g_news_timeTglT = bT; g_news_timeTglR = bR; g_news_timeTglB = bB; }
      //--- Compute background and foreground colors based on on/hover state
      color bg = onFlags[i] ? g_news_chipOnBg : g_news_chipOffBg;
      color fg = onFlags[i] ? g_news_chipOnText : g_news_chipOffText;
      if(g_news_hover == hovCodes[i]) bg = News_HoverForBg(bg);
      //--- Draw button background and border
      News_FillRoundRect(g_news_canv, bL, bT, btnW, btnH, 6, ColorToARGB(bg, 255));
      News_DrawRoundRectBorder(g_news_canv, bL, bT, btnW, btnH, 6, 1,
                               ColorToARGB(News_BorderForBg(bg), 255));
      //--- Select the best label that fits: full, short, then ellipsized
      const string lblFull  = onFlags[i] ? nounsOn[i]  : nounsOff[i];
      const string lblShort = onFlags[i] ? shortOn[i]  : shortOff[i];
      const int    innerPad = 8;
      const int    avail    = btnW - 2 * innerPad;
      string lbl;
      if(News_TextWidth(lblFull, "Arial Bold", NEWS_FONT_BUTTON) <= avail)
         lbl = lblFull;
      else if(News_TextWidth(lblShort, "Arial Bold", NEWS_FONT_BUTTON) <= avail)
         lbl = lblShort;
      else
         lbl = News_FitTextToWidth(lblShort, "Arial Bold", NEWS_FONT_BUTTON, avail);
      //--- Center label inside button
      const int lW = News_TextWidth(lbl, "Arial Bold", NEWS_FONT_BUTTON);
      const int lH = News_TextHeight("Arial Bold", NEWS_FONT_BUTTON);
      News_StampTextAA_Wrapper(lbl, bL + (btnW - lW) / 2, bT + (btnH - lH) / 2,
                             "Arial Bold", NEWS_FONT_BUTTON, fg);
      startX += btnW + gap;
     }
  }

Мы определяем функцию «News_RenderFilterToggles» для отрисовки трёх главных кнопок фильтров. Сначала мы рассчитываем вертикальное расположение строки непосредственно под заголовком, высоту кнопки, которая оставляет вертикальные отступы с обеих сторон, горизонтальный зазор между кнопками, а также минимальную и максимальную ширину каждой кнопки. Минимальные и максимальные допустимые границы не дают кнопкам сжиматься до нечитаемой ширины на узкой панели или растягиваться до нелепо широких плашек на широкой панели.

Далее мы размещаем надпись «Фильтры:» у левого края строки. Мы измеряем её ширину с помощью функции «News_TextWidth», чтобы спланировать вокруг неё остальную геометрию строки, а затем используем вспомогательную функцию «News_StampTextAA_Wrapper», чтобы отрисовать надпись с вертикальным центрированием цветом второстепенного текста.

После надписи мы рассчитываем доступную область для трёх кнопок. Левый край располагается после надписи с небольшим свободным зазором, а правый край заканчивается у бокового отступа. Мы делим оставшуюся ширину на три, учитывая два зазора между кнопками, и ограничиваем полученное значение минимальными и максимальными допустимыми границами. Затем мы выравниваем группу из трёх кнопок по правому краю, вычисляя её общую ширину и сдвигая начальную позицию к правому краю.

Мы определяем надписи, которые могут отображаться на каждой кнопке. Каждый переключатель имеет полное название, например «ВКЛ. Валюта» или «ВЫКЛ. Время», со встроенным индикатором состояния, а также сокращённое название, например просто «Валюта», для случаев, когда места мало. Массив флагов состояния «включено» содержит указатели на три главных переключателя фильтров, а массив кодов наведения — константы проверки попадания, которые слой взаимодействия назначает каждой кнопке.

Цикл отрисовки обрабатывает все три кнопки. Для каждой кнопки мы вычисляем её прямоугольник, сохраняем этот прямоугольник в соответствующем слоте для проверки попадания и выбираем цвета фона и текста в зависимости от того, включён переключатель или выключен. Когда курсор находится над этой конкретной кнопкой, мы изменяем фон с помощью функции «News_HoverForBg», чтобы получить слегка оттенённый вариант для состояния наведения. Затем мы отрисовываем кнопку в виде закруглённого прямоугольника с помощью вспомогательной функции «News_FillRoundRect», после чего добавляем тонкую рамку, нарисованную вспомогательной функцией «News_DrawRoundRectBorder». Цвет рамки берётся из функции «News_BorderForBg», которая создаёт дополняющую обводку, хорошо читаемую на фоне заливки.

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

ОТРИСОВАННАЯ ИНФОРМАЦИОННАЯ ПАНЕЛЬ

Теперь, когда это сделано, нам нужно обработать взаимодействия с информационной панелью, такие как клики, изменение размера и перетаскивание, и это несложно, поскольку нам нужно всего лишь перенастроить существующую логику. Мы определили эту логику как отдельный модуль взаимодействия с возможностью дальнейшего расширения.

Преобразование координат и проверка попадания на уровне пикселей

В предыдущей части каждый элемент, по которому можно было щёлкнуть, представлял собой объект графика со своим собственным событием щелчка. Здесь каждая интерактивная область находится внутри одного Canvas, поэтому нам нужен способ преобразовать исходные координаты мыши на графике в локальные координаты canvas, а затем определить, какой элемент находится под курсором.

//+------------------------------------------------------------------+
//|                                                 News Interact.mqh |
//|                           Copyright 2026, Allan Munene Mutiiria. |
//|                                   https://t.me/Forex_Algo_Trader |
//+------------------------------------------------------------------+
#property copyright "Copyright 2026, Allan Munene Mutiiria."
#property link      "https://t.me/Forex_Algo_Trader"

//--- Include guard
#ifndef NEWS_INTERACT_MQH
#define NEWS_INTERACT_MQH

//--- Include core data definitions
#include "News Core.mqh"
//--- Include trade logic handlers
#include "News Logic.mqh"
//--- Include canvas rendering routines
#include "News Render.mqh"

//+------------------------------------------------------------------+
//| Interaction State                                                |
//+------------------------------------------------------------------+
bool g_news_scrollDragging = false; // Scrollbar thumb drag in progress flag

//+------------------------------------------------------------------+
//| Convert chart coordinates to canvas-local coordinates            |
//+------------------------------------------------------------------+
void News_ChartToCanvas(int chartX, int chartY, int &localX, int &localY)
  {
//--- Subtract dashboard origin to get canvas-local coordinates
   localX = chartX - g_news_dashboardX;
   localY = chartY - g_news_dashboardY;
  }

//+------------------------------------------------------------------+
//| Hit-test canvas-local coordinates and return hover code          |
//+------------------------------------------------------------------+
int News_HitTest(int lx, int ly)
  {
//--- Test right-edge resize hot zone along the full edge minus corner radii
   {
      const int cornerR    = 8;
      const int edgeMargin = 4;
      const int hotTop     = cornerR + edgeMargin;
      const int hotBot     = NEWS_DASHBOARD_H - cornerR - edgeMargin;
      if(lx >= g_news_dashW - NEWS_RESIZE_HOT_W && lx <= g_news_dashW
         && ly >= hotTop && ly <= hotBot)
         return NEWS_HOV_RESIZE_R;
   }
//--- Test bottom-edge resize hot zone along the full edge minus corner radii
   {
      const int cornerR    = 8;
      const int edgeMargin = 4;
      const int hotLeft    = cornerR + edgeMargin;
      const int hotRight   = NEWS_DASHBOARD_W - cornerR - edgeMargin;
      if(ly >= g_news_dashH - NEWS_RESIZE_HOT_H && ly <= g_news_dashH
         && lx >= hotLeft && lx <= hotRight)
         return NEWS_HOV_RESIZE_B;
   }
//--- Test theme toggle button
   if(News_PointInRect(lx, ly, g_news_themeL, g_news_themeT,
                       g_news_themeR - g_news_themeL, g_news_themeB - g_news_themeT))
      return NEWS_HOV_THEME;
//--- Test close button
   if(News_PointInRect(lx, ly, g_news_closeL, g_news_closeT,
                       g_news_closeR - g_news_closeL, g_news_closeB - g_news_closeT))
      return NEWS_HOV_CLOSE;
//--- Test currency filter master toggle
   if(News_PointInRect(lx, ly, g_news_currTglL, g_news_currTglT,
                       g_news_currTglR - g_news_currTglL, g_news_currTglB - g_news_currTglT))
      return NEWS_HOV_FILTER_CURR;
//--- Test impact filter master toggle
   if(News_PointInRect(lx, ly, g_news_impTglL, g_news_impTglT,
                       g_news_impTglR - g_news_impTglL, g_news_impTglB - g_news_impTglT))
      return NEWS_HOV_FILTER_IMP;
//--- Test time filter master toggle
   if(News_PointInRect(lx, ly, g_news_timeTglL, g_news_timeTglT,
                       g_news_timeTglR - g_news_timeTglL, g_news_timeTglB - g_news_timeTglT))
      return NEWS_HOV_FILTER_TIME;
//--- Test each currency chip
   for(int i = 0; i < NEWS_CURR_COUNT; i++)
     {
      if(News_PointInRect(lx, ly, g_news_currL[i], g_news_currT[i],
                          g_news_currR[i] - g_news_currL[i], g_news_currB[i] - g_news_currT[i]))
         return NEWS_HOV_CURR_BASE + i;
     }
//--- Test each impact pill
   for(int i = 0; i < NEWS_IMPACT_COUNT; i++)
     {
      if(News_PointInRect(lx, ly, g_news_impL[i], g_news_impT[i],
                          g_news_impR[i] - g_news_impL[i], g_news_impB[i] - g_news_impT[i]))
         return NEWS_HOV_IMP_BASE + i;
     }
//--- Test each visible event row
   for(int r = 0; r < g_news_visibleRowCount; r++)
     {
      if(News_PointInRect(lx, ly, g_news_rowL[r], g_news_rowT[r],
                          g_news_rowR[r] - g_news_rowL[r], g_news_rowB[r] - g_news_rowT[r]))
         return NEWS_HOV_ROW_BASE + r;
     }
//--- Treat the header strip as the drag region when no button matched
   if(ly >= 0 && ly < NEWS_HEADER_H) return NEWS_HOV_DRAG;
   return NEWS_HOV_NONE;
  }

Мы определяем функцию «News_ChartToCanvas», чтобы преобразовывать координаты мыши в пространстве графика в локальные координаты Canvas, вычитая начало координат панели в её левом верхнем углу. В результате получается пара координат, в которой точка (0, 0) находится в левом верхнем углу панели независимо от того, куда панель была перетащена на графике; именно эту систему координат использует каждый кэшированный прямоугольник в слое отрисовки.

После вспомогательной функции для координат мы определяем функцию «News_HitTest», которая определяет, над каким интерактивным элементом в данный момент находится курсор. Функция проходит по всем известным интерактивным областям в порядке приоритета и при первом же совпадении возвращает константу кода наведения. Здесь важен приоритет, поскольку некоторые области перекрываются: например, «горячие зоны» изменения размера расположены у самых краёв панели и частично перекрывают область перетаскивания заголовка, поэтому мы проверяем их первыми, чтобы изменение размера по краю имело приоритет над перетаскиванием панели, когда курсор находится прямо на углу.

Начнём с «горячей зоны» изменения размера по правому краю. Мы задаём узкую вертикальную полосу вдоль правого края панели, но делаем для неё отступ сверху и снизу на величину радиуса угла плюс небольшой зазор. Этот отступ важен: без него пользователь, пытаясь перетащить панель за её закруглённый верхний правый или нижний правый угол, случайно запустил бы изменение размера. Исключая углы из зоны изменения размера, мы сохраняем естественное перетаскивание за угол и при этом оставляем для края изменения размера длинную, удобную для попадания область вдоль его прямого среднего участка.

Далее мы проверяем «горячую зону» изменения размера по нижнему краю, применяя ту же логику исключения углов по горизонтали. Нижняя полоса проходит вдоль нижнего края панели и имеет отступ с обеих сторон на величину радиуса угла и небольшой зазор, поэтому закруглённые нижний левый и нижний правый углы относятся к панели, а не к маркеру изменения размера.

После зон изменения размера мы проверяем кнопки заголовка. Мы используем вспомогательную функцию «News_PointInRect», чтобы проверить, попадает ли курсор внутрь каждого кэшированного прямоугольника, передавая ей локальные координаты, а также левую координату, верхнюю координату, ширину и высоту прямоугольника. Переключатель темы и кнопка закрытия проверяются по очереди, и при совпадении каждый из них возвращает свою отдельную константу кода наведения.

Затем мы по одному проверяем три главных переключателя фильтров, возвращая соответствующий код наведения для фильтра по валюте, важности или времени при совпадении. После переключателей мы проходим по всем восьми чипам валют, возвращая базовый код наведения плюс индекс чипа в массиве, чтобы диспетчер действий в обработчике щелчка точно знал, по какой валюте было попадание. Та же схема применяется к четырём плашкам важности.

После чипов мы проходим по каждой видимой в данный момент строке события и проверяем её относительно позиции курсора. Переменная «g_news_visibleRowCount» хранит число строк, которые проход отрисовки фактически отрисовал в последнем кадре, а границы каждой строки кэшируются в массивах прямоугольников строк. При обнаружении совпадения мы возвращаем код наведения на основе строки плюс индекс видимой строки, чтобы обработчик действий мог найти событие или разделитель дня, по которому щёлкнул пользователь.

Наконец, если ни один конкретный элемент не подошел, мы проверяем, попадает ли курсор внутрь полосы заголовка. Если да, мы возвращаем код наведения для перетаскивания, рассматривая весь заголовок как область захвата для перетаскивания. В противном случае мы возвращаем код «none», чтобы указать, что курсор находится над неактивной областью панели. Далее мы займёмся разработкой логики диспетчера действий по нажатию.

Диспетчеризация действий по щелчку на основе кодов наведения

Как только проверка попадания определяет код наведения, этот диспетчер преобразует его в фактическое изменение состояния и перерисовку.

//+------------------------------------------------------------------+
//| Handle action triggered by hover code                            |
//+------------------------------------------------------------------+
void News_HandleAction(int hov, bool doubleClick = false)
  {
//--- Close dashboard on close button click
   if(hov == NEWS_HOV_CLOSE)
     {
      g_news_dashboardVisible = false;
      News_DestroyCanvas();
      return;
     }
//--- Toggle dark/light theme on theme button click
   if(hov == NEWS_HOV_THEME)
     {
      News_ApplyTheme(!g_news_darkTheme);
      News_RenderAll();
      ChartRedraw();
      return;
     }
//--- Toggle currency filter master switch
   if(hov == NEWS_HOV_FILTER_CURR)
     {
      g_news_filterCurrencyOn = !g_news_filterCurrencyOn;
      g_news_filtersChanged   = true;
      News_RefreshEvents();
      News_RenderAll();
      ChartRedraw();
      return;
     }
//--- Toggle impact filter master switch
   if(hov == NEWS_HOV_FILTER_IMP)
     {
      g_news_filterImpactOn = !g_news_filterImpactOn;
      g_news_filtersChanged = true;
      News_RefreshEvents();
      News_RenderAll();
      ChartRedraw();
      return;
     }
//--- Toggle time range filter master switch
   if(hov == NEWS_HOV_FILTER_TIME)
     {
      g_news_filterTimeOn   = !g_news_filterTimeOn;
      g_news_filtersChanged = true;
      News_RefreshEvents();
      News_RenderAll();
      ChartRedraw();
      return;
     }
//--- Toggle individual currency chip selection
   if(hov >= NEWS_HOV_CURR_BASE && hov < NEWS_HOV_CURR_BASE + NEWS_CURR_COUNT)
     {
      const int idx = hov - NEWS_HOV_CURR_BASE;
      g_news_currSelected[idx] = !g_news_currSelected[idx];
      g_news_filtersChanged    = true;
      News_RefreshEvents();
      News_RenderAll();
      ChartRedraw();
      return;
     }
//--- Toggle individual impact pill selection
   if(hov >= NEWS_HOV_IMP_BASE && hov < NEWS_HOV_IMP_BASE + NEWS_IMPACT_COUNT)
     {
      const int idx = hov - NEWS_HOV_IMP_BASE;
      g_news_impactSelected[idx] = !g_news_impactSelected[idx];
      g_news_filtersChanged      = true;
      News_RefreshEvents();
      News_RenderAll();
      ChartRedraw();
      return;
     }
//--- Dispatch row click by row kind
   if(hov >= NEWS_HOV_ROW_BASE && hov < NEWS_HOV_ROW_BASE + NEWS_MAX_VISIBLE_ROWS)
     {
      const int rIdx = hov - NEWS_HOV_ROW_BASE;
      if(rIdx < g_news_visibleRowCount)
        {
         const int planIdx = g_news_rowEventIdx[rIdx];
         if(planIdx >= 0 && planIdx < ArraySize(g_news_rowPlan))
           {
            const int kind = g_news_rowPlan[planIdx].kind;
            //--- Toggle day collapse on double-click of a day separator row
            if(kind == NEWS_ROW_KIND_DAY)
              {
               if(doubleClick)
                 {
                  const string dateKey = g_news_rowPlan[planIdx].dateKey;
                  News_ToggleDayCollapsed(dateKey);
                  News_BuildRowPlan();
                  News_ScrollClamp(g_news_tableScroll);
                 }
              }
            //--- Show event details in toast on single event row click
            else if(kind == NEWS_ROW_KIND_EVENT)
              {
               const int evIdx = g_news_rowPlan[planIdx].eventIdx;
               if(evIdx >= 0 && evIdx < ArraySize(g_news_displayableEvents))
                 {
                  const NewsEvent ev  = g_news_displayableEvents[evIdx];
                  string msg          = ev.eventDate + " " + ev.eventTime + "  "
                                        + ev.currency + "  " + ev.event;
                  News_ShowToast(msg, false);
                 }
              }
           }
        }
      News_RenderAll();
      ChartRedraw();
      return;
     }
  }

Мы определяем функцию «News_HandleAction» как единый диспетчер для всех кликабельных элементов. Ветка «close» скрывает панель и уничтожает canvas с помощью «News_DestroyCanvas». Ветка темы переключает флаг темы, повторно применяет палитру через «News_ApplyTheme» и запускает перерисовку. Каждая из трёх ветвей главных фильтров переключает своё булево значение, помечает фильтры как требующие обновления и перед перерисовкой заново строит набор отображаемых элементов через «News_RefreshEvents».

Ветви отдельных чипов валют и плашек важности используют смещение кода наведения относительно базовой константы, чтобы вычислить индекс массива, затем переключают флаг выбора для соответствующего слота и выполняют такое же обновление.

Именно в ветке обработки строки двойной щелчок имеет значение. Мы преобразуем код наведения в индекс видимой строки, находим соответствующую запись в плане строк и считываем её тип. При двойном щелчке по разделителю дня мы вызываем «News_ToggleDayCollapsed» с ключом даты, перестраиваем план строк и приводим положение прокрутки к допустимым границам. В строке события мы извлекаем запись события и передаем строку с кратким описанием в «News_ShowToast», чтобы пользователь получил экранное подтверждение щелчка. Для создания canvas и изменения его размера мы используем следующий подход.

Жизненный цикл Canvas с изменением размера на основе видимой области

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

//+------------------------------------------------------------------+
//| Create main and separator canvas bitmap labels                   |
//+------------------------------------------------------------------+
bool News_CreateCanvas()
  {
//--- Return early if canvas already exists
   if(g_news_canvasExists) return true;
//--- Create main canvas at maximum dimensions for crop-based resizing
   if(!g_news_canv.CreateBitmapLabel(NEWS_CANVAS_NAME,
                                      g_news_dashboardX, g_news_dashboardY,
                                      NEWS_DASHBOARD_W_MAX, NEWS_DASHBOARD_H_MAX,
                                      COLOR_FORMAT_ARGB_NORMALIZE))
     {
      Print("News_CreateCanvas: failed - ", GetLastError());
      return false;
     }
//--- Configure main canvas object properties
   ObjectSetInteger(0, NEWS_CANVAS_NAME, OBJPROP_BACK,      false);
   ObjectSetInteger(0, NEWS_CANVAS_NAME, OBJPROP_SELECTABLE, false);
   ObjectSetInteger(0, NEWS_CANVAS_NAME, OBJPROP_HIDDEN,     true);
   ObjectSetInteger(0, NEWS_CANVAS_NAME, OBJPROP_ZORDER,     1000);
//--- Suppress default tooltip; interact layer sets it dynamically for revised-value hover
   ObjectSetString(0,  NEWS_CANVAS_NAME, OBJPROP_TOOLTIP, "\n");
//--- Crop visible area to current dashboard size
   ObjectSetInteger(0, NEWS_CANVAS_NAME, OBJPROP_XSIZE, g_news_dashW);
   ObjectSetInteger(0, NEWS_CANVAS_NAME, OBJPROP_YSIZE, g_news_dashH);
   g_news_canvasExists = true;
//--- Create separators overlay canvas at same maximum dimensions
   if(g_news_canvSep.CreateBitmapLabel(NEWS_CANVAS_NAME_SEP,
                                        g_news_dashboardX, g_news_dashboardY,
                                        NEWS_DASHBOARD_W_MAX, NEWS_DASHBOARD_H_MAX,
                                        COLOR_FORMAT_ARGB_NORMALIZE))
     {
      //--- Configure separator canvas object properties
      ObjectSetInteger(0, NEWS_CANVAS_NAME_SEP, OBJPROP_BACK,       false);
      ObjectSetInteger(0, NEWS_CANVAS_NAME_SEP, OBJPROP_SELECTABLE, false);
      ObjectSetInteger(0, NEWS_CANVAS_NAME_SEP, OBJPROP_HIDDEN,     true);
      ObjectSetString(0,  NEWS_CANVAS_NAME_SEP, OBJPROP_TOOLTIP,    "\n");
      ObjectSetInteger(0, NEWS_CANVAS_NAME_SEP, OBJPROP_ZORDER,     500);
      //--- Crop separator canvas to current dashboard size
      ObjectSetInteger(0, NEWS_CANVAS_NAME_SEP, OBJPROP_XSIZE, g_news_dashW);
      ObjectSetInteger(0, NEWS_CANVAS_NAME_SEP, OBJPROP_YSIZE, g_news_dashH);
      g_news_canvSepExists = true;
     }
   return true;
  }

//+------------------------------------------------------------------+
//| Destroy main canvas and separators overlay                       |
//+------------------------------------------------------------------+
void News_DestroyCanvas()
  {
//--- Destroy separator canvas first if it exists
   if(g_news_canvSepExists)
     {
      g_news_canvSep.Destroy();
      ObjectDelete(0, NEWS_CANVAS_NAME_SEP);
      g_news_canvSepExists = false;
     }
//--- Abort if main canvas was never created
   if(!g_news_canvasExists) return;
//--- Destroy main canvas and remove chart object
   g_news_canv.Destroy();
   ObjectDelete(0, NEWS_CANVAS_NAME);
   g_news_canvasExists = false;
   ChartRedraw();
  }

//+------------------------------------------------------------------+
//| Apply visible crop to both canvases without reallocating bitmaps |
//+------------------------------------------------------------------+
void News_ResizeCanvases(int newW, int newH)
  {
//--- Update visible crop on main canvas
   if(g_news_canvasExists)
     {
      ObjectSetInteger(0, NEWS_CANVAS_NAME, OBJPROP_XSIZE, newW);
      ObjectSetInteger(0, NEWS_CANVAS_NAME, OBJPROP_YSIZE, newH);
     }
//--- Update visible crop on separator canvas
   if(g_news_canvSepExists)
     {
      ObjectSetInteger(0, NEWS_CANVAS_NAME_SEP, OBJPROP_XSIZE, newW);
      ObjectSetInteger(0, NEWS_CANVAS_NAME_SEP, OBJPROP_YSIZE, newH);
     }
  }

Здесь мы определяем функцию «News_CreateCanvas», которая выделяет ресурсы для обеих bitmap-меток canvas. После защитной проверки с ранним выходом мы вызываем метод CreateBitmapLabel на основном canvas с максимальными шириной и высотой, а не с текущими размерами панели. Выделение с максимальным размером означает, что каждое последующее изменение размера будет дешёвым изменением видимой области, а не циклом уничтожения и повторного создания. Затем мы настраиваем свойства объекта графика: он не находится в фоне, не доступен для выбора, скрыт из списка объектов и имеет высокий Z-порядок, чтобы располагаться над другими объектами графика. Кроме того, мы подавляем всплывающую подсказку по умолчанию с помощью строки с символом новой строки, поскольку слой взаимодействия динамически задаёт её при наведении на пересмотренное значение. Свойства OBJPROP_XSIZE и «OBJPROP_YSIZE» задают начальную видимую область в соответствии с текущими размерами панели.

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

Функция «News_DestroyCanvas» удаляет оба canvas в обратном порядке — сначала разделительный, затем основной — с помощью метода Destroy и функции ObjectDelete. Мы сбрасываем флаги существования и вызываем ChartRedraw, чтобы объекты визуально исчезли с графика.

Наконец, функция «News_ResizeCanvases» применяет новую видимую область, не затрагивая базовые растровые изображения. Мы обновляем свойства «OBJPROP_XSIZE» и «OBJPROP_YSIZE» на обоих canvas. Именно благодаря этому перетаскивание для изменения размера в слое взаимодействия из предыдущего фрагмента кода воспринимается как мгновенное: никакого выделения памяти, никакого копирования, только изменение свойства объекта графика. Чтобы подключить эти функции к обработчикам событий, мы определяем эти обработчики здесь. Вот пример обработчика события таймера, поскольку остальные мы уже рассмотрели в предыдущей версии.

//+------------------------------------------------------------------+
//| Expert timer function                                            |
//+------------------------------------------------------------------+
void News_OnTimer()
  {
//--- Skip processing when dashboard is hidden
   if(!g_news_dashboardVisible) return;
//--- Manage toast lifecycle; force render while toast is alive
   bool needFullRender = false;
   bool toastAlive     = false;
   if(StringLen(g_news_toastText) > 0)
     {
      //--- Clear expired toast and flag a final render
      if(GetTickCount64() > g_news_toastExpiryMs)
        {
         g_news_toastText     = "";
         g_news_toastExpiryMs = 0;
         needFullRender       = true;
        }
      else
        {
         //--- Toast is still live; animate progress bar this tick
         toastAlive = true;
        }
     }
//--- Determine if a full render is due this tick
   static ulong s_lastFullMs = 0;
   const ulong nowMs = GetTickCount64();
//--- Render when toast is alive, just expired, or 5-second interval elapsed
   if(toastAlive || needFullRender || (nowMs - s_lastFullMs) >= 5000)
     {
      News_RenderAll();
      ChartRedraw();
      s_lastFullMs = nowMs;
      return;
     }
//--- Fast path: update only changed Remain cells to reduce CPU cost
   if(News_TickRemainCells()) ChartRedraw();
  }

Мы добавляем логику таймера в обработчик события «OnTimer» с помощью этой функции модуля «News_OnTimer». После защитной проверки видимости мы управляем жизненным циклом всплывающего уведомления. Когда toast-сообщение активно, мы сравниваем текущее значение счётчика тиков, полученное функцией GetTickCount64, с меткой времени истечения срока действия. Истёкшее всплывающее уведомление очищается и помечается для одной финальной отрисовки, чтобы оно исчезло с экрана, а активное всплывающее уведомление устанавливает флаг alive, поскольку его индикатор прогресса должен анимированно уменьшаться на этом тике.

Далее мы определяем, требуется ли полная отрисовка. Статическая метка времени последней полной отрисовки отслеживает, когда выполнялся последний полный проход. Мы вызываем «News_RenderAll», когда всплывающее уведомление активно, когда оно только что истекло или когда с момента последней полной отрисовки прошло 5 секунд. Интервал в 5 секунд покрывает медленно изменяющиеся области, такие как время сервера в заголовке и нижняя часть баннера обратного отсчёта.

Если ни одно из этих условий не выполняется, мы выбираем быстрый путь и вызываем функцию «News_TickRemainCells». Она проходит только по кэшированным ячейкам Remain, заново вычисляет строку для каждой из них и перерисовывает только те ячейки, текст которых изменился. Если перерисовывается хотя бы одна ячейка, функция возвращает true, и мы вызываем ChartRedraw, чтобы применить частичное обновление. Так столбец Remain обновляется каждые полсекунды без затрат на перерисовку всей панели каждый раз. Такую же логику мы применяем и к другим точкам входа модулей. Чтобы связать все эти модули, мы определяем базовый файл.

Основная точка входа

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

//+------------------------------------------------------------------+
//|                                MQL5 News Calendar EA PART 11.mq5 |
//|                           Copyright 2026, Allan Munene Mutiiria. |
//|                                   https://t.me/Forex_Algo_Trader |
//+------------------------------------------------------------------+
#property copyright "Copyright 2026, Allan Munene Mutiiria."
#property link      "https://t.me/Forex_Algo_Trader"
#property version   "1.00"
#property strict
#property description "MQL5 Economic News Calendar - Canvas-based Dashboard"

//+------------------------------------------------------------------+
//| Embedded Resources                                               |
//+------------------------------------------------------------------+
//--- Embed CSV resource for strategy tester mode
#resource "\\Files\\Database\\EconomicCalendar.csv" as string EconomicCalendarData

//+------------------------------------------------------------------+
//| Inputs - General Calendar Settings                               |
//+------------------------------------------------------------------+
input group "=== GENERAL CALENDAR SETTINGS ==="
input ENUM_TIMEFRAMES start_time       = PERIOD_H12; // Past time window for live mode
input ENUM_TIMEFRAMES end_time         = PERIOD_H12; // Future time window for live mode
input ENUM_TIMEFRAMES range_time       = PERIOD_H8;  // Time filter range from now
input bool            updateServerTime = true;       // Update server clock on header
input bool            debugLogging     = false;      // Print debug info to journal

//+------------------------------------------------------------------+
//| Inputs - Strategy Tester CSV Settings                            |
//+------------------------------------------------------------------+
input group "=== STRATEGY TESTER CSV SETTINGS ==="
input datetime StartDate = D'2026.01.01'; // Tester window start date
input datetime EndDate   = D'2026.05.12'; // Tester window end date

//+------------------------------------------------------------------+
//| Inputs - Auto-Trading Settings                                   |
//+------------------------------------------------------------------+
input group "=== AUTO-TRADING SETTINGS ==="

//+------------------------------------------------------------------+
//| Trade mode enumeration                                           |
//+------------------------------------------------------------------+
enum ENewsTradeMode
  {
   NEWS_TRADE_BEFORE,  // Trade before news event
   NEWS_TRADE_AFTER,   // Trade after news event
   NEWS_NO_TRADE,      // Do not trade on news
   NEWS_PAUSE_TRADING  // Pause trading around news
  };

input ENewsTradeMode tradeMode          = NEWS_TRADE_BEFORE; // Trade mode
input int            tradeOffsetHours   = 0;                 // Trade offset hours
input int            tradeOffsetMinutes = 5;                 // Trade offset minutes
input int            tradeOffsetSeconds = 0;                 // Trade offset seconds
input double         tradeLotSize       = 0.01;              // Trade lot size

//+------------------------------------------------------------------+
//| Module Includes                                                  |
//+------------------------------------------------------------------+
//--- Define sentinel to prevent modules from emitting their own externs
#define NEWS_COMPILED_FROM_MAIN
//--- Include core data and calendar loading logic
#include "News Core.mqh"
//--- Include trade signal and logic handlers
#include "News Logic.mqh"
//--- Include canvas rendering and dashboard drawing
#include "News Render.mqh"
//--- Include mouse, click, and chart interaction handlers
#include "News Interact.mqh"

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit()
  {
//--- Initialize all news modules and abort if setup fails
   if(!News_Init()) return INIT_FAILED;
//--- Start half-second timer for countdown and toast refresh
   EventSetMillisecondTimer(500);
//---
   return INIT_SUCCEEDED;
  }

//+------------------------------------------------------------------+
//| Expert deinitialization function                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
//--- Kill the periodic timer
   EventKillTimer();
//--- Clean up all news module resources
   News_Deinit();
  }

//+------------------------------------------------------------------+
//| Expert tick function                                             |
//+------------------------------------------------------------------+
void OnTick()
  {
//--- Delegate tick handling to the news logic module
   News_OnTick();
  }

//+------------------------------------------------------------------+
//| Expert chart event function                                      |
//+------------------------------------------------------------------+
void OnChartEvent(const int id, const long &lparam, const double &dparam, const string &sparam)
  {
//--- Delegate mouse, wheel, click, and drag events to interact module
   News_OnChartEvent(id, lparam, dparam, sparam);
  }

//+------------------------------------------------------------------+
//| Expert timer function                                            |
//+------------------------------------------------------------------+
void OnTimer()
  {
//--- Trigger countdown updates and expire stale toast notifications
   News_OnTimer();
  }
//+------------------------------------------------------------------+

Сначала мы встраиваем CSV-файл тестера стратегий с помощью директивы #resource, привязывая содержимое файла к строковому символу, который модуль логики считывает при активном режиме тестера. Затем группы входных параметров объявляют все настраиваемые пользователем параметры — временные окна, границы дат тестера, режим торговли и смещения, а также размер лота — при этом перечисление «ENewsTradeMode» объявлено непосредственно в главном файле, чтобы оно находилось в глобальной области видимости, общей для всех модулей.

После входных параметров мы определяем сторожевой макрос «NEWS_COMPILED_FROM_MAIN» и подключаем четыре модуля в порядке зависимостей: модуль ядра, модуль логики, модуль рендеринга, а затем модуль взаимодействия. Сторожевой макрос сообщает каждому модулю, что входные переменные уже объявлены в главном файле, поэтому модули пропускают собственные объявления extern через свои защитные блоки #ifndef.

Пять обработчиков событий — это короткие функции-обёртки, просто передающие вызовы дальше. Обработчик события OnInit вызывает функцию «News_Init», возвращает «INIT_FAILED» при сбое инициализации и запускает таймер с периодом полсекунды с помощью функции EventSetMillisecondTimer, после чего возвращает INIT_SUCCEEDED. Обработчик события «OnDeinit» останавливает таймер с помощью функции EventKillTimer и делегирует очистку функции «News_Deinit». Обработчики событий «OnTick», «OnChartEvent» и OnTimer передают свои параметры в соответствующие функции модулей. Благодаря такой структуре входной файл остаётся короче ста строк, а при будущей доработке функций изменения затрагивают только тот модуль, который отвечает за соответствующую задачу. Осталось только протестировать программу, и этому будет посвящен следующий раздел.


Визуализация

После прикрепления программы к графику MetaTrader 5 панель отображается в размере по умолчанию. Столбцы масштабируются вместе с панелью, а таблица событий заполняется данными из потока в реальном времени.

GIF-анимация бэктеста

В ходе тестирования переключатель темы мгновенно менял палитру без мерцания, а при перетаскивании правого и нижнего краев размер панели плавно изменялся, причем переполнение принимал на себя столбец Event. Двойной щелчок по разделителю дня сворачивал эту группу, столбец Remain обновлялся каждые полсекунды по ускоренной ветке таймера, а всплывающее уведомление с уменьшающейся полосой прогресса подтверждало каждую предновостную сделку.


Заключение

В заключение мы переработали панель «Экономический календарь MQL5», преобразовав ее из монолитной панели, основанной на объектах, в модульную систему на базе Canvas, распределенную по четырем специализированным файлам. Новая программа поддерживает палитру с двумя темами, изменение размера перетаскиванием правого и нижнего краев, сворачиваемые группы дней, маркеры пересмотренных предыдущих значений со всплывающими подсказками при наведении, столбец Remain с обновляемым в реальном времени обратным отсчетом и всплывающие уведомления с подтверждениями сделок. Устранение дубликатов сделок теперь выполняется по стабильным идентификаторам календарных событий, поиск событий-кандидатов кэшируется, чтобы не выполнять повторное сканирование на каждом тике, а ускоренная ветка таймера перерисовывает только те ячейки Remain, в которых отображаемые строки действительно изменились. Прочитав эту статью, вы сможете:

  • Разбивать монолитную программу MQL5 на модули ядра, логики, рендеринга и взаимодействия, используя сторожевой define-макрос для согласования объявлений входных параметров между файлами.
  • Создавать панель на базе Canvas, размер которой изменяется за счет изменения видимой области, а не перевыделения растрового буфера, при этом ширина столбцов масштабируется пропорционально текущей ширине панели во время выполнения.
  • Реализовывать сворачиваемые группы дней, кэш событий-кандидатов и полусекундную ускоренную ветку таймера, которая перерисовывает только те ячейки, содержимое которых действительно изменилось.

В следующей части мы заменим встроенный ресурс CSV на слой базы данных SQLite, чтобы события календаря сохранялись между перезапусками и становились доступными тестеру стратегий для автономного бэктестинга. Следите за продолжением.


Вложения

№ п/п
Название
Тип
Описание
1 MQL5 News Calendar EA PART 11.mq5 советник
Основная точка входа программы, которая встраивает CSV-ресурс, объявляет входные параметры пользователя и перечисление режимов торговли, подключает заголовочные файлы четырёх модулей и передаёт каждое событие в соответствующую функцию модуля
2 News Core.mqh Подключаемый файл
Определяет константы верстки, размеры шрифтов, коды глифов, палитру двух тем, структуры событий и плана строк, структуру состояния прокрутки, вспомогательный класс примитивов, а также утилиты форматирования значений и времени
3 News Interact.mqh Подключаемый файл
Маршрутизирует события мыши, колёсика и графика через преобразование координат, проверку попадания на уровне пикселей, распределение действий, обработку перетаскивания панели и изменения размера по краям, а также вспомогательные функции жизненного цикла Canvas
4 News Logic.mqh Подключаемый файл
Загружает события из встроенного CSV-файла в режиме тестера или из API календаря в реальном времени, применяет фильтры по валюте, важности и времени, формирует план строк со сворачиваемыми группами дней, а также запускает поиск кандидатов на сделки и исполнение ордеров
5 News Render.mqh Подключаемый файл
Отрисовывает всю панель — заголовок, переключатели фильтров, чипы валют и плашки важности, заголовок таблицы с наложенным разделителем, строки событий с маркерами пересмотренных значений и ячейками Remain, полосу прокрутки, баннер обратного отсчёта и всплывающее уведомление
6 EconomicCalendar.csv Ресурсный файл События исторического экономического календаря, встроенные в скомпилированную программу на этапе компиляции с помощью директивы «#resource». Перед компиляцией файл необходимо вручную поместить в подпапку «MQL5\Files\Database\» — если подпапка «Database» отсутствует, создайте её. Этот файл необходим на этапе компиляции, и без него компиляция завершится с ошибкой. Столбцы: дата, время, валюта, название события, важность, фактическое значение, прогноз, предыдущее значение


Проект поддерживается в Algo Forge: https://forge.mql5.io/29210372/Article-22597-MQL5-Economic-Calendar-Modular-Canvas-Dashboard

Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/22597

Прикрепленные файлы |
News_Core.mqh (76.58 KB)
News_Interact.mqh (32.22 KB)
News_Logic.mqh (27.15 KB)
News_Render.mqh (51.65 KB)
EconomicCalendar.csv (716.12 KB)
Построение системы обнаружения дивергенций (Часть II): Пользовательский индикатор Adaptive SuperTrend Построение системы обнаружения дивергенций (Часть II): Пользовательский индикатор Adaptive SuperTrend
В статье мы усовершенствуем индикатор SuperTrend, добавив модуль обнаружения дивергенций (MPO4 или RSI), который динамически уменьшает множитель ATR при ослаблении моментума. В ней рассматриваются формула сжатия, перенос состояния без перерисовки с использованием отдельных буферов, а также пошаговая реализация индикатора на языке MQL5 с отображением на ценовом графике. Вы узнаете, как интерпретировать стрелки и переключения линии, настраивать входные параметры и применять индикатор для трейлинга по правилам и более раннего подтверждения сигналов.
Нейросети в трейдинге: Диффузионная генерация торговых планов (Сценарный расчёт) Нейросети в трейдинге: Диффузионная генерация торговых планов (Сценарный расчёт)
Продолжаем торговую адаптацию DPCC и объединяем ранее разработанные OpenCL-кернелы в два класса MQL5: CNeuronScenarioRolloutOCL для сценарного расчёта торговых планов и CNeuronPlanProjectionOCL для их коррекции с учётом ограничений. Разбираем организацию данных, взаимодействие классов, поиск поправок и повторную проверку финансовых последствий. Показываем, как использовать эти механизмы в собственных моделях и различать допустимый план, исчерпание бюджета поиска и ошибку вычислений.
Альтернатива временным барам (Часть 4): Эффективность разных типов баров в трендовых стратегиях и стратегиях возврата к среднему Альтернатива временным барам (Часть 4): Эффективность разных типов баров в трендовых стратегиях и стратегиях возврата к среднему
Обеспечивает ли улучшение статистических свойств доходностей прирост эффективности стратегии? Мы фиксируем число временных баров, тиковых баров, тиковых баров дисбаланса и тиковых баров серий на 60,5 млн тиков EURUSD, затем выполняем метаразметку точек входа по RSI, полосам Боллинджера и ADX/DI и оцениваем результаты с помощью перекрестной проверки с очисткой. Ни одно семейство баров не дает стабильного преимущества; эффективность меняется в узких пределах, а лучший вариант не проходит перестановочный тест. Читатели узнают, как контролировать перекрытие, утечку данных и множественную проверку гипотез в исследованиях баров.
Теория экстремальных значений в MQL5: Создание индикатора риска обвала с оценкой хвоста распределения — за рамками VaR по методу Монте-Карло Теория экстремальных значений в MQL5: Создание индикатора риска обвала с оценкой хвоста распределения — за рамками VaR по методу Монте-Карло
Стандартные инструменты оценки риска на MQL5 используют недавние исторические данные и не учитывают, насколько тяжелым может быть левый хвост распределения. Мы реализуем теорию экстремальных значений (EVT) в MetaTrader 5: подгонку обобщенного распределения Парето (GPD) методом превышений над порогом (POT) с помощью ALGLIB, индикатор, который в реальном времени выводит VaR и ES на основе EVT, а также параметр формы хвоста, и советник, рассчитывающий размер позиций по оценке хвоста распределения. Тестирование на исторических данных в контролируемых условиях демонстрирует снижение просадки при неизменных точках входа.