Создание торговых систем с искусственным интеллектом на MQL5 (Часть 9): Создание диспетчера сигналов ИИ
Введение
В части 8 этой серии в торговый помощник были добавлены анимация загрузки, метрики времени ответа, повторная генерация промптов и экспорт ответов. В результате получился доработанный интерфейс чата. Однако разрыв между ответом в чате и фактической сделкой остался. Для каждого сигнала по-прежнему требовался вручную составленный промпт, ответы ИИ возвращались в свободной форме, которую код не мог надежно разобрать, а на графике не появлялось ничего, что обозначало бы принятое решение.
У этой проблемы три составляющие. Ответ вида "тренд выглядит бычьим" не содержит структурированных данных: его нельзя разобрать, зафиксировать в журнале или передать в функцию размещения ордера. Если все типы анализа проходят через одно окно чата, запрос на быстрый скальпинг, вопрос о дневной направленности и поиск ключевых уровней используют один неструктурированный канал. При этом нет способа сопоставить каждому запросу нужные данные или шаблон промпта. Даже когда поступает полезный сигнал, стрелка не отмечает бар входа, линия не обозначает уровень, а метка не связывает решение с текущим таймфреймом. Поэтому трейдеру приходится держать сигнал в голове, а не видеть его на графике.
Данная статья устраняет решает эту проблему для разработчиков на языке MetaQuotes Language 5 (MQL5) и алгоритмических трейдеров. Мы создаем полный процесс: от клика по кнопке через сбор баров, ограниченный промпт, построчный ответ KEY:VALUE, парсер и единую функцию размещения ордеров, вплоть до объектов графической разметки с подписями, привязанных к текущему графику. В результате получаем панель управления на основе канваса, семикнопочную панель диспетчеризации, редактор промптов с учетом положения каретки, стабильную таблицу диспетчеризации на основе целочисленных идентификаторов и построчный протокол ИИ, который формирует структурированные сигналы, готовые к исполнению и тестированию на исторических данных. Мы рассмотрим следующие темы:
- Проектирование системы действий с диспетчеризацией для торговых сигналов ИИ
- Тема и примитивы рисования
- Подготовка состояния программы и жизненного цикла
- Пользовательский редактор промптов
- Оркестрация отрисовки и пользовательский интерфейс
- Слой логики ИИ – промпты, сигналы и сделки
- Взаимодействие и диспетчеризация действий
- Тестирование на исторических данных
- Заключение
В итоге у вас будет программа на языке MQL5 с консолью сигналов из семи кнопок на графике. Она будет формировать структурированные торговые сигналы через ограниченный протокол ИИ, размещать рыночные или отложенные ордера в зависимости от действия и отображать каждый сигнал как объект с меткой, привязанный к текущему таймфрейму графика. Программа будет готова к тестированию на исторических данных и дальнейшей настройке.
Проектирование системы действий с диспетчеризацией для торговых сигналов ИИ
Система действий с диспетчеризацией организует несколько отдельных операций, запускаемых пользователем, через единый слой маршрутизации. Каждая операция имеет неизменяемый целочисленный идентификатор, метку, значок и обработчик. Центральный диспетчер получает идентификатор и вызывает соответствующий обработчик. Чтобы добавить новое действие, достаточно добавить строку в таблицу и ветку case в оператор switch без необходимости редактировать пять отдельных файлов. Это структурное решение проблемы свободного формата чата. Вместо того чтобы проводить все виды анализа через одно окно, для каждой операции предусмотрены собственные подготовка данных, шаблон промпта, логика разбора и объекты на графике. Пользователь при этом видит единый набор кнопок.
Семь доступных действий охватывают разные виды анализа. Действие Get Chart Data выгружает последние бары и значения индикаторов для последующих вопросов и уточнений. Действие Twin Bars проверяет два последних закрытых бара. Если их направление совпадает, формируется решение: купить, продать или не открывать сделку. Действие Quick Scalp проверяет последние десять баров на наличие свечных паттернов. Действие Daily Signal извлекает закрытые часовые бары текущего дня и оценивает направленный импульс. Действие Trend Read анализирует последние 30 баров и возвращает направление тренда вместе с двумя якорными точками для трендовой линии. Действие Key Level проверяет последние 50 баров на наличие одного значимого горизонтального уровня и указывает, следует ли торговать от него по отбою или на пробой. Действие Clear Drawings удаляет все графические объекты, созданные программой. Для каждого действия на основе ИИ используется построчный протокол вывода "ключ:значение": один факт на строку, без JSON и вложенных фигурных скобок. В нем также явно задано направление бара: бычий бар имеет цену закрытия выше цены открытия, а бары перечисляются от новых к старым. Благодаря этому ИИ не приходится угадывать наши правила.
Исполнение сделок напрямую соответствует протоколу. Twin Bars, Quick Scalp и Daily Signal размещают рыночные ордера по текущей цене. Key Level размещает отложенный ордер; его тип заранее определяется матрицей 2 × 2: поддержка и отбой – Buy Limit; поддержка и пробой – Sell Stop; сопротивление и отбой – Sell Limit; сопротивление и пробой – Buy Stop. ИИ определяет тип уровня и направление, а диспетчер выбирает тип ордера. Расстояния до стопа и цели рассчитываются с использованием буфера, равного доле диапазона недавнего бара. Поэтому один и тот же код работает с любым символом без отдельной настройки под инструмент. Ниже показана схема поставленных задач.

Тема и примитивы рисования
Реализация на MQL5
Программа состоит из девяти исходных файлов, которые подключаются из основной точки входа в фиксированном порядке. Каждый файл отвечает за одну конкретную задачу. Рассмотрим файлы в порядке подключения – так же, как их обрабатывает компилятор. Сначала идут константы темы и примитивы рисования, затем глобальное состояние и базовая структура жизненного цикла, пользовательский редактор промптов, слой отрисовки панели управления, логика ИИ для работы с промптами и сделками и, наконец, слой взаимодействия, связывающий ввод пользователя с диспетчеризацией действий. К концу разбора назначение каждого файла будет понятно, а программа будет работать как единое целое.
Тема и примитивы рисования
Начнем с базового файла, в котором определены все цвета, размеры шрифта, параметры компоновки и коды глифов, используемые в программе. Эти значения собраны в одном месте, чтобы одним вызовом переключать всю панель управления между светлой и темной темами, а также менять шрифт или отступ без поиска по всей кодовой базе.
Определение констант темы и палитры
Здесь объявляются константы компоновки, размеры шрифтов, коды глифов и глобальные переменные цветов. Затем определяется функция Ai_ApplyTheme, которая заполняет глобальные цвета темной или светлой палитрой.
//+------------------------------------------------------------------+ //| AI Canvas Theme.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 AI_CANVAS_THEME_MQH #define AI_CANVAS_THEME_MQH //+------------------------------------------------------------------+ //| Layout Dimensions | //+------------------------------------------------------------------+ #define AI_SIDEBAR_W_EXPANDED 150 // Expanded sidebar width #define AI_SIDEBAR_W_COLLAPSED 50 // Collapsed sidebar width //--- REST OF DIMENSIONS ARE AS BEFORE //+------------------------------------------------------------------+ //| Apply theme palette - true for dark, false for light | //+------------------------------------------------------------------+ void Ai_ApplyTheme(bool dark) { //--- Store active theme flag g_ai_darkTheme = dark; //--- Dark theme palette if(dark) { //--- Panel and background colors g_ai_bg = C'22,26,36'; g_ai_panelAlt = C'28,33,46'; g_ai_headerBg = C'30,35,48'; g_ai_sidebarBg = C'24,29,40'; g_ai_promptBg = C'34,30,24'; //--- Border colors lifted above panel for visibility g_ai_border = C'95,105,125'; g_ai_borderAccent = C'130,140,160'; //--- Text colors g_ai_titleText = C'225,230,240'; g_ai_subText = C'140,150,170'; g_ai_bodyText = C'215,220,232'; //--- Bubble colors g_ai_userBubbleText = C'200,210,225'; g_ai_aiBubbleText = C'140,180,255'; g_ai_userBubbleBg = C'40,48,65'; g_ai_aiBubbleBg = C'35,55,90'; g_ai_timestampText = C'105,115,135'; //--- SAME APPROACH TO THE REST OF MEMBERS } } #endif // AI_CANVAS_THEME_MQH
В начале файла включается защита от повторного включения AI_CANVAS_THEME_MQH, чтобы заголовок обрабатывался только один раз, даже если он подключается из нескольких модулей. Затем задаются параметры компоновки: ширина боковой панели в развернутом и свернутом состояниях, ширина основной области, высота заголовка и нижней панели, высота области чата и панели промпта, общие значения внутренних и внешних отступов, стандартная высота кнопки и коэффициент суперсэмплирования для примитивов рисования. Используются директивы препроцессора, поэтому значения становятся константами времени компиляции без накладных расходов во время выполнения.
Тот же принцип применяется к размерам шрифта: для заголовка, основного текста, меток кнопок, строки предпросмотра фрагмента во всплывающем окне истории чата, временной метки под каждым блоком сообщения, меток кнопок панели инструментов и подзаголовков. Ниже отдельным символам шрифтов Webdings и Wingdings через определения глифов сопоставляются символические имена. Например, AI_GLYPH_CLOSE получает значение "r" из Webdings, которое отображается как крестик закрытия. Глифы именуются по назначению, а не по исходному символу, чтобы кодовая база оставалась читаемой.
Затем объявляется флаг активной темы g_ai_darkTheme, первоначально соответствующий светлой теме. Все нужные программе цвета объявляются как глобальные переменные типа color. Они сгруппированы по назначению: цвета панелей и фонов, цвета рамок, цвета текста, цвета блоков сообщений пользователя и ИИ, акцентные цвета, пары цветов фона кнопок для обычного состояния и наведения, цвета кнопки закрытия, цвета элементов истории чата, цвета полосы прокрутки, цвета для каретки и выделения в редакторе, цвета заметок о загрузке и времени ответа, а также цвета для тост-уведомлений. Эти глобальные переменные намеренно не инициализируются при объявлении – их значения задает Ai_ApplyTheme.
Функция Ai_ApplyTheme принимает один аргумент типа bool: true для темной темы и false для светлой. Флаг сохраняется в g_ai_darkTheme, после чего выполняется одна из двух веток, задающая всю палитру за один проход. Для темной палитры используются глубокие синие фоны панелей и светлые цвета текста, обеспечивающие хорошую читаемость. Для светлой палитры используются почти белые панели с более темным текстом. Панель промпта имеет теплый бежевый оттенок, который отличает ее от области чата. Тот же подход применяется к остальным цветам. Далее создадим еще один подключаемый файл с примитивами рисования, который станет основой архитектуры.
Создание основы примитивов рисования
В этом файле размещены вспомогательные средства для канваса, используемые во всей программе: быстрый подкласс канваса, объявление класса примитивов и механизм вывода текста с двойной буферизацией. Большая часть низкоуровневой работы с канвасом – прямой доступ к пикселям, альфа-смешивание, закругленные прямоугольники с суперсэмплированием, построчная растеризация и бикубическое масштабирование изображений – подробно рассмотрена в серии "Торговые инструменты MQL5", часть которой посвящена канвасам. В частности, модуль бикубического масштабирования перенесен из предыдущих статей этой серии. Здесь эти элементы пропустим и сосредоточимся на новых возможностях программы.
//+------------------------------------------------------------------+ //| AI Canvas Primitives.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 AI_CANVAS_PRIMITIVES_MQH #define AI_CANVAS_PRIMITIVES_MQH //--- Include required libraries #include <Canvas/Canvas.mqh> #include "AI Canvas Theme.mqh" //+------------------------------------------------------------------+ //| Fast canvas subclass exposing direct pixel buffer access | //+------------------------------------------------------------------+ class CAiCanvasFast : public CCanvas { public: //+---------------------------------------------------------------+ //| Read pixel directly from buffer | //+---------------------------------------------------------------+ uint GetPixelDirect(int x, int y) const { //--- Direct row-major buffer read return m_pixels[y * Width() + x]; } //+---------------------------------------------------------------+ //| Write pixel directly to buffer | //+---------------------------------------------------------------+ void SetPixelDirect(int x, int y, uint v) { //--- Direct row-major buffer write m_pixels[y * Width() + x] = v; } //+---------------------------------------------------------------+ //| Copy rectangular region from source canvas to this canvas | //+---------------------------------------------------------------+ void CopyRectFromCanvas(CCanvas &src, int l, int t, int r, int b) { //--- Get source and destination dimensions const int sw = src.Width(); const int sh = src.Height(); const int dw = Width(); const int dh = Height(); //--- Clamp rectangle bounds to both canvases const int cl = MathMax(0, l); const int ct = MathMax(0, t); const int cr = MathMin(MathMin(r, sw), dw); const int cb = MathMin(MathMin(b, sh), dh); //--- Copy row by row for(int yy = ct; yy < cb; yy++) { const int rowBase = yy * dw; for(int xx = cl; xx < cr; xx++) m_pixels[rowBase + xx] = src.PixelGet(xx, yy); } } //+---------------------------------------------------------------+ //| Copy rectangular region from this canvas to destination | //+---------------------------------------------------------------+ void CopyRectToCanvas(CCanvas &dst, int l, int t, int r, int b) { //--- Get destination and source dimensions const int dwOther = dst.Width(); const int dhOther = dst.Height(); const int sw = Width(); const int sh = Height(); //--- Clamp rectangle bounds const int cl = MathMax(0, l); const int ct = MathMax(0, t); const int cr = MathMin(MathMin(r, sw), dwOther); const int cb = MathMin(MathMin(b, sh), dhOther); //--- Copy row by row for(int yy = ct; yy < cb; yy++) { const int rowBase = yy * sw; for(int xx = cl; xx < cr; xx++) dst.PixelSet(xx, yy, m_pixels[rowBase + xx]); } } //+---------------------------------------------------------------+ //| Fill rectangle with solid color directly in buffer | //+---------------------------------------------------------------+ void FillRectFast(int l, int t, int r, int b, uint argb) { //--- Clamp rectangle to canvas bounds const int w = Width(); const int h = Height(); const int cl = MathMax(0, l); const int ct = MathMax(0, t); const int cr = MathMin(r, w); const int cb = MathMin(b, h); //--- Fill row by row for(int yy = ct; yy < cb; yy++) { const int rowBase = yy * w; for(int xx = cl; xx < cr; xx++) m_pixels[rowBase + xx] = argb; } } }; //+------------------------------------------------------------------+ //| Canvas drawing primitives helper class | //+------------------------------------------------------------------+ class CAiCanvasPrimitives { public: bool m_hrFillReady; // HR fill canvas init flag bool m_hrBorderReady; // HR border canvas init flag int m_hrFillW; // HR fill canvas width int m_hrFillH; // HR fill canvas height int m_hrBorderW; // HR border canvas width int m_hrBorderH; // HR border canvas height CAiCanvasFast m_hrFill; // Cached HR fill canvas CAiCanvasFast m_hrBorder; // Cached HR border canvas CAiCanvasPrimitives(); bool EnsureHrFill(int needW, int needH); bool EnsureHrBorder(int needW, int needH); void BlendPixelSet(CCanvas &canvas, int x, int y, uint sourceARGB); void DownsampleCanvas(CCanvas &dst, CCanvas &src, int factor); void FillCornerQuadrantHR(CCanvas &canvas, int cx, int cy, int radius, uint argb, int signX, int signY); void FillRoundRectHR(CCanvas &canvas, int x, int y, int w, int h, int radius, uint argb); void FillSelectiveRoundRectHR(CCanvas &canvas, int x, int y, int w, int h, int radius, uint argb, bool rTL, bool rTR, bool rBL, bool rBR); void FillTriangleHR(CCanvas &canvas, int x0, int y0, int x1, int y1, int x2, int y2, uint argb); void FillQuadrilateralBorder(CCanvas &canvas, double &vx[], double &vy[], uint argb); void DrawBorderEdge(CCanvas &canvas, double x0, double y0, double x1, double y1, int thickness, uint argb); bool IsAngleBetween(double angle, double startAngle, double endAngle); void DrawCornerArc(CCanvas &canvas, int cx, int cy, int radius, int thickness, uint argb, double startAngle, double endAngle); void DrawSelectiveRoundRectBorderHR(CCanvas &canvas, int x, int y, int w, int h, int radius, uint argb, int thickness, bool rTL, bool rTR, bool rBL, bool rBR); void FillCircleAA(CCanvas &canvas, int cx, int cy, int radius, uint argb); void DrawCircleBorderAA(CCanvas &canvas, int cx, int cy, int radius, int thickness, uint argb); void DrawRoundRectBorderObStyle(CCanvas &canvas, int x, int y, int w, int h, int radius, uint argb, bool drawTop, bool drawLeft, bool drawRight, bool drawBottom, bool arcTL, bool arcTR, bool arcBL, bool arcBR); void FillRoundRectSharp(CCanvas &target, int x, int y, int w, int h, int radius, uint argb, int factor = 4); void DrawRoundRectBorderSharp(CCanvas &target, int x, int y, int w, int h, int radius, int thickness, uint argb, int factor = 4); }; //+------------------------------------------------------------------+ //| Stamp anti-aliased text on canvas using dual-buffer technique | //+------------------------------------------------------------------+ void AiStampTextAA(CCanvas &dst, int x, int y, const string txt, const string fontName, int fontSize, color textColor) { //--- Bail on empty text if(StringLen(txt) == 0) return; //--- Set font and measure text TextSetFont(fontName, -(fontSize * 10)); uint twU = 0, thU = 0; TextGetSize(txt, twU, thU); int tw = (int)twU, th = (int)thU; if(tw <= 0 || th <= 0) return; //--- Render text on black background buffer const uint textArgb = ColorToARGB(textColor, 255); uint bufB[]; ArrayResize(bufB, tw * th); ArrayFill(bufB, 0, tw * th, 0xFF000000); TextOut(txt, 0, 0, TA_LEFT | TA_TOP, bufB, tw, th, textArgb, COLOR_FORMAT_ARGB_NORMALIZE); //--- Render text on white background buffer uint bufW[]; ArrayResize(bufW, tw * th); ArrayFill(bufW, 0, tw * th, 0xFFFFFFFF); TextOut(txt, 0, 0, TA_LEFT | TA_TOP, bufW, tw, th, textArgb, COLOR_FORMAT_ARGB_NORMALIZE); //--- Cache canvas dimensions and source color components const int cW = dst.Width(), cH = dst.Height(); const uchar srcR = (uchar)( textColor & 0xFF); const uchar srcG = (uchar)((textColor >> 8) & 0xFF); const uchar srcB = (uchar)((textColor >> 16) & 0xFF); //--- Walk text pixels and derive coverage from buffer difference for(int py = 0; py < th; py++) { for(int px = 0; px < tw; px++) { //--- Compute alpha from black-vs-white pixel difference int i = py * tw + px; int dR = (int)((bufW[i] >> 16) & 0xFF) - (int)((bufB[i] >> 16) & 0xFF); int dG = (int)((bufW[i] >> 8) & 0xFF) - (int)((bufB[i] >> 8) & 0xFF); int dB = (int)( bufW[i] & 0xFF) - (int)( bufB[i] & 0xFF); int a = 255 - (dR + dG + dB) / 3; if(a <= 0) continue; if(a > 255) a = 255; //--- Skip pixels outside canvas int dstX = x + px, dstY = y + py; if(dstX < 0 || dstX >= cW || dstY < 0 || dstY >= cH) continue; //--- Blend text pixel onto destination uint existing = dst.PixelGet(dstX, dstY); double sA = (double)a / 255.0; double dA = ((existing >> 24) & 0xFF) / 255.0; double oA = sA + dA * (1.0 - sA); if(oA <= 0.0) continue; double sRf = srcR / 255.0, sGf = srcG / 255.0, sBf = srcB / 255.0; double dRf = ((existing >> 16) & 0xFF) / 255.0; double dGf = ((existing >> 8) & 0xFF) / 255.0; double dBf = ( existing & 0xFF) / 255.0; uint outPix = ((uint)(uchar)(oA * 255.0 + 0.5) << 24) | ((uint)(uchar)((sRf*sA + dRf*dA*(1.0-sA)) / oA * 255.0 + 0.5) << 16) | ((uint)(uchar)((sGf*sA + dGf*dA*(1.0-sA)) / oA * 255.0 + 0.5) << 8) | (uint)(uchar)((sBf*sA + dBf*dA*(1.0-sA)) / oA * 255.0 + 0.5); dst.PixelSet(dstX, dstY, outPix); } } }
В начале файла включается защита от повторного включения AI_CANVAS_PRIMITIVES_MQH. Затем подключаются стандартный заголовок "Canvas/Canvas.mqh" для базового класса CCanvas и собственный подключаемый файл "AI Canvas Theme.mqh", чтобы примитивы могли обращаться к глобальным переменным цветов.
CAiCanvasFast объявляется как открытый подкласс CCanvas. Причина проста: базовый класс хранит m_pixels в защищенной области, а унаследованные вызовы PixelSet и PixelGet требуют отдельного вызова функции для каждого пикселя. При смешивании десятков тысяч пикселей за кадр такие накладные расходы становятся существенными. Благодаря подклассу становятся доступны GetPixelDirect и SetPixelDirect, которые индексируют буфер напрямую по построчной формуле y * Width() + x. Также добавляются CopyRectFromCanvas, CopyRectToCanvas и FillRectFast для массовых операций, проходящих по буферу построчно без отдельных вызовов для каждого пикселя. Каждый из этих методов сначала ограничивает прямоугольник границами канваса, чтобы чтение и запись не выходили за пределы буфера.
Затем CAiCanvasPrimitives объявляется как вспомогательный класс, содержащий все примитивы рисования этого файла. Его элементы делятся на две группы. Два экземпляра CAiCanvasFast – m_hrFill и m_hrBorder – служат рабочими канвасами вне экрана для суперсэмплирования с последующим уменьшением разрешения. Закругленные прямоугольники отрисовываются на одном из них с четырехкратным целевым разрешением, после чего результат усредняется по блокам для получения четких сглаженных краев. Флаги m_hrFillReady, m_hrFillW, m_hrFillH и соответствующая тройка для рамки позволяют увеличивать рабочие канвасы через EnsureHrFill и EnsureHrBorder только по запросу, когда текущего размера недостаточно. Объявления методов охватывают все необходимое: от FillRoundRectHR и DrawSelectiveRoundRectBorderHR до FillTriangleHR и FillCircleAA, а также вызываемые извне обертки FillRoundRectSharp и DrawRoundRectBorderSharp. Это полный набор инструментов, используемый модулем отрисовки.
AiStampTextAA определяется как функция для отрисовки сглаженного текста в любой точке канваса. Проблема состоит в том, что TextOut отрисовывает глифы в пиксельный буфер, но не возвращает альфа-покрытие для каждого пикселя. Поэтому прямой вывод результата дает полностью непрозрачный текст с зубчатыми краями, не учитывающий уже отрисованное на канвасе. Эта проблема обходится с помощью приема с двойным буфером. Создаются два пиксельных буфера – bufB и bufW – размером, соответствующим измеренному тексту. Один заполняется непрозрачным черным цветом, другой – непрозрачным белым, после чего для обоих буферов вызывается TextOut с одинаковым текстом и целевым цветом. Там, где пиксель полностью покрыт глифом, оба буфера получают исходный цвет, поэтому разница между ними равна нулю.
Там, где пиксель полностью находится за пределами глифа, буферы сохраняют черный и белый фон соответственно, а разница достигает максимума. Значения пикселей на сглаженных краях располагаются между этими крайними случаями пропорционально покрытию. Затем выполняется проход по буферам: вычисляется разница по каждому каналу, усредняются три канала и инвертируется результат. Так получается чистая альфа-маска, которая используется в стандартном альфа-смешивании по методу Over с текущим пикселем канваса. В теле финального цикла выполняется та же формула альфа-смешивания Портера-Даффа "Over", которую мы используем во всех прочих местах, только в качестве исходной степени покрытия берется вычисленная альфа. В результате получается текст со сглаживанием, корректно отображаемый на любом фоне, на котором расположено облако сообщения в чате. Далее реализуем попиксельную подгонку текста с помощью двоичного поиска. В предыдущей части использовалось простое усечение по числу символов, и теперь мы устраним это ограничение.
Попиксельная подгонка текста с многоточием
Определяется вспомогательная функция Ai_FitTextToWidth, которая обрезает строку до заданной максимальной ширины в пикселях и при необходимости добавляет многоточие. Она заменяет простое усечение по числу символов из предыдущей статьи.
//+------------------------------------------------------------------+ //| Fit text to maximum pixel width with ellipsis | //+------------------------------------------------------------------+ string Ai_FitTextToWidth(const string text, const string fontName, int fontSize, int maxWidthPx) { //--- Bail on empty input if(StringLen(text) == 0) return ""; if(maxWidthPx <= 0) return ""; //--- Fast path - full text fits const int fullW = AiTextWidth(text, fontName, fontSize); if(fullW <= maxWidthPx) return text; //--- Bail if even ellipsis doesn't fit const string ellipsis = "..."; const int ellipsisW = AiTextWidth(ellipsis, fontName, fontSize); if(ellipsisW > maxWidthPx) return ""; //--- Binary search longest prefix that fits with ellipsis appended const int n = StringLen(text); int lo = 0, hi = n; while(lo < hi) { const int mid = (lo + hi + 1) / 2; const string trial = StringSubstr(text, 0, mid) + ellipsis; if(AiTextWidth(trial, fontName, fontSize) <= maxWidthPx) { lo = mid; } else { hi = mid - 1; } } //--- Return ellipsis-only when nothing fits, else prefix + ellipsis if(lo <= 0) return ellipsis; return StringSubstr(text, 0, lo) + ellipsis; }
Сначала выполняются две ранние проверки: пустая входная строка дает пустой результат, то же происходит при неположительном значении аргумента maxWidthPx. Затем выполняется быстрый сценарий. Полная ширина строки измеряется функцией AiTextWidth с заданными fontName и fontSize. Если строка уже помещается в maxWidthPx, она возвращается без изменений. Следующая проверка обрабатывает крайний случай, когда доступная ширина настолько мала, что не помещается даже многоточие из трех символов. В этом случае возвращается пустая строка, а не частичное многоточие.
Затем двоичным поиском находится самый длинный префикс, который помещается вместе с многоточием. Переменные lo и hi задают границы длины проверяемого префикса: от нуля до полной длины строки соответственно. На каждой итерации вычисляется среднее значение, формируется пробная строка StringSubstr(text, 0, mid) + ellipsis и измеряется ее ширина. Если пробная строка помещается, lo получает значение mid: эта длина подходит, поэтому можно проверить более длинный префикс. Если строка не помещается, hi получает значение mid - 1, поскольку эта длина и все значения больше нее превышают допустимую ширину. Цикл завершается, когда границы сходятся к одному значению – максимальной длине префикса, который вместе с многоточием укладывается в доступную ширину. В конце выполняется еще одна проверка: если lo равно нулю, не помещается ни один символ префикса, поэтому возвращается только многоточие. В противном случае возвращается найденный префикс с многоточием.
Вместо посимвольного перебора используется двоичный поиск, поскольку в пропорциональных шрифтах, таких как Arial, ширина глифов сильно различается. Строки из символов "i" и "M" одинаковой длины имеют заметно разную ширину в пикселях. Линейное усечение по числу символов из предыдущей статьи дает заметно непоследовательные результаты: иногда усеченная метка оставляет большой зазор до правого края, а иногда выходит за него. Двоичный поиск требует порядка логарифма от длины строки и учитывает фактические размеры отрисованного текста. Поэтому каждый усеченный заголовок чата, фрагмент всплывающего окна и метка кнопки в программе отклоняются от заданной ширины не более чем на один пиксель независимо от символов. В результате получаем следующее.

Теперь перейдем к отрисовке пользовательских семантических значков. Этот подход выбран для полного контроля над отображаемыми элементами: значки Wingdings не позволяли достаточно точно передать нужный смысл.
Пользовательские семантические значки для панели инструментов действий
Определяются набор средств для отрисовки значков и 13 пользовательских глифов, заменяющих символы Webdings и Wingdings из предыдущей статьи. Каждая кнопка действия теперь получает отдельный значок, отрисованный попиксельно, с цветом, соответствующим ее функции.
//+------------------------------------------------------------------+ //| Draw small chevron arrow at center point | //+------------------------------------------------------------------+ void AiDrawChevron(CCanvas &canvas, int cx, int cy, bool pointUp, uint argb) { //--- Draw two diagonal strokes converging up or down if(pointUp) { AiThickLineAA(canvas, cx - 4, cy + 2, cx, cy - 2, 2, argb); AiThickLineAA(canvas, cx, cy - 2, cx + 4, cy + 2, 2, argb); } else { AiThickLineAA(canvas, cx - 4, cy - 2, cx, cy + 2, 2, argb); AiThickLineAA(canvas, cx, cy + 2, cx + 4, cy - 2, 2, argb); } } //+------------------------------------------------------------------+ //| Draw thin anti-aliased icon line using Wu algorithm | //+------------------------------------------------------------------+ void AiIconLine(CCanvas &canvas, double x0, double y0, double x1, double y1, uint argb) { //--- Determine steep or shallow slope double dxL = x1 - x0, dyL = y1 - y0; bool steep = MathAbs(dyL) > MathAbs(dxL); //--- Steep slope - iterate Y axis if(steep) { if(y0 > y1) { double t; t = x0; x0 = x1; x1 = t; t = y0; y0 = y1; y1 = t; } double grad = (y1 == y0) ? 0.0 : (x1 - x0) / (y1 - y0); int iy0 = (int)MathRound(y0), iy1 = (int)MathRound(y1); double xf = x0 + grad * (iy0 - y0); for(int iy = iy0; iy <= iy1; iy++) { int ix = (int)MathFloor(xf); double frac = xf - ix; AiIconAAPlot(canvas, ix, iy, 1.0 - frac, argb); AiIconAAPlot(canvas, ix + 1, iy, frac, argb); xf += grad; } } //--- Shallow slope - iterate X axis else { if(x0 > x1) { double t; t = x0; x0 = x1; x1 = t; t = y0; y0 = y1; y1 = t; } double grad = (x1 == x0) ? 0.0 : (y1 - y0) / (x1 - x0); int ix0 = (int)MathRound(x0), ix1 = (int)MathRound(x1); double yf = y0 + grad * (ix0 - x0); for(int ix = ix0; ix <= ix1; ix++) { int iy = (int)MathFloor(yf); double frac = yf - iy; AiIconAAPlot(canvas, ix, iy, 1.0 - frac, argb); AiIconAAPlot(canvas, ix, iy + 1, frac, argb); yf += grad; } } } //--- OTHER ICONS USE THE SAME APPROACH
Сначала определяются три небольшие вспомогательные функции, используемые функциями отрисовки значков. AiDrawChevron рисует две диагональные линии, сходящиеся вверх или вниз и образующие небольшой маркер развернутого или свернутого состояния. Затем AiIconLine реализует алгоритм Сяолиня Ву: проход по главной оси выполняется с шагом в один пиксель, а два пикселя отрисовываются с весами "1.0 - frac" и "frac". Благодаря этому линия остается сглаженной под любым углом.
Все 13 функций отрисовки значков имеют одинаковую структуру: каждая принимает канвас, позицию угла, размер и цвет, а затем формирует глиф с помощью AiThickLineAA, AiIconLine, AiBlendPixel, AiStrokeArcAA и canvas.CircleWu. Каждому значку назначается цвет, отражающий выполняемое действие, чтобы пользователь сразу понимал функции кнопок панели инструментов. Ниже показан результат после подключения этих компонентов.

Далее займемся встроенным парсером Markdown, чтобы возвращаемый ИИ диалог можно было отобразить в ожидаемом формате.
Встроенный парсер Markdown и отрисовка стилизованных фрагментов
Определяется подсистема Markdown, позволяющая отображать в ответах ИИ полужирное и курсивное начертание прямо в тексте. Она заменяет простой текстовый вывод из предыдущей статьи.
//+------------------------------------------------------------------+ //| Markdown Constants | //+------------------------------------------------------------------+ #define AI_MD_KIND_BODY 0 // Plain body line #define AI_MD_KIND_H1 1 // Heading level 1 (sentinel \1) #define AI_MD_KIND_H2 2 // Heading level 2 (sentinel \2) #define AI_MD_KIND_H3 3 // Heading level 3 (sentinel \3) #define AI_MD_KIND_NUMBERED 5 // Numbered list line //+------------------------------------------------------------------+ //| Styled markdown run | //+------------------------------------------------------------------+ struct AiMdRun { string text; // Run text content bool bold; // Bold style flag bool italic; // Italic style flag }; //+------------------------------------------------------------------+ //| Parse a single markdown line into styled runs | //+------------------------------------------------------------------+ void AiMdParseInline(const string txt, AiMdRun &runs[]) { //--- Reset output array and bail on empty input ArrayResize(runs, 0); const int len = StringLen(txt); if(len == 0) return; //--- Initialize parser state bool curBold = false, curItalic = false; string buf = ""; //--- Define flush macro for emitting accumulated runs #define AI_MD_FLUSH() \ { \ if(StringLen(buf) > 0) { \ const int sz = ArraySize(runs); \ ArrayResize(runs, sz + 1); \ runs[sz].text = buf; \ runs[sz].bold = curBold; \ runs[sz].italic = curItalic; \ buf = ""; \ } \ } //--- Walk characters and toggle styles int i = 0; while(i < len) { const ushort ch = StringGetCharacter(txt, i); //--- Handle asterisk markers if(ch == '*') { //--- Triple asterisk toggles bold + italic if(i + 2 < len && StringGetCharacter(txt, i + 1) == '*' && StringGetCharacter(txt, i + 2) == '*') { AI_MD_FLUSH(); curBold = !curBold; curItalic = !curItalic; i += 3; continue; } //--- Double asterisk toggles bold if(i + 1 < len && StringGetCharacter(txt, i + 1) == '*') { AI_MD_FLUSH(); curBold = !curBold; i += 2; continue; } //--- Single asterisk toggles italic AI_MD_FLUSH(); curItalic = !curItalic; i++; continue; } //--- Append plain character buf += StringSubstr(txt, i, 1); i++; } //--- Flush trailing run AI_MD_FLUSH(); #undef AI_MD_FLUSH } //+------------------------------------------------------------------+ //| Compute markdown style state at end of line | //+------------------------------------------------------------------+ void AiMdComputeEndState(const string txt, bool &openBold, bool &openItalic) { //--- Bail on empty input const int len = StringLen(txt); if(len == 0) return; //--- Walk characters mirroring AiMdParseInline transitions int i = 0; while(i < len) { const ushort ch = StringGetCharacter(txt, i); if(ch == '*') { //--- Triple asterisk toggles both if(i + 2 < len && StringGetCharacter(txt, i + 1) == '*' && StringGetCharacter(txt, i + 2) == '*') { openBold = !openBold; openItalic = !openItalic; i += 3; continue; } //--- Double asterisk toggles bold if(i + 1 < len && StringGetCharacter(txt, i + 1) == '*') { openBold = !openBold; i += 2; continue; } //--- Single asterisk toggles italic openItalic = !openItalic; i++; continue; } i++; } } //+------------------------------------------------------------------+ //| Build marker prefix needed to reopen styles on continuation line | //+------------------------------------------------------------------+ string AiMdReopenMarkers(const bool openBold, const bool openItalic) { //--- Pick marker length based on combined open state if(openBold && openItalic) return "***"; if(openBold) return "**"; if(openItalic) return "*"; return ""; } //+------------------------------------------------------------------+ //| Resolve font name for a markdown run's bold/italic combination | //+------------------------------------------------------------------+ string AiMdRunFont(const AiMdRun &r) { //--- Map style flags to Arial variant if(r.bold && r.italic) return "Arial Bold Italic"; if(r.bold) return "Arial Bold"; if(r.italic) return "Arial Italic"; return "Arial"; } //+------------------------------------------------------------------+ //| Measure total pixel width of styled run sequence | //+------------------------------------------------------------------+ int AiMdRunsWidth(const AiMdRun &runs[], int fontSize) { //--- Sum widths of each run with its font variant int total = 0; const int n = ArraySize(runs); for(int i = 0; i < n; i++) { total += AiTextWidth(runs[i].text, AiMdRunFont(runs[i]), fontSize); } return total; } //+------------------------------------------------------------------+ //| Stamp styled run sequence side-by-side onto canvas | //+------------------------------------------------------------------+ void AiMdStampRuns(CCanvas &canvas, int x, int y, const AiMdRun &runs[], int fontSize, color textCol) { //--- Walk runs and stamp each at advancing X position int curX = x; const int n = ArraySize(runs); for(int i = 0; i < n; i++) { if(StringLen(runs[i].text) == 0) continue; const string font = AiMdRunFont(runs[i]); AiStampTextAA(canvas, curX, y, runs[i].text, font, fontSize, textCol); curX += AiTextWidth(runs[i].text, font, fontSize); } }
Сначала определяются константы типа строки AI_MD_KIND_BODY, AI_MD_KIND_H1–AI_MD_KIND_H3 и AI_MD_KIND_NUMBERED. Модуль отрисовки использует их для разметки каждой строки ответа ИИ и определяет, выводить ли ее как основной текст, заголовок одного из трех уровней или элемент нумерованного списка. Затем объявляется структура AiMdRun, представляющая один стилизованный фрагмент текста с полями text, bold и italic. После разбора строка Markdown преобразуется в массив таких фрагментов.
AiMdParseInline определяется как парсер, который обрабатывает одну строку текста и формирует массив фрагментов. curBold и curItalic отслеживаются как флаги активного стиля, а обычные символы накапливаются в buf. Макрос AI_MD_FLUSH формирует из текущего буфера новый фрагмент с активными на данный момент стилями, а затем очищает буфер. При обнаружении звездочки проверяются тройная, двойная и одинарная формы в таком порядке: "***" переключает одновременно полужирное и курсивное начертание, "**" – только полужирное, а "*" – только курсивное. При каждом переключении сначала выводится накопленный буфер, чтобы стиль изменился в нужной позиции, после чего курсор перемещается за маркер. Обычные символы добавляются в буфер без изменений, а после завершения цикла выводится последний фрагмент.
AiMdComputeEndState выполняет облегченный проход и возвращает итоговое состояние bold/italic в конце строки, чтобы при переносе на следующую визуальную строку можно было корректно восстановить активные стили. Модуль отрисовки использует это, когда абзац после переноса занимает несколько визуальных строк. Если "**" открывается в середине первой строки и закрывается в середине второй, вторая строка должна начинаться в полужирном начертании. AiMdReopenMarkers преобразует это общее открытое состояние в префикс маркеров, который вставляется в начало строки продолжения, чтобы парсер сохранил текущее состояние.
AiMdRunFont сопоставляет флаги стиля фрагмента с одним из четырех вариантов Arial: обычным, полужирным, курсивным или полужирным курсивом. AiMdRunsWidth суммирует ширину в пикселях каждого фрагмента, измеренную с его вариантом шрифта. Логика переноса использует это значение, чтобы определить место разрыва стилизованной строки. AiMdStampRuns проходит по массиву фрагментов слева направо и вызывает AiStampTextAA для каждого непустого фрагмента с соответствующим шрифтом. curX увеличивается на ширину текущего фрагмента, поэтому фрагменты примыкают друг к другу и образуют одну визуально непрерывную строку текста с разными стилями. Это основные элементы разметки Markdown; при необходимости их можно дополнить другими. В результате получаем следующее.

Теперь перейдем к состоянию программы и ее жизненному циклу.
Подготовка состояния программы и жизненного цикла
Переходим к слою состояния и жизненного цикла, связывающему примитивы канваса с работающей программой. Записи чатов, активный диалог, флаги видимости всплывающих окон и таблицы диспетчеризации собраны в одном заголовке состояния. Затем программа собирается из логики полосы прокрутки, компактной оболочки и основного файла точки входа.
Определение состояния программы и вспомогательных функций
В одном заголовке объявляются все глобальные переменные, которые программа читает или изменяет, включая новые таблицы диспетчеризации для семи торговых действий и состояние тост-уведомлений.
//+------------------------------------------------------------------+ //| AI Canvas State.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 AI_CANVAS_STATE_MQH #define AI_CANVAS_STATE_MQH //--- Pull theme for AI_DASHBOARD_X_DEFAULT and AI_DASHBOARD_Y_DEFAULT #include "AI Canvas Theme.mqh" //+------------------------------------------------------------------+ //| Canvas Object Names | //+------------------------------------------------------------------+ #define AI_CANVAS_NAME_MAIN "ChatGPT_AI_MainCanvas" // Main dashboard canvas object #define AI_CANVAS_NAME_PROMPT "ChatGPT_AI_PromptCanvas" // Prompt overlay canvas object //+------------------------------------------------------------------+ //| Chat Record Structure | //+------------------------------------------------------------------+ struct Chat { int id; // Unique chat identifier string title; // Chat display title string history; // Full conversation transcript }; //+------------------------------------------------------------------+ //| Chat State | //+------------------------------------------------------------------+ Chat g_ai_chats[]; // All loaded chats int g_ai_currentChatId = -1; // Active chat id (-1 = new chat) string g_ai_currentTitle = ""; // Active chat title string g_ai_conversationHistory = ""; // Active chat transcript string g_ai_currentPrompt = ""; // Persisted prompt text //+------------------------------------------------------------------+ //| UI Visibility Flags | //+------------------------------------------------------------------+ bool g_ai_sidebarExpanded = true; // Sidebar expanded state bool g_ai_dashboardVisible = true; // Dashboard visible state //+------------------------------------------------------------------+ //| Popup State Flags | //+------------------------------------------------------------------+ bool g_ai_showSmallHistory = false; // Small history popup visible bool g_ai_showBigHistory = false; // Big history popup visible bool g_ai_showSearch = false; // Search popup visible bool g_ai_justOpenedSmall = false; // Skip first-click close on small popup bool g_ai_justOpenedBig = false; // Skip first-click close on big popup bool g_ai_justOpenedSearch = false; // Skip first-click close on search popup string g_ai_searchQuery = ""; // Current search query text //+------------------------------------------------------------------+ //| Drag State | //+------------------------------------------------------------------+ bool g_ai_dragging = false; // Header drag in progress int g_ai_dragOffsetX = 0; // Drag X offset from anchor int g_ai_dragOffsetY = 0; // Drag Y offset from anchor //+------------------------------------------------------------------+ //| Dashboard Position | //+------------------------------------------------------------------+ int AI_DASHBOARD_X = AI_DASHBOARD_X_DEFAULT; // Current dashboard X int AI_DASHBOARD_Y = AI_DASHBOARD_Y_DEFAULT; // Current dashboard Y //+------------------------------------------------------------------+ //| Footer Dropdown State | //+------------------------------------------------------------------+ bool g_ai_showFooterDropdown = false; // Dropdown popup visible int g_ai_footerDropdownSelectedIdx = 0; // Selected action index //+------------------------------------------------------------------+ //| Footer Action Dispatch Tables | //+------------------------------------------------------------------+ const int AI_FOOTER_DD_COUNT = 7; // Number of dropdown actions const string AI_FOOTER_DD_LABELS[] = { "Get Chart Data", // 0 - chart data dump "Twin Bars", // 1 - twin-bars signal check "Quick Scalp", // 2 - single-bar entry signal "Daily Signal", // 3 - daily H1 bias signal "Trend Read", // 4 - trendline anchors "Key Level", // 5 - significant S/R level "Clear Drawings" // 6 - wipe signal drawings }; const int AI_FOOTER_DD_ACTION_IDS[]= { 0, 1, 2, 3, 4, 5, 6 }; // Stable action IDs const int AI_FOOTER_DD_ICONS[] = { 0, // chart icon 1, // twin bars icon 2, // lightning icon 3, // day icon 4, // trend icon 5, // level icon 6 // close icon }; //+------------------------------------------------------------------+ //| API Throttle Flag | //+------------------------------------------------------------------+ bool g_ai_signalRequestInFlight = false; // Block parallel API calls //+------------------------------------------------------------------+ //| Tick and Animation State | //+------------------------------------------------------------------+ datetime g_ai_lastBarTime = 0; // Last seen bar time (for new-bar detection) int g_ai_spinnerCycle = 0; // Loading spinner animation tick //+------------------------------------------------------------------+ //| Toast Notification State | //+------------------------------------------------------------------+ string g_ai_toastText = ""; // Active toast text (empty = no toast) bool g_ai_toastIsError = false;// Pick error vs success color ulong g_ai_toastExpiryMs = 0; // Tick count when toast expires //+------------------------------------------------------------------+ //| Pencil Hover Tracking | //+------------------------------------------------------------------+ bool g_ai_overPencilIcon = false; // Cursor over narrow pencil icon //+------------------------------------------------------------------+ //| Send Button State Tracking | //+------------------------------------------------------------------+ bool g_ai_lastRenderedSendDisabled = true; // Disabled state at last footer render //--- CHAT DECODE AND ENCODE LOGIC REMAINS #endif // AI_CANVAS_STATE_MQH
В начале файла включается защита от повторного включения AI_CANVAS_STATE_MQH, затем подключается заголовок темы. Благодаря этому файл состояния может использовать AI_DASHBOARD_X_DEFAULT и AI_DASHBOARD_Y_DEFAULT для начальной позиции панели управления. Затем объявляются имена объектов канваса, структура записи Chat с полями id, title и history, а также глобальные переменные состояния чата: массив g_ai_chats, g_ai_currentChatId, g_ai_currentTitle, g_ai_conversationHistory и g_ai_currentPrompt. Флаги видимости боковой панели, панели управления, малого и большого всплывающих окон, а также окна поиска перенесены из предыдущей статьи и выполняют те же функции.
Новое в этом заголовке – инфраструктура диспетчеризации. Массив AI_FOOTER_DD_LABELS содержит метки семи действий: Get Chart Data, Twin Bars, Quick Scalp, Daily Signal, Trend Read, Key Level и Clear Drawings. Они расположены в том же порядке, что и в раскрывающемся списке. Соответствующий массив AI_FOOTER_DD_ACTION_IDS содержит неизменяемые целочисленные ID, которые диспетчер использует для маршрутизации клика к нужному обработчику. AI_FOOTER_DD_ICONS сопоставляет каждому действию функцию отрисовки значка из файла примитивов. g_ai_footerDropdownSelectedIdx хранит последнее выбранное действие, чтобы метка кнопки сохранялась между кликами. g_ai_signalRequestInFlight блокирует параллельные вызовы API: если до получения первого ответа происходит второй клик, он без уведомления игнорируется вместо отправки второго запроса.
Также добавляются три переменные тост-уведомлений: g_ai_toastText, g_ai_toastIsError и g_ai_toastExpiryMs. Модуль отрисовки использует их для вывода временного баннера после деструктивных действий, таких как очистка чата или удаление всей истории. Состояние перетаскивания, счетчик циклов индикатора загрузки и флаг наведения на значок-карандаш для редактирования сообщений дополняют набор новых глобальных переменных.
Вспомогательные функции AiEncodeID и AiDecodeID перенесены без изменений. Далее рассмотрим логику полосы прокрутки. Вместо полной полосы прокрутки из прежней наивной архитектуры примитивов здесь используется отдельная дорожка.
Состояние и отрисовка полосы прокрутки
Логика полосы прокрутки выносится из модуля отрисовки в небольшой повторно используемый модуль со структурой состояния, вспомогательными функциями проверки попадания и процедурой отрисовки. Он заменяет четыре почти одинаковых участка кода полосы прокрутки из предыдущей статьи.
//+------------------------------------------------------------------+ //| AI Canvas Scrollbar.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 AI_CANVAS_SCROLLBAR_MQH #define AI_CANVAS_SCROLLBAR_MQH //--- Include required modules #include "AI Canvas Theme.mqh" #include "AI Canvas Primitives.mqh" //+------------------------------------------------------------------+ //| Scrollbar State Structure | //+------------------------------------------------------------------+ struct AiScrollState { int trackL; // Track left edge int trackT; // Track top edge int trackR; // Track right edge int trackB; // Track bottom edge int scrollPx; // Current scroll offset in pixels int viewportH; // Visible viewport height int totalH; // Total scrollable content height bool hoveredArea; // Cursor is inside the scrollbar area bool hoveredThumb; // Cursor is over the scrollbar thumb bool dragging; // Thumb is being dragged bool hover; // Generic hover flag int dragOriginPx; // Scroll position at drag start int dragOriginY; // Mouse Y at drag start }; //+------------------------------------------------------------------+ //| Reset scroll state to defaults | //+------------------------------------------------------------------+ void AiScrollInit(AiScrollState &s) { //--- Zero track and scroll metrics s.trackL = 0; s.trackT = 0; s.trackR = 0; s.trackB = 0; s.scrollPx = 0; s.viewportH = 0; s.totalH = 0; //--- Clear hover and drag flags s.hoveredArea = false; s.hoveredThumb = false; s.hover = false; s.dragging = false; s.dragOriginPx = 0; s.dragOriginY = 0; } //+------------------------------------------------------------------+ //| Compute maximum allowed scroll offset | //+------------------------------------------------------------------+ int AiScrollMax(const AiScrollState &s) { //--- Cap minimum at zero when content fits in viewport int m = s.totalH - s.viewportH; return (m < 0) ? 0 : m; } //+------------------------------------------------------------------+ //| Test if scrollbar should be visible | //+------------------------------------------------------------------+ bool AiScrollVisible(const AiScrollState &s) { //--- Only visible when content overflows viewport return AiScrollMax(s) > 0; } //+------------------------------------------------------------------+ //| Clamp scroll offset within valid range | //+------------------------------------------------------------------+ void AiScrollClamp(AiScrollState &s) { //--- Clamp scroll position between 0 and max int m = AiScrollMax(s); if(s.scrollPx < 0) s.scrollPx = 0; if(s.scrollPx > m) s.scrollPx = m; } //--- SAME APPROACH TO THE REST OF THE SCROLLBAR #endif // AI_CANVAS_SCROLLBAR_MQH
Сначала рассмотрим структуру AiScrollState, содержащую все данные, необходимые полосе прокрутки: четыре границы дорожки от trackL до trackB, текущее смещение прокрутки scrollPx, высоту области просмотра и всего содержимого, а также флаги наведения и перетаскивания. В предыдущей статье эти данные хранились как отдельные глобальные переменные для каждой прокручиваемой области. Поэтому для области чата, панели промпта, большого всплывающего окна истории и окна поиска приходилось четыре раза дублировать вычисления. Теперь полное состояние каждой области хранится в одном экземпляре структуры, а приведенные ниже вспомогательные функции работают с переданной структурой.
AiScrollInit задает полям структуры нулевые значения по умолчанию при создании. AiScrollMax возвращает максимальное допустимое смещение прокрутки: totalH - viewportH, ограниченное снизу нулем. Поэтому область, содержимое которой помещается в окне просмотра, не получает отрицательного запаса прокрутки. AiScrollVisible сводит проверку видимости к одному условию: полоса отображается, только если AiScrollMax возвращает положительное значение. AiScrollClamp удерживает внешне заданную позицию прокрутки в допустимом диапазоне. Он вызывается после событий колесика мыши и обновления перетаскивания. Такой же подход применяется в остальной логике полосы прокрутки. Модуль взаимодействия передает реальные события графика этим четырем функциям, а модуль отрисовки использует полученное состояние. Ниже показан результат после подключения компонентов.

Далее расширим редактор промптов стандартными возможностями редактирования: правкой в середине строки, удалением, неограниченной длиной промпта и горячими клавишами. Это снимает ограничение на количество символов, существовавшее в предыдущем редакторе. Все это реализовано в одном файле, чтобы упростить дальнейшее расширение функционала. Следующий раздел рассматривает это подробнее.
Пользовательский редактор промптов
Переходим к редактору промптов, заменяющему поле графического объекта OBJ_EDIT, использованное в предыдущей статье. В предыдущей версии программа получала текст промпта только после нажатия пользователем Enter, поскольку тогда MetaTrader генерировал событие завершения редактирования. Этот редактор использует собственный буфер, отрисовывает собственную каретку, обрабатывает нажатия клавиш и выводится непосредственно на канвасе. Поэтому он поддерживает многострочные промпты, редактирование в середине строки, выделение, копирование и вставку текста, установку позиции кликом и предварительный просмотр во время ввода.
Объявление класса редактора
Объявляется класс CAiEditor, содержащий все данные состояния редактора промптов и предоставляющий методы, вызываемые остальной программой.
//+------------------------------------------------------------------+ //| AI Canvas Editor.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 AI_CANVAS_EDITOR_MQH #define AI_CANVAS_EDITOR_MQH //--- Include required libraries #include <Canvas/Canvas.mqh> #include "AI Canvas Theme.mqh" #include "AI Canvas Primitives.mqh" #include "AI Canvas Scrollbar.mqh" //+------------------------------------------------------------------+ //| Multi-line scrollable text editor with caret and selection | //+------------------------------------------------------------------+ class CAiEditor { public: string buffer; // Full text content int caret; // Caret character position int anchor; // Selection anchor position (-1 = none) bool focused; // Focus state flag string fontName; // Font name for rendering int fontSize; // Font size in points int padX; // Horizontal padding int padY; // Vertical padding int wrapWidth; // Word-wrap width in pixels int lineH; // Line height in pixels AiScrollState scroll; // Vertical scroll state string visLines[]; // Visual line strings (post-wrap) int bufOffsets[]; // Buffer offsets per visual line bool blinkOn; // Caret blink toggle ulong lastBlinkMs; // Last blink toggle timestamp string placeholder; // Placeholder text when empty //--- Persistent off-screen canvas for clip and blend operations CAiCanvasFast tmpCanvas; // Reusable scratch canvas bool tmpCanvasReady; // Scratch canvas init flag int tmpCanvasW; // Scratch canvas width int tmpCanvasH; // Scratch canvas height string tmpCanvasName; // Unique scratch canvas object name CAiEditor(); void Init(const string fnt, int fsz, int padInX, int padInY); void SetWrapWidth(int wrapW); void SetPlaceholder(const string s) { placeholder = s; } void SetText(const string s); string GetText() const { return buffer; } bool IsEmpty() const { return StringLen(buffer) == 0; } void Rebuild(); bool HasSelection(); bool GetSelectionRange(int &s, int &e); bool DeleteSelection(); void ClearSelection() { anchor = -1; } void SelectAll(); void InsertChar(const string ch); void InsertNewline() { InsertChar("\n"); } void Backspace(); void DeleteChar(); void MoveCaretLeft(); void MoveCaretRight(); void MoveCaretUp(); void MoveCaretDown(); void MoveCaretHome(); void MoveCaretEnd(); void ShiftExtendLeft(); void ShiftExtendRight(); void ShiftExtendUp(); void ShiftExtendDown(); void ShiftExtendHome(); void ShiftExtendEnd(); void SetCaretFromMouse(int localX, int localY); bool HandleKeydown(int vk, bool shift, bool ctrl); void UpdateBlink(); void EnsureCaretVisible(); void Render(CCanvas &canvas, int rectL, int rectT, int rectR, int rectB, CAiCanvasPrimitives &prim); private: void FindLineCol(int caretPos, int &outLine, int &outCol); int ColPxToCharIndex(const string line, int xPx); int CharIndexToColPx(const string line, int charIdx); };
В начале файла включается защита от повторного включения AI_CANVAS_EDITOR_MQH. Затем подключаются заголовок канваса, константы темы, вспомогательные средства примитивов и модуль полосы прокрутки, используемый редактором при переполнении содержимого по вертикали.
Затем объявляется класс CAiEditor с одной конкретной задачей: дополнить строковый буфер всем необходимым для текстового поля ввода. Поле buffer содержит полный текст, а caret хранит позицию символа, куда будет вставлен следующий знак. Поле anchor обозначает другой край выделения; значение -1 означает отсутствие активного выделения. Поле focused определяет, направляются ли нажатия клавиш в этот редактор или другой. Поля шрифта и пара отступов задают геометрию отрисовки, а wrapWidth определяет доступную ширину в пикселях для переноса. Поле lineH кэширует высоту строки, чтобы не вычислять ее при каждой отрисовке.
Поле scroll – экземпляр AiScrollState, используемый редактором для вертикальной прокрутки, когда содержимое выходит за пределы видимой области. Это та же структура из только что рассмотренного модуля полосы прокрутки. Массив visLines содержит визуальные строки после выполнения Rebuild, а bufOffsets хранит смещение буфера, с которого начинается каждая из них. Это позволяет сопоставлять позиции каретки в буфере с координатами строк и столбцов на экране. Флаг blinkOn и временная метка lastBlinkMs управляют анимацией мигания каретки, а placeholder содержит серую строку подсказки, отрисовываемую при пустом буфере.
Четыре поля, связанные с tmpCanvas, кэшируют повторно используемый канвас вне экрана, применяемый для отсечения и альфа-смешивания подсветки выделения. Этот канвас создается при первой отрисовке, увеличивается только при росте прямоугольника редактора и повторно используется при всех следующих отрисовках. Поэтому при каждом нажатии клавиши не приходится уничтожать и создавать заново растровое изображение.
Объявления методов делятся примерно на четыре группы. Методы жизненного цикла и работы с содержимым – Init, SetWrapWidth, SetPlaceholder, SetText, GetText, IsEmpty и Rebuild – позволяют оболочке настраивать редактор, а модулю отрисовки получать текущее содержимое. Методы выделения – HasSelection, GetSelectionRange, DeleteSelection, ClearSelection и SelectAll – управляют выделенным диапазоном. Методы редактирования – InsertChar, InsertNewline, Backspace и DeleteChar – выполняют изменения, вызываемые нажатием клавиши. Методы перемещения каретки образуют пары: методы от MoveCaretLeft до MoveCaretEnd снимают выделение и перемещают каретку, а методы от ShiftExtendLeft до ShiftExtendEnd расширяют выделение в соответствующем направлении.
SetCaretFromMouse получает координаты клика в локальной системе пикселей редактора и устанавливает каретку у ближайшего символа. HandleKeydown – центральный диспетчер, вызываемый модулем взаимодействия для каждого события клавиатуры. Он получает виртуальный код клавиши и флаги модификаторов, после чего вызывает соответствующий метод. UpdateBlink по таймеру переключает blinkOn; EnsureCaretVisible прокручивает область просмотра, чтобы каретка оставалась видимой после перемещения; Render отрисовывает редактор на целевом канвасе внутри прямоугольника, заданного вызывающей стороной. Три закрытых вспомогательных метода – FindLineCol, ColPxToCharIndex и CharIndexToColPx – преобразуют позиции в буфере, координаты визуальных строк и пиксельные координаты. На этих вычислениях основана вся логика работы каретки. Далее рассмотрим часть методов класса; остальные используют ту же логику.
Создание, перенос буфера и редактирование с учетом выделения
Определяются конструктор редактора, методы установки содержимого, процедура переноса, формирующая визуальные строки из буфера, а также методы вставки и удаления с учетом выделения.
//+------------------------------------------------------------------+ //| Construct editor with default state | //+------------------------------------------------------------------+ CAiEditor::CAiEditor() { //--- Initialize text and selection state buffer = ""; caret = 0; anchor = -1; focused = false; //--- Initialize font and layout defaults fontName = "Arial"; fontSize = AI_FONT_BODY; padX = 8; padY = 6; wrapWidth = 200; lineH = 16; //--- Initialize scroll, blink, and placeholder AiScrollInit(scroll); blinkOn = true; lastBlinkMs = 0; placeholder = ""; //--- Initialize scratch canvas state tmpCanvasReady = false; tmpCanvasW = 0; tmpCanvasH = 0; //--- Generate unique scratch canvas name per instance static int s_editorInstanceCounter = 0; s_editorInstanceCounter++; tmpCanvasName = "AiEditorTmpPersistent_" + IntegerToString(s_editorInstanceCounter); } //+------------------------------------------------------------------+ //| Initialize font and padding | //+------------------------------------------------------------------+ void CAiEditor::Init(const string fnt, int fsz, int padInX, int padInY) { //--- Apply font and padding settings fontName = fnt; fontSize = fsz; padX = padInX; padY = padInY; //--- Compute line height from font metrics lineH = AiTextHeight(fnt, fsz) + 2; } //+------------------------------------------------------------------+ //| Set wrap width and rebuild visual lines | //+------------------------------------------------------------------+ void CAiEditor::SetWrapWidth(int w) { //--- Bail if unchanged if(w == wrapWidth) return; wrapWidth = w; Rebuild(); } //+------------------------------------------------------------------+ //| Replace buffer content and reset caret | //+------------------------------------------------------------------+ void CAiEditor::SetText(const string s) { //--- Replace buffer and reset selection buffer = s; caret = StringLen(buffer); anchor = -1; Rebuild(); } //+------------------------------------------------------------------+ //| Check if a non-empty selection exists | //+------------------------------------------------------------------+ bool CAiEditor::HasSelection() { //--- No anchor or zero-width range means no selection if(anchor < 0) return false; if(anchor == caret) return false; return true; } //+------------------------------------------------------------------+ //| Get normalized selection range start/end | //+------------------------------------------------------------------+ bool CAiEditor::GetSelectionRange(int &s, int &e) { //--- Bail when no selection if(!HasSelection()) { s = e = 0; return false; } //--- Normalize ordering s = (anchor < caret) ? anchor : caret; e = (anchor > caret) ? anchor : caret; //--- Clamp to buffer bounds const int len = StringLen(buffer); if(s < 0) s = 0; if(e > len) e = len; if(s > e) s = e; return e > s; } //+------------------------------------------------------------------+ //| Delete the current selection from buffer | //+------------------------------------------------------------------+ bool CAiEditor::DeleteSelection() { //--- Bail if no selection int s, e; if(!GetSelectionRange(s, e)) return false; //--- Splice buffer around selection const int len = StringLen(buffer); const string before = (s > 0) ? StringSubstr(buffer, 0, s) : ""; const string after = (e < len) ? StringSubstr(buffer, e, len - e) : ""; buffer = before + after; //--- Reset caret to splice point caret = s; anchor = -1; Rebuild(); return true; } //+------------------------------------------------------------------+ //| Select the entire buffer content | //+------------------------------------------------------------------+ void CAiEditor::SelectAll() { //--- Anchor to start, caret at end const int len = StringLen(buffer); if(len <= 0) { anchor = -1; caret = 0; return; } anchor = 0; caret = len; } //+------------------------------------------------------------------+ //| Insert character/string at caret | //+------------------------------------------------------------------+ void CAiEditor::InsertChar(const string ch) { //--- Bail on empty input if(StringLen(ch) == 0) return; //--- Replace selection if present DeleteSelection(); //--- Clamp caret to valid range int len = StringLen(buffer); if(caret < 0) caret = 0; if(caret > len) caret = len; //--- Splice in the new content string before = StringSubstr(buffer, 0, caret); string after = StringSubstr(buffer, caret, len - caret); buffer = before + ch + after; caret += StringLen(ch); Rebuild(); EnsureCaretVisible(); } //+------------------------------------------------------------------+ //| Delete character before caret | //+------------------------------------------------------------------+ void CAiEditor::Backspace() { //--- Delete selection first if present if(DeleteSelection()) { EnsureCaretVisible(); return; } if(caret <= 0) return; //--- Splice out character before caret int len = StringLen(buffer); string before = StringSubstr(buffer, 0, caret - 1); string after = (caret < len) ? StringSubstr(buffer, caret, len - caret) : ""; buffer = before + after; caret--; Rebuild(); EnsureCaretVisible(); } //+------------------------------------------------------------------+ //| Delete character at caret | //+------------------------------------------------------------------+ void CAiEditor::DeleteChar() { //--- Delete selection first if present if(DeleteSelection()) { EnsureCaretVisible(); return; } int len = StringLen(buffer); if(caret >= len) return; //--- Splice out character at caret string before = StringSubstr(buffer, 0, caret); string after = StringSubstr(buffer, caret + 1, len - caret - 1); buffer = before + after; Rebuild(); EnsureCaretVisible(); }
Конструктор CAiEditor::CAiEditor задает полям безопасные значения по умолчанию: пустой буфер, каретку в позиции 0, отсутствие якоря выделения и фокуса, основной шрифт Arial, горизонтальные отступы 8 пикселей, вертикальные – 6 пикселей, ширину переноса 200 пикселей, переопределяемую оболочкой при запуске, и высоту строки 16 пикселей. Статический счетчик экземпляров гарантирует, что каждый созданный в программе редактор получит уникальное имя объекта временного канваса, например "AiEditorTmpPersistent_1". Это необходимо, поскольку два редактора с одним именем канваса перезаписывали бы буферы друг друга.
Init применяет настройки шрифта и отступов и пересчитывает lineH по фактическим метрикам шрифта, возвращаемым AiTextHeight, добавляя 2 пикселя интерлиньяжа. SetWrapWidth обновляет доступную ширину для переноса и перестраивает визуальные строки, если значение действительно изменилось. SetText полностью заменяет содержимое буфера, устанавливает каретку в конец, очищает выделение и выполняет перестроение.
HasSelection возвращает true, только если якорь задан и отличается от позиции каретки. Поэтому устаревший якорь в той же позиции, что и каретка, считается отсутствием выделения. GetSelectionRange приводит якорь и каретку к возрастающим значениям начала и конца независимо от направления перетаскивания, после чего ограничивает результат границами буфера. DeleteSelection удаляет из буфера выделенный диапазон, устанавливает каретку в точку удаления, очищает якорь и выполняет перестроение. SelectAll устанавливает якорь в позицию 0 и перемещает каретку в конец буфера.
InsertChar – основной метод вставки. Сначала удаляется существующее выделение, поэтому ввод поверх выделенного диапазона заменяет его. Затем каретка ограничивается допустимыми границами, новый текст вставляется в буфер в ее позиции, каретка сдвигается на длину вставки, визуальные строки перестраиваются, а EnsureCaretVisible прокручивает область просмотра, если каретка вышла за ее пределы. InsertNewline – однострочная обертка, вызывающая InsertChar с аргументом "\n", чтобы при переносе он рассматривался как логический разрыв строки.
Backspace и DeleteChar используют тот же принцип приоритета выделения: при наличии выделения они удаляют его и завершаются; в противном случае удаляют символ перед кареткой или в позиции каретки соответственно. Обе операции перестраивают визуальные строки и затем вызывают EnsureCaretVisible, чтобы область просмотра следовала за кареткой при каждом редактировании. Остальные методы построены по тому же принципу. Ниже показано, как обрабатывается ввод с клавиатуры.
Преобразование событий клавиатуры в операции редактирования
HandleKeydown определяется как центральный диспетчер, преобразующий виртуальный код клавиши и флаги модификаторов в нужное действие редактора. Он заменяет использование встроенной обработки клавиатуры OBJ_EDIT в MetaTrader из предыдущей статьи.
//+------------------------------------------------------------------+ //| Process keyboard input event | //+------------------------------------------------------------------+ bool CAiEditor::HandleKeydown(int vk, bool shift, bool ctrl) { //--- Ignore when unfocused if(!focused) return false; //--- Ignore modifier-only keys if(vk == 16 || vk == 17 || vk == 18 || vk == 20 || vk == 144 || vk == 145 || vk == 91 || vk == 92 || vk == 93) return false; //--- Handle shift-modified navigation (selection extension) if(shift) { if(vk == 37) { ShiftExtendLeft(); return true; } if(vk == 39) { ShiftExtendRight(); return true; } if(vk == 38) { ShiftExtendUp(); return true; } if(vk == 40) { ShiftExtendDown(); return true; } if(vk == 36) { ShiftExtendHome(); return true; } if(vk == 35) { ShiftExtendEnd(); return true; } } //--- Handle plain navigation keys if(vk == 37) { MoveCaretLeft(); return true; } if(vk == 39) { MoveCaretRight(); return true; } if(vk == 38) { MoveCaretUp(); return true; } if(vk == 40) { MoveCaretDown(); return true; } if(vk == 36) { MoveCaretHome(); return true; } if(vk == 35) { MoveCaretEnd(); return true; } //--- Handle editing keys if(vk == 8) { Backspace(); return true; } if(vk == 46) { DeleteChar(); return true; } if(vk == 13 && shift) { InsertNewline(); return true; } //--- Filter to printable virtual keys bool isPrintableVk = (vk == 32) || (vk >= 48 && vk <= 57) || (vk >= 65 && vk <= 90) || (vk >= 96 && vk <= 111) || (vk >= 186 && vk <= 223); if(!isPrintableVk) return false; //--- Translate to character and insert short uch = TranslateKey(vk); if(uch <= 0) return false; ushort code = (ushort)uch; string ch = ShortToString(code); InsertChar(ch); return true; }
Сначала выполняется немедленный выход, если редактор не находится в фокусе, чтобы нажатия клавиш для окна поиска или других элементов не попадали в этот редактор. Затем игнорируются события отдельных клавиш-модификаторов: Shift, Control, Alt, Caps Lock, Num Lock, Scroll Lock и клавиш Windows. Их виртуальные коды поступают как отдельные события при нажатии и отпускании, а обработка не должна вставлять фантомные символы или вызывать перемещение.
Далее навигация разделяется на две ветви в зависимости от того, удерживается ли Shift. При удержании Shift четыре клавиши со стрелками, а также Home и End расширяют выделение в соответствующих направлениях через семейство методов ShiftExtend. Без Shift те же шесть клавиш снимают активное выделение и перемещают каретку с помощью семейства методов MoveCaret. Здесь используются стандартные виртуальные коды Windows: 37-40 для четырех стрелок, 36 для Home и 35 для End. Именно эти значения событие графика передает в lparam события клавиатуры.
Далее рассматриваются клавиши редактирования. Код 8 соответствует Backspace, а код 46 – Delete; оба направляются соответствующим методам. Код 13 соответствует Enter, а комбинация Shift+Enter обрабатывается как вставка новой строки. Обычный Enter зарезервирован для отправки промпта, поэтому для добавления разрыва строки в многострочное сообщение используется Shift+Enter. Модуль взаимодействия перехватывает обычный Enter на более высоком уровне и направляет его к действию отправки до передачи центральному диспетчеру. Эти коды символы представлены в таблице кодов символов ASCII, с которой можно ознакомиться по ссылке. Ниже приведен пример обработки Backspace с кодом 8.

Наконец, из оставшихся виртуальных кодов отбираются только коды печатных клавиш. Допускаются пять диапазонов: пробел с кодом 32, цифровой ряд 48-57, буквенный ряд 65-90, коды цифровой клавиатуры 96-111 и блок пунктуации OEM 186-223. Все остальные коды пропускаются, а функция возвращает false, чтобы обработчик событий графика мог направить их в другое место. Для кодов, прошедших фильтр, вызывается TranslateKey – встроенная функция MetaTrader, учитывающая активную раскладку клавиатуры и состояние модификаторов и возвращающая введенный пользователем символ Unicode. Полученный код преобразуется в строку функцией ShortToString и передается в InsertChar. Оставшуюся работу выполняют уже рассмотренные механизм замены выделения и отслеживание области просмотра в методе вставки. Теперь редактирование обрабатывается в одном месте, а не в двух отдельных областях отображения. Ниже приведен пример для сравнения.

Далее рассмотрим логику отрисовки, объединяющую все элементы для отображения. Она описана в следующем разделе.
Оркестрация отрисовки и пользовательский интерфейс
Переходим к модулю отрисовки, формирующему каждый пиксель, который видит пользователь. В предыдущей статье отрисовка выполнялась через графические объекты, поэтому каждый визуальный элемент представлял собой отдельный OBJ_LABEL или OBJ_BUTTON. Большая часть работы по компоновке сводилась к перемещению этих объектов при обновлении. Здесь вся панель управления представляет собой одно растровое изображение канваса, полностью перерисовываемое при каждом изменении состояния. Сверху расположен отдельный канвас наложения промпта для редактора. Мы не будем повторно рассматривать отрисовку отдельных элементов: заливка, рамки, оттенки при наведении, наложение изображений и размещение меток используют уже рассмотренные шаблоны. Вместо этого сосредоточимся на новых компонентах: глобальном состоянии модуля отрисовки, тост-уведомлении, кэше разбора панели чата и предварительной обработке Markdown, а также составной кнопке сигнала с раскрывающимся списком.
Глобальные переменные отрисовки, коды наведения курсора и тост-уведомление
Объявляются экземпляры канваса, рабочие буферы вне экрана, состояния прокрутки, буферы изображений и константы кодов наведения курсора, используемые остальной частью модуля отрисовки. Также определяются вспомогательные функции тост-уведомлений, выводящие временные баннеры после деструктивных действий.
//+------------------------------------------------------------------+ //| AI Canvas 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 AI_CANVAS_RENDER_MQH #define AI_CANVAS_RENDER_MQH //--- Include required modules #include <Canvas/Canvas.mqh> #include "AI Canvas Theme.mqh" #include "AI Canvas State.mqh" #include "AI Canvas Primitives.mqh" #include "AI Canvas Scrollbar.mqh" #include "AI Canvas Editor.mqh" //+------------------------------------------------------------------+ //| Canvas Objects | //+------------------------------------------------------------------+ CCanvas g_ai_canvMain; // Main dashboard canvas CCanvas g_ai_canvSmallPopup; // Small history popup canvas CCanvas g_ai_canvBigPopup; // Big popup canvas CCanvas g_ai_canvSearchPopup; // Search popup canvas CCanvas g_ai_canvPrompt; // Prompt-pane overlay canvas bool g_ai_canvPromptExists = false; // Prompt overlay init flag //+------------------------------------------------------------------+ //| Persistent Off-screen Clip Buffers | //+------------------------------------------------------------------+ CAiCanvasFast g_ai_canvChatTmp; // Chat-pane scratch canvas bool g_ai_canvChatTmpReady = false; // Chat scratch init flag int g_ai_canvChatTmpW = 0; // Chat scratch width int g_ai_canvChatTmpH = 0; // Chat scratch height CAiCanvasFast g_ai_canvSearchTmp; // Search popup scratch canvas bool g_ai_canvSearchTmpReady = false; // Search scratch init flag int g_ai_canvSearchTmpW = 0; // Search scratch width int g_ai_canvSearchTmpH = 0; // Search scratch height bool g_ai_canvMainExists = false; // Main canvas init flag bool g_ai_canvSmallExists = false; // Small popup init flag bool g_ai_canvBigExists = false; // Big popup init flag bool g_ai_canvSearchExists = false; // Search popup init flag //+------------------------------------------------------------------+ //| Drawing Primitives and Editor Instances | //+------------------------------------------------------------------+ CAiCanvasPrimitives g_ai_prim; // Drawing primitives helper CAiEditor g_ai_editor; // Main prompt editor CAiEditor g_ai_searchEditor; // Search query editor //+------------------------------------------------------------------+ //| Test if prompt editor is empty or whitespace-only | //+------------------------------------------------------------------+ bool Ai_PromptIsEmpty() { //--- Read editor text and check length const string txt = g_ai_editor.GetText(); const int n = StringLen(txt); if(n == 0) return true; //--- Walk characters looking for non-whitespace for(int i = 0; i < n; i++) { const ushort c = StringGetCharacter(txt, i); if(c != ' ' && c != '\t' && c != '\n' && c != '\r') return false; } return true; } //+------------------------------------------------------------------+ //| Scroll States | //+------------------------------------------------------------------+ AiScrollState g_ai_chatScroll; // Chat pane scroll state AiScrollState g_ai_bigScroll; // Big popup scroll state AiScrollState g_ai_searchScroll; // Search popup scroll state //+------------------------------------------------------------------+ //| Image Pixel Buffers | //+------------------------------------------------------------------+ uint g_ai_pixHeader[]; // Header image pixels uint g_ai_pixSidebarBig[]; // Expanded sidebar logo uint g_ai_pixSidebarSmall[]; // Collapsed sidebar logo uint g_ai_pixNewChat[]; // New chat icon pixels uint g_ai_pixClear[]; // Clear icon pixels uint g_ai_pixHistory[]; // History icon pixels uint g_ai_pixSearch[]; // Search icon pixels bool g_ai_imagesLoaded = false; // Images loaded flag //+------------------------------------------------------------------+ //| Hover Code Constants | //+------------------------------------------------------------------+ #define AI_HOV_NONE 0 // No hover #define AI_HOV_CLOSE 1 // Close button #define AI_HOV_TOGGLE 2 // Sidebar toggle #define AI_HOV_NEW_CHAT 3 // New chat button #define AI_HOV_CLEAR 4 // Clear chat button #define AI_HOV_HISTORY 5 // History button #define AI_HOV_SEARCH 6 // Search button #define AI_HOV_CHART 7 // Chart action #define AI_HOV_SIGNAL 8 // Signal action half #define AI_HOV_SEND 9 // Send button #define AI_HOV_REGEN 10 // Regenerate button #define AI_HOV_EXPORT 11 // Export button #define AI_HOV_BIG_CLOSE 12 // Big popup close #define AI_HOV_SEARCH_CLOSE 13 // Search popup close #define AI_HOV_SEE_MORE 14 // See more link #define AI_HOV_THEME 15 // Theme toggle #define AI_HOV_DRAG 16 // Header drag area #define AI_HOV_FOOTER_DD 17 // Footer dropdown anchor #define AI_HOV_SIGNAL_DD 18 // Signal chevron half #define AI_HOV_SCROLL_FAB 19 // Scroll-to-bottom FAB #define AI_HOV_SIDE_CHAT_BASE 100 // Sidebar chat row base #define AI_HOV_SIDE_DEL_BASE 200 // Sidebar delete base #define AI_HOV_SMALL_CHAT_BASE 300 // Small popup chat row base #define AI_HOV_SMALL_DEL_BASE 400 // Small popup delete base #define AI_HOV_BIG_CHAT_BASE 500 // Big popup chat row base #define AI_HOV_BIG_DEL_BASE 600 // Big popup delete base #define AI_HOV_SEARCH_CHAT_BASE 700 // Search popup chat row base #define AI_HOV_SEARCH_DEL_BASE 800 // Search popup delete base #define AI_HOV_FOOTER_DD_ITEM_BASE 900 // Footer dropdown item base #define AI_HOV_USER_EDIT_BASE 1000 // User edit pencil base //--- Width of chevron-half zone in split signal button #define AI_SIGNAL_DD_ZONE_W 28 //+------------------------------------------------------------------+ //| Hover and Mouse Tracking | //+------------------------------------------------------------------+ int g_ai_hover = AI_HOV_NONE; // Current hover code int g_ai_mouseLx = -1; // Last canvas-local mouse X int g_ai_mouseLy = -1; // Last canvas-local mouse Y //+------------------------------------------------------------------+ //| Action Button Rectangles | //+------------------------------------------------------------------+ int g_ai_regenL = 0, g_ai_regenT = 0, g_ai_regenR = 0, g_ai_regenB = 0; // Regenerate button rect int g_ai_exportL = 0, g_ai_exportT = 0, g_ai_exportR = 0, g_ai_exportB = 0; // Export button rect //+------------------------------------------------------------------+ //| Scroll-to-bottom FAB Rectangle | //+------------------------------------------------------------------+ int g_ai_scrollFabL = 0, g_ai_scrollFabT = 0, g_ai_scrollFabR = 0, g_ai_scrollFabB = 0; // FAB rect bool g_ai_scrollFabVisible = false; // FAB visibility flag //+------------------------------------------------------------------+ //| Per-User-Message Edit Pencil State | //+------------------------------------------------------------------+ int g_ai_userEditRectL[]; // Wide hover rect left bounds int g_ai_userEditRectT[]; // Wide hover rect top bounds int g_ai_userEditRectR[]; // Wide hover rect right bounds int g_ai_userEditRectB[]; // Wide hover rect bottom bounds int g_ai_userEditClickL[]; // Narrow click rect left bounds int g_ai_userEditClickT[]; // Narrow click rect top bounds int g_ai_userEditClickR[]; // Narrow click rect right bounds int g_ai_userEditClickB[]; // Narrow click rect bottom bounds string g_ai_userEditPrompt[]; // Cached prompt text per pencil bool g_ai_chatScrollPin = false; // Chat scroll auto-pin to bottom flag //+------------------------------------------------------------------+ //| Set toast notification text and start timer | //+------------------------------------------------------------------+ void Ai_ShowToast(string text, bool isError) { //--- Store toast text and 5-second expiry g_ai_toastText = text; g_ai_toastIsError = isError; g_ai_toastExpiryMs = GetTickCount64() + 5000; } //+------------------------------------------------------------------+ //| Render toast notification overlay | //+------------------------------------------------------------------+ void Ai_RenderToast() { //--- Bail when no active toast if(StringLen(g_ai_toastText) == 0) return; const ulong now = GetTickCount64(); if(now > g_ai_toastExpiryMs) return; //--- Setup padding and font const int padX = 16; const int padY = 8; const string toastFont = "Arial Bold"; const int toastSize = 10; //--- Measure text dimensions const int textW = AiTextWidth(g_ai_toastText, toastFont, toastSize); const int textH = AiTextHeight(toastFont, toastSize); //--- Setup progress bar geometry const int barH = 2; const int barGap = 6; const int boxW = textW + 2 * padX; const int boxH = textH + barGap + barH + 2 * padY; //--- Center horizontally below header const int mainContentL = Ai_MainContentX(); const int mainContentR = Ai_DashboardW(); const int boxL = mainContentL + ((mainContentR - mainContentL) - boxW) / 2; const int boxT = AI_HEADER_H + 6; //--- Fill toast box g_ai_prim.FillRoundRectSharp(g_ai_canvMain, boxL, boxT, boxW, boxH, 8, ColorToARGB(g_ai_toastBg, 255)); //--- Draw box border g_ai_prim.DrawRoundRectBorderSharp(g_ai_canvMain, boxL, boxT, boxW, boxH, 8, 1, ColorToARGB(g_ai_toastBorder, 255)); //--- Stamp toast text with success/error color const color textCol = g_ai_toastIsError ? g_ai_toastError : g_ai_toastSuccess; AiStampTextAA(g_ai_canvMain, boxL + padX, boxT + padY, g_ai_toastText, toastFont, toastSize, textCol); //--- Compute progress bar fill width from remaining lifetime const int trackL = boxL + padX; const int trackR = boxL + boxW - padX; const int trackW = trackR - trackL; const int trackY = boxT + padY + textH + barGap; const ulong totalLifeMs = 5000; const long remaining = (long)g_ai_toastExpiryMs - (long)now; double ratio = (double)remaining / (double)totalLifeMs; if(ratio < 0.0) ratio = 0.0; if(ratio > 1.0) ratio = 1.0; const int fillW = (int)(trackW * ratio); const int fillL = trackL + (trackW - fillW) / 2; //--- Draw track background g_ai_canvMain.FillRectangle(trackL, trackY, trackR - 1, trackY + barH - 1, ColorToARGB(g_ai_toastBorder, 255)); //--- Draw shrinking fill segment if(fillW > 0) { g_ai_canvMain.FillRectangle(fillL, trackY, fillL + fillW - 1, trackY + barH - 1, ColorToARGB(textCol, 255)); } }
Сначала объявляются пять экземпляров CCanvas: основной канвас панели управления g_ai_canvMain, канвас наложения промпта g_ai_canvPrompt и три канваса всплывающих окон, используемые модулем отрисовки как отдельные поверхности. g_ai_canvChatTmp и g_ai_canvSearchTmp – постоянные экземпляры CAiCanvasFast вне экрана. Модуль отрисовки использует их как рабочие буферы для операций отсечения и смешивания. Они создаются при первом использовании, увеличиваются при необходимости через соответствующие поля ширины и высоты и повторно используются при каждой отрисовке. Поэтому растровые изображения не приходится создавать и уничтожать в каждом кадре.
Объявляются по одному экземпляру вспомогательного класса примитивов и двух редакторов: g_ai_editor для панели промпта и отдельный g_ai_searchEditor для поля запроса всплывающего окна поиска. Также определяется Ai_PromptIsEmpty, возвращающая true, если буфер редактора пуст или содержит только пробельные символы. Кнопка отправки использует этот флаг для отключения, когда нет промпта для отправки. Три экземпляра AiScrollState – g_ai_chatScroll, g_ai_bigScroll и g_ai_searchScroll – хранят позицию прокрутки панели чата и двух прокручиваемых всплывающих окон.
Буферы пикселей изображений от g_ai_pixHeader до g_ai_pixSearch содержат масштабированные растровые данные логотипов и значков боковой панели. Ai_LoadImages однократно загружает их из ресурсов программы, после чего они наносятся на канвас в нужных местах. Код наведения курсора задает соответствие между каждым кликабельным элементом и уникальным целым числом. Модуль взаимодействия записывает это значение в g_ai_hover, а модуль отрисовки использует его для выбора нужного оттенка. Базовые коды от одного до девятнадцати соответствуют отдельным элементам, например кнопке закрытия или переключателю темы. Базовые коды строк, начинающиеся со ста, выделяют диапазоны для строк списка чатов, строк всплывающих списков, элементов раскрывающегося списка и значков редактирования сообщений. Благодаря этому одна система проверки попаданий обрабатывает все списки переменной длины в программе.
Прямоугольники кнопок действий от g_ai_regenL до g_ai_exportB сохраняют позиции значков повторной генерации и экспорта после каждой отрисовки. Это позволяет обработчику кликов проверять попадание без повторного расчета компоновки. Группа прямоугольников кнопки FAB прокрутки вниз и флаг g_ai_scrollFabVisible задают состояние небольшой плавающей кнопки действия. Она появляется, когда пользователь находится далеко от конца длинного чата. Восемь массивов для значков редактирования сообщений пользователя хранят широкие прямоугольники наведения, узкие прямоугольники клика и кэшированный текст промпта каждого сообщения пользователя в чате. При клике по значку соответствующий промпт загружается в редактор для изменения.
Затем определяется Ai_ShowToast – единая точка входа для вывода временных уведомлений из остальных частей программы. Функция сохраняет текст, флаг ошибки или успеха и временную метку истечения срока действия, установленную на пять секунд вперед. Модуль взаимодействия вызывает эту функцию после деструктивных действий: удаления чата, очистки переписки или удаления с графика графических объектов сигнала. Модуль логики вызывает ее при сбоях API.
Ai_RenderToast отрисовывает активное тост-уведомление на основном канвасе. Если тост-уведомление не задано или срок его действия уже истек, функция завершает работу. Затем она измеряет текст и размещает по центру под заголовком блок с отступами вокруг текста и двухпиксельной полосой прогресса внизу. Блок и его рамка заполняются цветами темы; текст выводится зеленым при успехе и красным при ошибке. Полоса прогресса отрисовывается в два прохода: сначала дорожка цвета рамки по всей ширине, затем центральный заполненный сегмент, ширина которого пропорционально уменьшается до нуля по мере истечения времени. Сжатие от центра создает визуальный обратный отсчет вместо внезапного исчезновения, поэтому пользователь видит, сколько времени остается на чтение сообщения. После подключения получается следующий результат.

Далее подключим всплывающее окно поиска, для прокрутки которого используется внеэкранный канвас отсечения.
Всплывающее окно поиска, кэш фильтра и пайплайн отрисовки
Определяются Ai_RenderPopup – единая функция отрисовки всплывающих окон поиска и истории, состояние всплывающих окон, а также вспомогательные функции Ai_GetPopupRect и Ai_FirstPromptSnippet. Также определяется Ai_RenderAll, обеспечивающая отрисовку всей панели управления в правильном порядке слоев.
//+------------------------------------------------------------------+ //| Search Box Rect State | //+------------------------------------------------------------------+ int g_ai_popupSearchL = 0, g_ai_popupSearchT = 0; // Search box top-left int g_ai_popupSearchR = 0, g_ai_popupSearchB = 0; // Search box bottom-right //+------------------------------------------------------------------+ //| Render search or history popup | //+------------------------------------------------------------------+ void Ai_RenderPopup(int anchorIdx) { //--- Get popup rect and store globally int pL, pT, pR, pB; Ai_GetPopupRect(anchorIdx, pL, pT, pR, pB); g_ai_popupL = pL; g_ai_popupT = pT; g_ai_popupR = pR; g_ai_popupB = pB; const int popupW = pR - pL; const int popupH = pB - pT; //--- Fill popup background (square for search, rounded for history) if(anchorIdx == 0) { g_ai_canvMain.FillRectangle(pL, pT, pR - 1, pB - 1, ColorToARGB(g_ai_panelAlt, 255)); } else { g_ai_prim.FillRoundRectSharp(g_ai_canvMain, pL, pT, popupW, popupH, 8, ColorToARGB(g_ai_panelAlt, 255)); g_ai_prim.DrawRoundRectBorderSharp(g_ai_canvMain, pL, pT, popupW, popupH, 8, 1, ColorToARGB(g_ai_borderAccent, 255)); } //--- Stamp popup title const string title = (anchorIdx == 0) ? "Search Chats" : "Recent Chats"; AiStampTextAA(g_ai_canvMain, pL + 16, pT + 12, title, "Arial Bold", AI_FONT_LABEL, g_ai_titleText); //--- Compute layout heights const int rowH = 44; const int titleArea = (anchorIdx == 0) ? 36 : 28; const int searchArea = (anchorIdx == 0) ? 38 : 0; //--- Render search input box for search popup if(anchorIdx == 0) { //--- Compute search box rect int siL = pL + 16; int siT = pT + titleArea; int siR = pR - 16; int siB = siT + searchArea - 6; g_ai_popupSearchL = siL; g_ai_popupSearchT = siT; g_ai_popupSearchR = siR; g_ai_popupSearchB = siB; //--- Fill search box and draw border g_ai_prim.FillRoundRectSharp(g_ai_canvMain, siL, siT, siR - siL, siB - siT, 4, ColorToARGB(g_ai_bg, 255)); g_ai_prim.DrawRoundRectBorderSharp(g_ai_canvMain, siL, siT, siR - siL, siB - siT, 4, 1, ColorToARGB(g_ai_borderAccent, 255)); //--- Vertically center the search editor const int boxH = siB - siT; const int lineH = AiTextHeightCached("Arial", AI_FONT_BODY) + 2; const int edL = siL + 8; const int edT = siT + (boxH - lineH) / 2 - g_ai_searchEditor.padY; const int edR = siR - 4; const int edB = edT + lineH + 2 * g_ai_searchEditor.padY; g_ai_searchEditor.Render(g_ai_canvMain, edL, edT, edR, edB, g_ai_prim); } else { //--- Reset search box rect when not search popup g_ai_popupSearchL = g_ai_popupSearchT = g_ai_popupSearchR = g_ai_popupSearchB = 0; } //--- Build filtered chat list int filteredIdx[]; const int total = ArraySize(g_ai_chats); string queryLower = ""; if(anchorIdx == 0 && StringLen(g_ai_searchQuery) > 0) { queryLower = g_ai_searchQuery; StringToLower(queryLower); } //--- Filter and lowercase cache (static across renders) #define AI_SEARCH_HISTORY_CAP 4096 static string s_cacheLowerTitles[]; static string s_cacheLowerHistories[]; static int s_cacheChatCount = -1; static int s_cacheTitleLenSum = -1; static int s_cacheHistoryLenSum = -1; static string s_cacheFilterQuery = "\x01"; static int s_cacheFilterIdx[]; //--- Compute current chats fingerprint int curTitleLenSum = 0; int curHistoryLenSum = 0; for(int fi = 0; fi < total; fi++) { curTitleLenSum += StringLen(g_ai_chats[fi].title); const int hLen = StringLen(g_ai_chats[fi].history); curHistoryLenSum += (hLen < AI_SEARCH_HISTORY_CAP) ? hLen : AI_SEARCH_HISTORY_CAP; } const bool chatsCacheValid = (s_cacheChatCount == total) && (s_cacheTitleLenSum == curTitleLenSum) && (s_cacheHistoryLenSum == curHistoryLenSum); //--- Rebuild lowercase cache when chats change if(!chatsCacheValid) { ArrayResize(s_cacheLowerTitles, total); ArrayResize(s_cacheLowerHistories, total); for(int li = 0; li < total; li++) { //--- Lowercase title string tL = g_ai_chats[li].title; StringToLower(tL); //--- Lowercase capped history head string hRaw = g_ai_chats[li].history; if(StringLen(hRaw) > AI_SEARCH_HISTORY_CAP) hRaw = StringSubstr(hRaw, 0, AI_SEARCH_HISTORY_CAP); StringToLower(hRaw); //--- Store in cache s_cacheLowerTitles[li] = tL; s_cacheLowerHistories[li] = hRaw; } s_cacheChatCount = total; s_cacheTitleLenSum = curTitleLenSum; s_cacheHistoryLenSum = curHistoryLenSum; //--- Invalidate filter cache too s_cacheFilterQuery = "\x01"; } //--- Use filter cache when query and chats are unchanged if(anchorIdx == 0 && chatsCacheValid && s_cacheFilterQuery == g_ai_searchQuery) { //--- Reuse cached filter result const int cn = ArraySize(s_cacheFilterIdx); ArrayResize(filteredIdx, cn); for(int ci = 0; ci < cn; ci++) filteredIdx[ci] = s_cacheFilterIdx[ci]; } else { //--- Filter chats with title-first short-circuit for(int i = total - 1; i >= 0; i--) { if(StringLen(queryLower) > 0) { if(StringFind(s_cacheLowerTitles[i], queryLower) >= 0) { //--- Title hit - include without scanning history } else if(StringFind(s_cacheLowerHistories[i], queryLower) < 0) { continue; } } //--- Append index to filtered list int sz = ArraySize(filteredIdx); ArrayResize(filteredIdx, sz + 1); filteredIdx[sz] = i; } //--- Snapshot to filter cache if(anchorIdx == 0) { const int sn = ArraySize(filteredIdx); ArrayResize(s_cacheFilterIdx, sn); for(int si = 0; si < sn; si++) s_cacheFilterIdx[si] = filteredIdx[si]; s_cacheFilterQuery = g_ai_searchQuery; } } //--- Compute row content area bounds const int rowsTop = pT + titleArea + searchArea; const int rowsBot = pB - 8; const int viewportH = MathMax(0, rowsBot - rowsTop); const int totalFiltered = ArraySize(filteredIdx); //--- Determine layout based on popup type bool useScrollbar = false; int visStart = 0; int visCount = 0; const int sbW = 4; //--- Search popup uses virtualized list with scrollbar if(anchorIdx == 0) { const int contentH = totalFiltered * rowH; g_ai_searchScroll.totalH = contentH; g_ai_searchScroll.viewportH = viewportH; if(AiScrollVisible(g_ai_searchScroll)) useScrollbar = true; AiScrollClamp(g_ai_searchScroll); //--- Compute first partially visible row and count visStart = MathMax(0, g_ai_searchScroll.scrollPx / rowH); const int startScreenY = rowsTop + visStart * rowH - g_ai_searchScroll.scrollPx; const int yRoom = rowsBot - startScreenY; visCount = MathMin(totalFiltered - visStart, (yRoom + rowH - 1) / rowH + 1); if(visCount < 0) visCount = 0; if(visStart + visCount > totalFiltered) visCount = totalFiltered - visStart; } else { //--- History popup caps at 8 rows visStart = 0; visCount = MathMin(totalFiltered, 8); } //--- Allocate row hit-test arrays ArrayResize(g_ai_popupRowL, visCount); ArrayResize(g_ai_popupRowT, visCount); ArrayResize(g_ai_popupRowR, visCount); ArrayResize(g_ai_popupRowB, visCount); ArrayResize(g_ai_popupRowChatIdx, visCount); //--- Prepare clip canvas for search popup virtualization const bool useClipCanvas = (anchorIdx == 0 && visCount > 0); if(useClipCanvas) { //--- Ensure search scratch canvas matches main size const int needW = g_ai_canvMain.Width(); const int needH = g_ai_canvMain.Height(); if(!g_ai_canvSearchTmpReady || g_ai_canvSearchTmpW < needW || g_ai_canvSearchTmpH < needH) { if(g_ai_canvSearchTmpReady) g_ai_canvSearchTmp.Destroy(); const int newW = MathMax(needW, g_ai_canvSearchTmpW); const int newH = MathMax(needH, g_ai_canvSearchTmpH); if(g_ai_canvSearchTmp.CreateBitmap("AiSearchPopupTmpPersistent", 0, 0, newW, newH, COLOR_FORMAT_ARGB_NORMALIZE)) { g_ai_canvSearchTmpW = newW; g_ai_canvSearchTmpH = newH; g_ai_canvSearchTmpReady = true; } } } //--- Seed scratch canvas with main pixels for clip blending if(useClipCanvas && g_ai_canvSearchTmpReady) { g_ai_canvSearchTmp.CopyRectFromCanvas(g_ai_canvMain, pL, rowsTop, pR, rowsBot); } //--- Render each visible row for(int v = 0; v < visCount; v++) { //--- Get chat index for this row const int filterPos = visStart + v; const int chatIdx = filteredIdx[filterPos]; //--- Compute row coords with scroll offset const int naturalY = rowsTop + filterPos * rowH; const int rT = naturalY - ((anchorIdx == 0) ? g_ai_searchScroll.scrollPx : 0); const int rB = rT + rowH - 2; const int rL = pL + 10; const int rR = pR - 10; //--- Clip hit rect to visible row band const int hitT = MathMax(rT, rowsTop); const int hitB = MathMin(rB, rowsBot); g_ai_popupRowL[v] = rL; g_ai_popupRowT[v] = hitT; g_ai_popupRowR[v] = rR; g_ai_popupRowB[v] = hitB; g_ai_popupRowChatIdx[v] = chatIdx; //--- Skip painting offscreen rows if(hitB <= hitT) continue; //--- Determine hover state and pick fill bool hov = (g_ai_popupHovRow == v); bool hovDel = (hov && g_ai_popupHovDel); color bg = hov ? g_ai_chatItemBgHover : g_ai_chatItemBg; //--- Render to clip canvas or main directly if(useClipCanvas && g_ai_canvSearchTmpReady) { //--- Fill row and draw border on scratch g_ai_prim.FillRoundRectSharp(g_ai_canvSearchTmp, rL, rT, rR - rL, rB - rT, 4, ColorToARGB(bg, 255)); g_ai_prim.DrawRoundRectBorderSharp(g_ai_canvSearchTmp, rL, rT, rR - rL, rB - rT, 4, 1, ColorToARGB(AiBorderForBg(bg), 255)); //--- Compute available width for text const int hpDeleteIconW = AiTextWidth(AI_GLYPH_DELETE, "Wingdings 2", 14); const int hpAvailW = (rR - rL) - 24 - hpDeleteIconW; //--- Stamp title on top line string titleText = Ai_FitTextToWidth(g_ai_chats[chatIdx].title, "Arial", AI_FONT_LABEL, hpAvailW); color titleCol = (g_ai_chats[chatIdx].id == g_ai_currentChatId) ? g_ai_chatItemActiveText : g_ai_titleText; AiStampTextAA(g_ai_canvSearchTmp, rL + 8, rT + 6, titleText, "Arial", AI_FONT_LABEL, titleCol); //--- Stamp snippet on bottom line string snippetRaw = Ai_FirstPromptSnippet(g_ai_chats[chatIdx].history); string snippetText = Ai_FitTextToWidth(snippetRaw, "Arial", AI_FONT_SNIPPET, hpAvailW); const int snippetY = rT + 6 + AiTextHeightCached("Arial", AI_FONT_LABEL) + 4; AiStampTextAA(g_ai_canvSearchTmp, rL + 8, snippetY, snippetText, "Arial", AI_FONT_SNIPPET, g_ai_subText); //--- Stamp delete glyph on hover if(hov) { color xCol = hovDel ? g_ai_chatItemDelHover : g_ai_subText; int xW = AiTextWidth(AI_GLYPH_DELETE, "Wingdings 2", 14); int xH = AiTextHeight("Wingdings 2", 14); AiStampTextAA(g_ai_canvSearchTmp, rR - 8 - xW, rT + ((rB - rT) - xH) / 2, AI_GLYPH_DELETE, "Wingdings 2", 14, xCol); } } else { //--- Render directly to main canvas (history popup path) g_ai_prim.FillRoundRectSharp(g_ai_canvMain, rL, rT, rR - rL, rB - rT, 4, ColorToARGB(bg, 255)); g_ai_prim.DrawRoundRectBorderSharp(g_ai_canvMain, rL, rT, rR - rL, rB - rT, 4, 1, ColorToARGB(AiBorderForBg(bg), 255)); //--- Compute available width for text const int hp2DeleteIconW = AiTextWidth(AI_GLYPH_DELETE, "Wingdings 2", 14); const int hp2AvailW = (rR - rL) - 24 - hp2DeleteIconW; //--- Stamp title and snippet string titleText = Ai_FitTextToWidth(g_ai_chats[chatIdx].title, "Arial", AI_FONT_LABEL, hp2AvailW); color titleCol = (g_ai_chats[chatIdx].id == g_ai_currentChatId) ? g_ai_chatItemActiveText : g_ai_titleText; AiStampTextAA(g_ai_canvMain, rL + 8, rT + 6, titleText, "Arial", AI_FONT_LABEL, titleCol); string snippetRaw = Ai_FirstPromptSnippet(g_ai_chats[chatIdx].history); string snippetText = Ai_FitTextToWidth(snippetRaw, "Arial", AI_FONT_SNIPPET, hp2AvailW); const int snippetY = rT + 6 + AiTextHeightCached("Arial", AI_FONT_LABEL) + 4; AiStampTextAA(g_ai_canvMain, rL + 8, snippetY, snippetText, "Arial", AI_FONT_SNIPPET, g_ai_subText); //--- Stamp delete glyph on hover if(hov) { color xCol = hovDel ? g_ai_chatItemDelHover : g_ai_subText; int xW = AiTextWidth(AI_GLYPH_DELETE, "Wingdings 2", 14); int xH = AiTextHeight("Wingdings 2", 14); AiStampTextAA(g_ai_canvMain, rR - 8 - xW, rT + ((rB - rT) - xH) / 2, AI_GLYPH_DELETE, "Wingdings 2", 14, xCol); } } } //--- Copy clipped rows back to main canvas if(useClipCanvas && g_ai_canvSearchTmpReady) { g_ai_canvSearchTmp.CopyRectToCanvas(g_ai_canvMain, pL, rowsTop, pR, rowsBot); } //--- Draw search popup scrollbar when needed if(anchorIdx == 0 && useScrollbar) { g_ai_searchScroll.trackL = pR - 7; g_ai_searchScroll.trackT = rowsTop; g_ai_searchScroll.trackR = g_ai_searchScroll.trackL + sbW; g_ai_searchScroll.trackB = rowsBot; AiScrollDraw(g_ai_canvMain, g_ai_searchScroll, g_ai_prim); } }
Здесь определяется Ai_RenderPopup для отрисовки обоих всплывающих окон. Если anchorIdx равен нулю, всплывающее окно поиска с полем запроса отрисовывается в верхней части. В противном случае список истории отрисовывается с привязкой к соответствующей кнопке боковой панели. Основная область всплывающего окна заполняется цветом g_ai_panelAlt, после чего выводится заголовок. В варианте поиска размещается поле ввода и вызывается g_ai_searchEditor.Render для отрисовки редактора внутри него.
Отдельного внимания заслуживают фильтр и кэш строк в нижнем регистре. Преобразовывать название и историю каждого чата в нижний регистр при каждом нажатии клавиши неэффективно. Поэтому сохраняется статический отпечаток массива чатов: общее число элементов, а также суммы длин названий и историй. Массивы названий и историй в нижнем регистре перестраиваются только при изменении этого отпечатка. Размер каждой кэшированной истории в нижнем регистре также ограничивается 4 килобайтами. Для поверхностного сопоставления, используемого всплывающим окном, достаточно совпадений в первых 4 килобайтах. Результат фильтра кэшируется по значению g_ai_searchQuery, поэтому при последующих отрисовках с тем же запросом используется предыдущий список совпадений. Цикл сопоставления сначала проверяет заголовок и использует "короткое замыкание": если название в нижнем регистре содержит запрос, чат добавляется без проверки истории. Поэтому поиск ключевых слов в названиях почти не требует дополнительных ресурсов.
Во всплывающем окне поиска используется виртуализированный список. По значению g_ai_searchScroll.scrollPx определяется первая частично видимая строка и число строк, помещающихся до нижней границы, после чего отрисовывается только этот участок списка. Во всплывающем окне истории выводится не более восьми строк, так как прокрутка в нем не используется. Сначала строки всплывающего окна поиска отрисовываются на постоянном рабочем канвасе g_ai_canvSearchTmp. Затем CopyRectToCanvas копирует отсеченную полосу на основной канвас. Поэтому строки, уходящие за верхнюю или нижнюю границу всплывающего окна при прокрутке, корректно обрезаются, а пиксели не выходят за его пределы. Всплывающее окно истории отрисовывается непосредственно на основном канвасе, так как его содержимое не выходит за границы окна. В каждой строке сверху выводится название, а на второй строке – первый фрагмент промпта. При наведении появляется подсветка и значок удаления.

Теперь перейдем к организации логики ИИ, рассмотренной в следующем разделе.
Слой логики ИИ – промпты, сигналы и сделки
Переходим к слою логики, который преобразует клик по кнопке в структурированный диалог с ИИ, разбирает результат, совершает сделки и наносит аннотации на график. Большая часть технической основы перенесена из предыдущей статьи: выгрузка данных графика, экранирование JSON, преобразование истории в сообщения, обработка запросов WebRequest, сохранение чатов с шифрованием AES и управление записями чатов. Рассмотрим новые элементы программы: протокол диспетчеризации, слой размещения ордеров, общие вспомогательные функции для чата и графика, а также семь сигнальных действий, использующих эти компоненты.
Протокол диспетчеризации – вводный блок с определениями баров, форматирование баров и разбор пар "ключ:значение"
Определяются три вспомогательные функции, формирующие протокол взаимодействия программы с ИИ: общий вводный блок с определениями баров, с которого начинается каждый сигнальный промпт; функция форматирования баров, располагающая данные OHLC в обратном хронологическом порядке; и парсер Ai_ParseKVResponse, извлекающий значения из построчных ответов.
//+------------------------------------------------------------------+ //| AI 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 AI_LOGIC_MQH #define AI_LOGIC_MQH //--- Include required libraries #include <Trade/Trade.mqh> #include "AI JSON FILE.mqh" #include "AI Canvas State.mqh" #include "AI Canvas Render.mqh" //+------------------------------------------------------------------+ //| Forward Extern Declarations | //+------------------------------------------------------------------+ #ifndef AI_COMPILED_FROM_MAIN extern string OpenAI_Model; // OpenAI model identifier extern string OpenAI_Endpoint; // OpenAI API endpoint URL extern int MaxResponseLength; // Maximum response token length extern string LogFileName; // Log file name extern int MaxChartBars; // Maximum chart bars to send extern bool DeleteLogsOnChatDelete; // Delete logs when chat is deleted extern bool AutoTrade; // Enable auto-trade execution extern double LotSize; // Trade lot size extern string OpenAI_API_Key; // OpenAI API key #endif //+------------------------------------------------------------------+ //| Build standard bar-definition preamble for AI prompts | //+------------------------------------------------------------------+ string Ai_BuildBarPreamble() { //--- Return shared bar rules text return "BAR DEFINITIONS (read carefully before analyzing):\n" " - A bar is BULLISH if its close > open.\n" " - A bar is BEARISH if its close < open.\n" " - A bar is DOJI if close equals open.\n" "\n" "BAR ORDERING IN THE DATA BELOW (CRITICAL):\n" " - Bars are listed in DESCENDING TIME ORDER.\n" " - Bar 1 = MOST RECENT closed bar (highest timestamp, just finished).\n" " - Bar 2 = the bar that closed BEFORE Bar 1.\n" " - Bar 3 closed before Bar 2, and so on backwards in time.\n" " - Bar 1 is the RIGHTMOST candle on the chart.\n" " - The data block includes each bar's timestamp so you can verify ordering.\n" " - DO NOT reverse this. Bar 1 is NEWEST, Bar N is OLDEST.\n" "\n"; } //+------------------------------------------------------------------+ //| Format N bars in descending time order for AI prompt | //+------------------------------------------------------------------+ string Ai_FormatBarsDesc(ENUM_TIMEFRAMES tf, int count) { //--- Pull rates and order newest first MqlRates r[]; if(CopyRates(Symbol(), tf, 1, count, r) < count) return ""; ArraySetAsSeries(r, true); //--- Build header string out = "BAR DATA (Bar 1 = newest, Bar " + IntegerToString(count) + " = oldest):\n"; //--- Append each bar with direction tag for(int i = 0; i < count; i++) { string dir = (r[i].close > r[i].open) ? "BULLISH" : (r[i].close < r[i].open) ? "BEARISH" : "DOJI"; out += "Bar " + IntegerToString(i + 1) + " | Time=" + TimeToString(r[i].time, TIME_DATE | TIME_MINUTES) + " | O=" + DoubleToString(r[i].open, _Digits) + " | H=" + DoubleToString(r[i].high, _Digits) + " | L=" + DoubleToString(r[i].low, _Digits) + " | C=" + DoubleToString(r[i].close, _Digits) + " | (this bar is " + dir + ")\n"; } return out; } //+------------------------------------------------------------------+ //| Parse KEY: VALUE line from AI response | //+------------------------------------------------------------------+ string Ai_ParseKVResponse(string raw, string key) { //--- Split into lines and uppercase target key string lines[]; int n = StringSplit(raw, '\n', lines); string keyUpper = key; StringToUpper(keyUpper); //--- Search for matching key for(int i = 0; i < n; i++) { string line = lines[i]; StringTrimLeft(line); StringTrimRight(line); int colonPos = StringFind(line, ":"); if(colonPos <= 0) continue; string lineKey = StringSubstr(line, 0, colonPos); StringTrimLeft(lineKey); StringTrimRight(lineKey); StringToUpper(lineKey); if(lineKey != keyUpper) continue; string val = StringSubstr(line, colonPos + 1); StringTrimLeft(val); StringTrimRight(val); return val; } return ""; }
Определяется Ai_BuildBarPreamble, возвращающая один и тот же фиксированный текстовый блок в начале каждого сигнального промпта. В предыдущей статье промпты могли опираться на предположения об определениях бычьих и медвежьих баров и порядке их следования. Иногда ИИ менял порядок баров или считал доджи бычьим баром, что приводило к неверным сигналам. Эти неоднозначности устраняются явным заданием правил: если цена закрытия выше цены открытия, бар бычий; если ниже – медвежий; если значения равны, это доджи. Также дважды указывается, что бар 1 – последний закрытый бар, а бар N – самый старый; для визуальной проверки он расположен справа на графике. Каждое сигнальное действие добавляет этот вводный блок перед собственными правилами. Поэтому каждый диалог с ИИ начинается с общего набора терминов.
Определяется Ai_FormatBarsDesc для форматирования блока данных баров. С помощью CopyRates извлекаются count баров, начиная с бара 1 выбранного таймфрейма. Массив задается как серия, поэтому по индексу 0 находится самый новый бар; затем формируется строка заголовка с указанием диапазона. Для каждого бара формируется строка с разделителем "|": номер бара, временная метка, четыре значения OHLC, округленные до числа знаков символа, и вычисленный в том же проходе тег направления в скобках. Тег направления избыточен, поскольку направление можно определить по значениям OHLC. Однако его размещение в строке снижает вероятность того, что ИИ неверно подсчитает бычьи и медвежьи бары при составлении сводки. Несколько дополнительных токенов позволяют избежать ошибок, которые не удавалось выявить в предыдущей статье.
Определяется Ai_ParseKVResponse для извлечения отдельного значения из построчного ответа. Функция разбивает исходный ответ по переводам строк и переводит искомый ключ в верхний регистр для поиска без учета регистра. Затем она просматривает строки в поисках двоеточия, разделяет строку на ключ и значение по этому разделителю, удаляет пробелы с обеих сторон и возвращает значение при совпадении ключей. В предыдущей статье у ИИ запрашивался объект JSON, который разбирался JSON-десериализатором, встроенным в программу. Такой подход работал при корректном ответе, но давал сбой даже при небольшом отклонении: пропущенной запятой, лишней строке текста или точке в конце. Построчные ответы в формате "ключ:значение" устойчивее к отклонениям, чем JSON: случайный комментарий до или после строк с ключами не нарушает разбор. ИИ также надежнее соблюдает формат, поскольку каждая строка содержит отдельное значение. Каждое описанное ниже сигнальное действие вызывает этот парсер один раз для каждого ожидаемого поля, поэтому однострочного ответа с "SIGNAL: BUY" и "ENTRY: 1.10250" достаточно для выполнения остальной части действия. На этом реализация логики протокола диспетчеризации завершена. Далее подключим логику взаимодействия для обработки событий графика.
Взаимодействие и диспетчеризация действий
Переходим к модулю взаимодействия, который принимает события мыши и клавиатуры из MetaTrader и направляет их соответствующим обработчикам. В предыдущей статье обработчики событий привязывались к именам объектов графика. Здесь вместо этого используется код наведения курсора: каждому кликабельному элементу соответствует целое число, а обработка выполняется в одной функции. Рассмотрим переопределение обработки клавиатуры, диспетчер действий, порядок проверки попадания и быстрый путь отрисовки.
Переопределение обработки клавиатуры и центральный диспетчер действий
Определяются две функции переопределения обработки клавиатуры, отключающие сочетания клавиш графика, когда редактор находится в фокусе; таблица диспетчеризации, сопоставляющая семь идентификаторов действий с семью сигнальными функциями; и центральный маршрутизатор Ai_HandleAction, запускающий соответствующее действие по коду наведения курсора.
//+------------------------------------------------------------------+ //| AI Canvas 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 AI_CANVAS_INTERACT_MQH #define AI_CANVAS_INTERACT_MQH //--- Include required modules #include "AI Canvas State.mqh" #include "AI Canvas Render.mqh" #include "AI Canvas Editor.mqh" #include "AI Logic.mqh" //+------------------------------------------------------------------+ //| Global Variables | //+------------------------------------------------------------------+ bool g_ai_chatScrollDragging = false; // Chat-pane scrollbar drag flag bool g_ai_editorScrollDragging = false; // Editor scrollbar drag flag bool g_ai_searchScrollDragging = false; // Search popup scrollbar drag flag //--- Forward declarations void Ai_HideDashboard(); void Ai_RecomputeLayout(); //+------------------------------------------------------------------+ //| Keyboard Override State | //+------------------------------------------------------------------+ bool g_ai_kbOverrideActive = false; // Override active flag bool g_ai_savedKbControl = true; // Saved keyboard control state bool g_ai_savedQuickNav = true; // Saved quick navigation state //+------------------------------------------------------------------+ //| Begin keyboard override to capture chart input | //+------------------------------------------------------------------+ void Ai_BeginKeyboardOverride() { //--- Bail if already active if(g_ai_kbOverrideActive) return; //--- Save current chart settings g_ai_savedKbControl = (bool)ChartGetInteger(0, CHART_KEYBOARD_CONTROL); g_ai_savedQuickNav = (bool)ChartGetInteger(0, CHART_QUICK_NAVIGATION); //--- Disable chart keyboard handling ChartSetInteger(0, CHART_KEYBOARD_CONTROL, false); ChartSetInteger(0, CHART_QUICK_NAVIGATION, false); g_ai_kbOverrideActive = true; } //+------------------------------------------------------------------+ //| End keyboard override and restore chart input | //+------------------------------------------------------------------+ void Ai_EndKeyboardOverride() { //--- Bail if not active if(!g_ai_kbOverrideActive) return; //--- Restore saved settings ChartSetInteger(0, CHART_KEYBOARD_CONTROL, g_ai_savedKbControl); ChartSetInteger(0, CHART_QUICK_NAVIGATION, g_ai_savedQuickNav); g_ai_kbOverrideActive = false; } //+------------------------------------------------------------------+ //| Dispatch footer dropdown action by id | //+------------------------------------------------------------------+ void Ai_DispatchFooterAction(int actionId) { //--- Route to handler based on action id switch(actionId) { case 0: AiGetAndAppendChartData(); break; // Get Chart Data case 1: AiTwinBars(); break; // Twin Bars case 2: AiGetTradeSignal(false); break; // Quick Scalp case 3: AiDailySignal(); break; // Daily Signal case 4: AiTrendRead(); break; // Trend Read case 5: AiKeyLevel(); break; // Key Level case 6: AiClearSignalDrawings(); break; // Clear Drawings default: //--- Unknown action - log and ignore Print("Ai_DispatchFooterAction: unknown actionId=", actionId); break; } } //+------------------------------------------------------------------+ //| Handle action triggered by hover code | //+------------------------------------------------------------------+ void Ai_HandleAction(int hov) { //--- Close dashboard if(hov == AI_HOV_CLOSE) { Ai_HideDashboard(); return; } //--- Toggle theme if(hov == AI_HOV_THEME) { Ai_ApplyTheme(!g_ai_darkTheme); Ai_RenderAll(); ChartRedraw(); return; } //--- Toggle sidebar expansion if(hov == AI_HOV_TOGGLE) { g_ai_sidebarExpanded = !g_ai_sidebarExpanded; Ai_RecomputeLayout(); Ai_RenderAll(); ChartRedraw(); return; } //--- Create new chat if(hov == AI_HOV_NEW_CHAT) { g_ai_showSearch = false; g_ai_showSmallHistory = false; AiCreateNewChat(); return; } //--- Clear current chat if(hov == AI_HOV_CLEAR) { //--- Skip if already empty if(StringLen(g_ai_conversationHistory) == 0 && StringLen(g_ai_editor.GetText()) == 0) { Ai_RenderAll(); ChartRedraw(); return; } //--- Snapshot title for toast before clearing string titleSnap = g_ai_currentTitle; if(StringLen(titleSnap) > 30) titleSnap = StringSubstr(titleSnap, 0, 27) + "..."; //--- Clear history and prompt g_ai_conversationHistory = ""; g_ai_currentPrompt = ""; g_ai_editor.SetText(""); //--- Persist and notify const bool savedOk = AiUpdateCurrentHistory(); if(savedOk) Ai_ShowToast("Successfully cleared chat '" + titleSnap + "'", false); else Ai_ShowToast("Failed to clear chat", true); Ai_RenderAll(); ChartRedraw(); return; } //--- Toggle history popup if(hov == AI_HOV_HISTORY) { g_ai_showSmallHistory = !g_ai_showSmallHistory; g_ai_showSearch = false; Ai_RenderAll(); ChartRedraw(); return; } //--- Toggle search popup if(hov == AI_HOV_SEARCH) { g_ai_showSearch = !g_ai_showSearch; g_ai_showSmallHistory = false; if(g_ai_showSearch) { //--- Focus search editor and reset state if(g_ai_editor.focused) g_ai_editor.focused = false; g_ai_searchEditor.focused = true; g_ai_searchEditor.SetText(""); g_ai_searchQuery = ""; g_ai_searchScroll.scrollPx = 0; Ai_BeginKeyboardOverride(); } else { //--- Unfocus search editor and end override if no other focus g_ai_searchEditor.focused = false; if(!g_ai_editor.focused) Ai_EndKeyboardOverride(); } Ai_RenderAll(); ChartRedraw(); return; } //--- Signal button action half - fire selected dropdown action if(hov == AI_HOV_SIGNAL) { g_ai_showFooterDropdown = false; g_ai_showSearch = false; g_ai_showSmallHistory = false; const int ddi = MathMax(0, MathMin(g_ai_footerDropdownSelectedIdx, AI_FOOTER_DD_COUNT - 1)); Ai_DispatchFooterAction(AI_FOOTER_DD_ACTION_IDS[ddi]); Ai_RenderAll(); ChartRedraw(); return; } //--- Signal button chevron half - toggle dropdown if(hov == AI_HOV_SIGNAL_DD) { g_ai_showFooterDropdown = !g_ai_showFooterDropdown; g_ai_showSearch = false; g_ai_showSmallHistory = false; Ai_RenderAll(); ChartRedraw(); return; } //--- Footer dropdown item selection if(hov >= AI_HOV_FOOTER_DD_ITEM_BASE && hov < AI_HOV_FOOTER_DD_ITEM_BASE + 100) { int item = hov - AI_HOV_FOOTER_DD_ITEM_BASE; g_ai_footerDropdownSelectedIdx = item; g_ai_showFooterDropdown = false; if(item >= 0 && item < AI_FOOTER_DD_COUNT) { Ai_DispatchFooterAction(AI_FOOTER_DD_ACTION_IDS[item]); } Ai_RenderAll(); ChartRedraw(); return; } //--- Send button - submit prompt if(hov == AI_HOV_SEND) { //--- Suppress empty submissions if(Ai_PromptIsEmpty()) return; string txt = g_ai_editor.GetText(); g_ai_editor.SetText(""); g_ai_currentPrompt = ""; AiSubmitMessage(txt); return; } //--- Regenerate button - resubmit last prompt if(hov == AI_HOV_REGEN) { //--- Capture last prompt then strip the turn pair and resubmit string lastPrompt = AiGetLastUserPrompt(); if(StringLen(lastPrompt) > 0) { AiRemoveLastConversationTurn(); AiSubmitMessage(lastPrompt); } return; } //--- Export button - write chat to file if(hov == AI_HOV_EXPORT) { string fname = "ChatGPT_Export_Chat" + IntegerToString(g_ai_currentChatId) + ".txt"; int h = FileOpen(fname, FILE_WRITE | FILE_TXT | FILE_ANSI); if(h != INVALID_HANDLE) { FileWriteString(h, "Title: " + g_ai_currentTitle + "\r\n\r\n"); FileWriteString(h, g_ai_conversationHistory); FileClose(h); Print("Exported chat to ", fname); Ai_ShowToast("Chat exported to " + fname, false); } else { const int err = GetLastError(); Print("Export failed: ", err); Ai_ShowToast("Export failed (error " + IntegerToString(err) + ")", true); } Ai_RenderAll(); ChartRedraw(); return; } //--- Scroll-to-bottom FAB - jump chat scroll to max if(hov == AI_HOV_SCROLL_FAB) { g_ai_chatScroll.scrollPx = AiScrollMax(g_ai_chatScroll); AiScrollClamp(g_ai_chatScroll); Ai_RenderAll(); ChartRedraw(); return; } //--- User-message edit pencil - load prompt into editor if(hov >= AI_HOV_USER_EDIT_BASE && hov < AI_HOV_USER_EDIT_BASE + 100) { const int peIdx = hov - AI_HOV_USER_EDIT_BASE; if(peIdx >= 0 && peIdx < ArraySize(g_ai_userEditPrompt)) { //--- Validate click landed in narrow pencil rect not just bubble const bool inClickRect = (peIdx < ArraySize(g_ai_userEditClickL)) && (g_ai_mouseLx >= g_ai_userEditClickL[peIdx]) && (g_ai_mouseLx < g_ai_userEditClickR[peIdx]) && (g_ai_mouseLy >= g_ai_userEditClickT[peIdx]) && (g_ai_mouseLy < g_ai_userEditClickB[peIdx]); if(!inClickRect) return; //--- Load prompt into editor const string prompt = g_ai_userEditPrompt[peIdx]; g_ai_editor.SetText(prompt); //--- Focus editor and start keyboard override if(g_ai_searchEditor.focused) g_ai_searchEditor.focused = false; if(!g_ai_editor.focused) { g_ai_editor.focused = true; Ai_BeginKeyboardOverride(); } //--- Sync currentPrompt for chat-store consistency g_ai_currentPrompt = prompt; Ai_RenderAll(); ChartRedraw(); } return; } //--- Sidebar chat row click - switch active chat if(hov >= AI_HOV_SIDE_CHAT_BASE && hov < AI_HOV_SIDE_DEL_BASE) { int row = hov - AI_HOV_SIDE_CHAT_BASE; int total = ArraySize(g_ai_chats); int chatIdx = total - 1 - row; if(chatIdx >= 0 && chatIdx < total && g_ai_chats[chatIdx].id != g_ai_currentChatId) { AiUpdateCurrentHistory(); g_ai_currentChatId = g_ai_chats[chatIdx].id; g_ai_currentTitle = g_ai_chats[chatIdx].title; g_ai_conversationHistory = g_ai_chats[chatIdx].history; Ai_RenderAll(); ChartRedraw(); } return; } //--- Sidebar delete button - remove chat if(hov >= AI_HOV_SIDE_DEL_BASE && hov < AI_HOV_SIDE_DEL_BASE + 100) { int row = hov - AI_HOV_SIDE_DEL_BASE; int total = ArraySize(g_ai_chats); int chatIdx = total - 1 - row; if(chatIdx >= 0 && chatIdx < total) { //--- Snapshot title before delete for toast string titleSnap = g_ai_chats[chatIdx].title; if(StringLen(titleSnap) > 30) titleSnap = StringSubstr(titleSnap, 0, 27) + "..."; const bool deletedOk = AiDeleteChat(g_ai_chats[chatIdx].id); //--- Notify user via toast if(deletedOk) Ai_ShowToast("Successfully deleted chat '" + titleSnap + "'", false); else Ai_ShowToast("Failed to delete chat", true); Ai_RenderAll(); ChartRedraw(); } return; } //--- Popup chat row click - switch active chat if(hov >= AI_HOV_SMALL_CHAT_BASE && hov < AI_HOV_SMALL_DEL_BASE) { int row = hov - AI_HOV_SMALL_CHAT_BASE; if(row >= 0 && row < ArraySize(g_ai_popupRowChatIdx)) { int chatIdx = g_ai_popupRowChatIdx[row]; if(chatIdx >= 0 && chatIdx < ArraySize(g_ai_chats) && g_ai_chats[chatIdx].id != g_ai_currentChatId) { AiUpdateCurrentHistory(); g_ai_currentChatId = g_ai_chats[chatIdx].id; g_ai_currentTitle = g_ai_chats[chatIdx].title; g_ai_conversationHistory = g_ai_chats[chatIdx].history; } } g_ai_showSearch = false; g_ai_showSmallHistory = false; Ai_RenderAll(); ChartRedraw(); return; } //--- Popup delete button - remove chat from popup if(hov >= AI_HOV_SMALL_DEL_BASE && hov < AI_HOV_SMALL_DEL_BASE + 100) { int row = hov - AI_HOV_SMALL_DEL_BASE; if(row >= 0 && row < ArraySize(g_ai_popupRowChatIdx)) { int chatIdx = g_ai_popupRowChatIdx[row]; if(chatIdx >= 0 && chatIdx < ArraySize(g_ai_chats)) { //--- Snapshot title before delete for toast string titleSnap = g_ai_chats[chatIdx].title; if(StringLen(titleSnap) > 30) titleSnap = StringSubstr(titleSnap, 0, 27) + "..."; const bool deletedOk = AiDeleteChat(g_ai_chats[chatIdx].id); if(deletedOk) Ai_ShowToast("Successfully deleted chat '" + titleSnap + "'", false); else Ai_ShowToast("Failed to delete chat", true); } } Ai_RenderAll(); ChartRedraw(); return; } }
Определяются Ai_BeginKeyboardOverride и Ai_EndKeyboardOverride, устраняющие проблему, которой не было в предыдущей статье. При использовании объекта графика поле OBJ_EDIT находилось в фокусе MetaTrader и самостоятельно обрабатывало нажатия клавиш. Теперь редактор реализован нами, поэтому график по-прежнему прокручивается по стрелке влево и запускает одноклавишные индикаторы при вводе букв параллельно с набором текста. При включении сохраняются значения CHART_KEYBOARD_CONTROL и CHART_QUICK_NAVIGATION в двух флагах состояния, затем эти режимы отключаются. При выходе сохраненные значения восстанавливаются. Оба вызова идемпотентны, поэтому логике потери фокуса редактором не нужно отслеживать, включено ли переопределение.
Ai_DispatchFooterAction определяется как реализованная в коде таблица диспетчеризации. Семь идентификаторов действий из модуля состояния однозначно соответствуют семи сигнальным функциям: 0 – добавление данных графика; 1-5 – четыре сигнальных действия и Quick Scalp; 6 – очистка графических объектов. Все элементы, запускающие действие, – рабочая часть разделенной кнопки сигнала, клики по элементам раскрывающегося списка и будущие горячие клавиши – обращаются к этому переключателю с соответствующим ID. Для добавления восьмого действия достаточно добавить здесь новый вариант и соответствующие записи в таблицы меток и значков.
Ai_HandleAction определяется как центральный маршрутизатор, принимающий код наведения курсора и запускающий соответствующее действие. После каждого совпадения функция завершается досрочно, поэтому цепочка выглядит как набор независимых обработчиков. Простые случаи обрабатываются напрямую: закрытие скрывает панель управления, выбор темы изменяет флаг темного режима, переключатель изменяет состояние боковой панели, а создание нового чата вызывает функцию модуля логики. Действие Clear сохраняет заголовок для тост-уведомления перед очисткой истории чата и буфера промпта. Переключатель поиска также переводит редактор поиска в фокус и включает переопределение обработки клавиатуры; переключатель истории изменяет только флаг видимости.
Обе части разделенной кнопки сигнала обрабатываются через таблицу диспетчеризации. AI_HOV_SIGNAL считывает выбранный индекс раскрывающегося списка и вызывает Ai_DispatchFooterAction; AI_HOV_SIGNAL_DD изменяет видимость раскрывающегося списка. Диапазон элементов раскрывающегося списка в нижней части панели сохраняет новый выбор и запускает действие в том же вызове. Поэтому выбор элемента одновременно обновляет метку рабочей части кнопки и немедленно выполняет действие.
Остальные ветви обрабатывают следующие действия: отправка передает сообщение через AiSubmitMessage; повторная генерация получает последний промпт, удаляет последнюю пару реплик и отправляет запрос заново; экспорт записывает чат в текстовый файл и выводит тост-уведомление; плавающая кнопка прокрутки перемещает представление в конец чата; значок редактирования проверяет, попал ли клик в узкий прямоугольник значка, а затем загружает кэшированный промпт в редактор. Каждый из четырех диапазонов строк списка – чаты боковой панели, удаление чатов на боковой панели, чаты во всплывающем окне и удаление чатов во всплывающем окне – вычитает свой базовый код наведения курсора, чтобы получить индекс строки. Затем активный чат переключается либо удаляется. Каждый код наведения курсора, сформированный системой проверки попаданий, в итоге обрабатывается этой функцией, что делает систему диспетчеризации расширяемой. Чтобы связать все компоненты программы, создается файл советника, в который подключаются необходимые файлы, а соответствующие функции вызываются из соответствующих обработчиков событий, как показано ниже.
//+------------------------------------------------------------------+ //| AI ChatGPT EA Part 9.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 icon "1. Forex Algo-Trader.ico" //--- Embed bitmap resources used by the AI chat shell interface #resource "AI MQL5.bmp" #resource "AI LOGO.bmp" #resource "AI NEW CHAT.bmp" #resource "AI CLEAR.bmp" #resource "AI HISTORY.bmp" #resource "AI SEARCH.bmp" //+------------------------------------------------------------------+ //| Inputs | //+------------------------------------------------------------------+ input group "=== OPENAI API SETTINGS ===" input string OpenAI_Model = "gpt-4o"; // OpenAI Model Name input string OpenAI_Endpoint = "https://api.openai.com/v1/chat/completions"; // OpenAI API Endpoint URL input int MaxResponseLength = 3000; // Max Response Length (Tokens) input group "=== LOGGING SETTINGS ===" input string LogFileName = "ChatGPT_EA_Log.txt"; // Log File Name input int MaxChartBars = 10; // Max Chart Bars To Send To AI input bool DeleteLogsOnChatDelete = true; // Clear Logs When Deleting Chats input group "=== AUTO-TRADING SETTINGS ===" input bool AutoTrade = true; // Enable Auto-Trading On AI Signals input double LotSize = 0.01; // Lot Size For Auto-Trades input int MagicNumber = 12345; // Magic Number For Trades input bool EnableAutoSignal = false; // Enable Auto Signal Check On New Bar //+------------------------------------------------------------------+ //| Global Variables | //+------------------------------------------------------------------+ //--- Hold the OpenAI API key used for authenticating chat requests string OpenAI_API_Key = "sk-proj-NLeLNsEMxmmGkkFT7o9eajnyjY_tXPR3x6Cjbiop6749wO4V6xibqMyhdt06AM1gOdftxeW3BlbkF8ZsPUxb3tlphFiDk97OxpPu5dD7pIs1l_azyyDV-RtNPcoOiJponKqhQNxCBQ1IO_xcA"; //+------------------------------------------------------------------+ //| Module Includes | //+------------------------------------------------------------------+ //--- Define sentinel so headers skip their extern forward declarations //--- This prevents type conflicts between input-vs-extern of the same names //--- Standalone compilation of any .mqh leaves this undefined so externs emit #define AI_COMPILED_FROM_MAIN //--- Include theme constants and color definitions #include "AI Canvas Theme.mqh" //--- Include low-level canvas drawing primitives #include "AI Canvas Primitives.mqh" //--- Include JSON parsing and file utilities #include "AI JSON FILE.mqh" //--- Include shared canvas state structures and globals #include "AI Canvas State.mqh" //--- Include scrollbar component logic #include "AI Canvas Scrollbar.mqh" //--- Include text editor and caret handling #include "AI Canvas Editor.mqh" //--- Include rendering routines for the chat panel #include "AI Canvas Render.mqh" //--- Include trading and AI signal logic #include "AI Logic.mqh" //--- Include user interaction and input dispatch #include "AI Canvas Interact.mqh" //--- Include top-level shell lifecycle (init, deinit, tick, events) #include "AI Canvas Shell.mqh" //+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { //--- Delegate initialization to the shell module if(!Ai_Init()) return INIT_FAILED; //--- Start the millisecond timer for editor caret blink EventSetMillisecondTimer(500); //--- Report successful initialization return INIT_SUCCEEDED; } //+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { //--- Stop the timer event EventKillTimer(); //--- Delegate cleanup to the shell module Ai_Deinit(); } //+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick() { //--- Forward tick handling to the shell module Ai_OnTick(); } //+------------------------------------------------------------------+ //| ChartEvent function | //+------------------------------------------------------------------+ void OnChartEvent(const int id, const long &lparam, const double &dparam, const string &sparam) { //--- Forward chart event to the shell module for dispatch Ai_OnChartEvent(id, lparam, dparam, sparam); } //+------------------------------------------------------------------+ //| Timer function | //+------------------------------------------------------------------+ void OnTimer() { //--- Forward timer tick to the shell module Ai_OnTimer(); } //+------------------------------------------------------------------+
Начнем с шести строк #resource, встраивающих непосредственно в скомпилированную программу значок приложения, растровые изображения логотипов в заголовке и на боковой панели, а также четыре значка действий боковой панели. Используется тот же подход, что и в предыдущей статье, однако значки Twin Bars, Daily Signal, Trend Read и Key Level отрисовываются во время выполнения через модуль примитивов, а не загружаются с диска. Благодаря этому требуется поставлять меньше файлов ресурсов.
Директива #define AI_COMPILED_FROM_MAIN, расположенная перед подключениями, служит контрольным флагом. Каждый подключаемый файл проверяет его, чтобы определить, нужно ли создавать предварительные extern-объявления входных параметров. Когда подключаемые файлы компилируются отдельно для тестирования, контрольный флаг не определен и создаются extern-объявления. При подключении из основного файла флаг определен, поэтому эти объявления не создаются. Благодаря этому не возникает конфликтов типов между определенными здесь входными параметрами и extern-объявлениями в подключаемых файлах. Следующие десять подключений включают все модули в порядке зависимостей: внизу находятся тема и примитивы, затем JSON и состояние, далее полоса прокрутки и редактор, а выше – отрисовка, логика, взаимодействие и оболочка.
Пять обработчиков событий содержат однострочную переадресацию вызовов. OnInit вызывает Ai_Init и запускает таймер с периодом 500 миллисекунд, обеспечивающий мигание курсора и обновление полосы прогресса тост-уведомления.OnDeinit останавливает таймер и вызывает Ai_Deinit. OnTick передает управление Ai_OnTick для автоматической проверки сигналов при формировании нового бара.OnChartEvent передает события мыши и клавиатуры в Ai_OnChartEvent модуля взаимодействия, а OnTimer вызывает Ai_OnTimer для обновления мигания и тост-уведомления. Благодаря компактности входного файла пользователю достаточно открыть его, изменить входные параметры и API-ключ, а затем скомпилировать программу. Все остальные модули остаются без изменений. Остается протестировать программу; это рассматривается в следующем разделе.
Тестирование на исторических данных
Мы скомпилировали программу и подключили ее к графику. Ниже представлена итоговая визуализация в виде одного изображения формата Graphics Interchange Format (GIF).

Во время тестирования разделенная кнопка сигнала запускала правильное действие при каждом клике; семь сигнальных действий создавали структурированные ходы диалога с разобранными результатами и строками результатов сделок; механизм автоматической торговли размещал рыночные и отложенные ордера в соответствии с направлением каждого сигнала; графические объекты появлялись с привязкой к нужным барам и с читаемыми метками, а действие Clear Drawings удаляло их за один проход.
Заключение
Неструктурированный интерфейс чата заменен архитектурой на основе диспетчеризации, которая превращает клики в проверяемые торговые артефакты. Теперь программа направляет семь различных торговых действий – Get Chart Data, Twin Bars, Quick Scalp, Daily Signal, Trend Read, Key Level и Clear Drawings – через единый диспетчер, использующий стабильные целочисленные ID. После общего вводного блока с определениями баров ИИ должен использовать ограниченный построчный ответ формата "ключ:значение". Благодаря этому результаты надежно разбираются даже в том случае, если модель добавляет комментарии. Единый механизм размещения ордеров выбирает рыночный или отложенный ордер по матрице действий, проверяет допустимые брокером уровни стопов и масштабирует SL/TP с учетом волатильности последних баров. Каждый сигнал отрисовывается как графический объект с меткой, привязанный к бару, и может быть удален одной командой. История чата и сигнальные ходы диалога сохраняются, что упрощает анализ и тестирование на исторических данных.
Эти результаты можно проверить: проект компилируется, запускает каждое действие через диспетчер, размещает ордера в соответствии с разобранными сигналами, выводит аннотации с привязкой к барам и поддерживает тестирование на исторических данных. С точки зрения архитектуры добавить восьмое действие несложно: достаточно зарегистрировать его метку, значок и ID действия, а затем добавить один вариант в AiDispatchFooterAction. Перерабатывать интерфейс или подключение обработчиков событий не потребуется. В результате создан воспроизводимый и проверяемый пайплайн от ИИ к торговле, подходящий для экспериментов в условиях, приближенных к рабочим, и последовательного расширения функционала. После прочтения этой статьи вы сможете:
- разработать систему действий на основе диспетчеризации, в которой стабильные целочисленные ID маршрутизируются через центральный обработчик. Добавление нового действия при этом сводится к регистрации одной записи, а не требует подключения новых обработчиков событий;
- создавать протоколы взаимодействия с ИИ, использующие построчный ответ формата "ключ:значение" и устойчивые к отклонениям в сопроводительном тексте и неполным ответам, при которых строгий парсингJSON завершился бы ошибкой;
- размещать рыночные и отложенные ордера из единой функции с проверкой допустимых брокером уровней стопов, буферами SL и TP, масштабируемыми по волатильности, и выбором одного из четырех типов отложенных ордеров по матрице типа уровня и направления сделки.
Приложения
| № | Имя | Тип | Описание |
|---|---|---|---|
| 1 | AI Canvas Theme.mqh | Включаемый файл | Определяет константы компоновки, размеры шрифтов, коды глифов, а также темную и светлую палитры цветов, применяемые через единый переключатель темы. |
| 2 | AI JSON FILE.mqh | Включаемый файл | Содержит JSON-парсер и сериализатор, используемые слоем сохранения зашифрованных чатов между сеансами. |
| 3 | AI Canvas Primitives.mqh | Включаемый файл | Содержит низкоуровневые примитивы отрисовки: заливку и рамки скругленных прямоугольников, сглаженную отрисовку текста, масштабирование изображений, пользовательские семантические значки и встроенный парсер Markdown. |
| 4 | AI Canvas State.mqh | Включаемый файл | Хранит общее состояние программы: массив чатов, данные текущего чата, позицию и флаги видимости панели управления, а также таблицы диспетчеризации, сопоставляющие семь ID действий с их метками и значками. |
| 5 | AI Canvas Scrollbar.mqh | Включаемый файл | Определяет структуру состояния полосы прокрутки и вспомогательные функции, вычисляющие геометрию ползунка, обрабатывающие его перетаскивание и прокрутку колесом мыши, а также отрисовывающие дорожку. |
| 6 | AI Canvas Editor.mqh | Включаемый файл | Определяет класс многострочного текстового редактора с миганием каретки, выделением, переносом слов, установкой позиции по клику и редактированием при каждом нажатии клавиши. |
| 7 | AI Canvas Render.mqh | Включаемый файл | Отрисовывает всю панель управления: заголовок, боковую панель, область чата с облаками сообщений в разметке Markdown, область промпта, нижнюю часть панели, всплывающие окна и тост-уведомление. |
| 8 | AI Logic.mqh | Включаемый файл | Реализует протокол диспетчеризации ИИ, семь сигнальных действий, единую функцию размещения ордеров, вспомогательную функцию добавления ходов диалога и сохранение зашифрованных чатов. |
| 9 | AI Canvas Interact.mqh | Включаемый файл | Направляет события мыши и клавиатуры из MetaTrader к нужному действию с помощью диспетчеризации по коду наведения курсора, двух функций переопределения обработки клавиатуры и центрального обработчика действий. |
| 10 | AI Canvas Shell.mqh | Включаемый файл | Отвечает за жизненный цикл программы: создание и удаление канваса, пересчет компоновки, обработку тика нового бара для автоматической проверки сигналов, а также функции отображения и скрытия панели. |
| 11 | AI EA PART 9.mq5 | Советник | Основная точка входа программы: встраивает растровые ресурсы, объявляет пользовательские параметры, подключает десять модульных файлов и передает каждое событие в соответствующую функцию оболочки. |
| 12 | AI EA BMP FILES.zip | Архив с ресурсами | Содержит растровые файлы логотипа заголовка, логотипа боковой панели и четырех значков действий боковой панели, которые программа встраивает при компиляции. |
Проект поддерживается в Algo Forge: https://forge.mql5.io/29210372/Article-22495-Dispatch-Driven-AI-Trading-Signals
Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/22495
Предупреждение: все права на данные материалы принадлежат MetaQuotes Ltd. Полная или частичная перепечатка запрещена.
Данная статья написана пользователем сайта и отражает его личную точку зрения. Компания MetaQuotes Ltd не несет ответственности за достоверность представленной информации, а также за возможные последствия использования описанных решений, стратегий или рекомендаций.
Изучение стандартной библиотеки MQL5 (Часть 7): Создание интерактивных меток позиций с помощью класса CCanvas
Возможности Мастера MQL5, которые вам нужно знать (Часть 87): Управление капиталом с масштабированием по волатильности на основе монотонной очереди в MQL5
Создание торговых систем с искусственным интеллектом на MQL5 (Часть 8): Доработка интерфейса с анимацией, метриками времени и инструментами управления ответами
- Бесплатные приложения для трейдинга
- 8 000+ сигналов для копирования
- Экономические новости для анализа финансовых рынков
Вы принимаете политику сайта и условия использования