Создание торговых систем с искусственным интеллектом на MQL5 (Часть 8): Доработка интерфейса с анимацией, метриками времени и инструментами управления ответами
Введение
В предыдущей статье (часть 7) мы продолжили модульную реорганизацию торговой системы с ИИ на языке MetaQuotes Language 5 (MQL5), улучшили структуру кода для упрощения сопровождения и добавили автоматизацию торговли по сигналам, сгенерированным ИИ, с настраиваемым размером лота и магическими числами. В части 8 доработаем пользовательский интерфейс (UI), добавив анимацию, метрики времени и инструменты управления ответами. Этот подход улучшает взаимодействие с пользователем: во время API-запросов отображается анимация загрузки, в ответах указывается время их формирования, а кнопки повторной генерации и экспорта позволяют управлять результатами ИИ. Мы рассмотрим следующие темы:
- Знакомство с расширенным функционалом пользовательского интерфейса
- Реализация на MQL5
- Тестирование на исторических данных
- Заключение
В итоге у вас будет рабочая программа на языке MQL5 с доработанным интерфейсом для торговых сценариев на основе ИИ, готовая к дальнейшей настройке – приступим.
Знакомство с расширенным функционалом пользовательского интерфейса
Расширенный функционал пользовательского интерфейса улучшает взаимодействие с торговой системой на основе ИИ. Анимация загрузки обеспечивает визуальную обратную связь во время API-запросов на этапах подготовки и ожидания ответа, а метрики времени в секундах показывают, насколько быстро формируется ответ. Добавляются инструменты управления ответами: кнопки повторной генерации повторно отправляют последний промпт, чтобы получить новый ответ ИИ, а кнопки экспорта сохраняют ответы в текстовые файлы для удобного просмотра или отправки.
Эти возможности реализуются модульно путем расширения существующих компонентов UI. Добавляются анимационные циклы с последовательной сменой точек, расчет времени ответа по счетчику тиков и обработчики нажатий кнопок для повторной генерации или экспорта в файл. Мы расширим управление состояниями боковой панели, добавив динамическое изменение размера и перемещение объектов. В зависимости от действий пользователя предусмотрено условное отображение элементов интерфейса. Это обеспечит корректное обновление областей вывода и позиций прокрутки без нарушения основной логики ИИ. Ниже схематично показаны поставленные цели.

Реализация на MQL5
Для реализации обновлений сначала определим константы для новых объектов. Начнем с файла компонентов UI, где размещены нужные инструменты. Ниже приведена необходимая логика.
string REGEN_ICON_FONT = "Webdings"; string EXPORT_ICON_FONT = "Wingdings 3"; #define REGEN_ICON CharToString('q') // Circular arrow (spin/regenerate) #define EXPORT_ICON CharToString('7') // Proxy for save/export #define ICON_SIZE 16 #define ICON_SPACING 5 color REGEN_COLOR = clrGreen; color EXPORT_COLOR = clrBlack;
В глобальной области видимости файла UI задаются REGEN_ICON_FONT со шрифтом Webdings и EXPORT_ICON_FONT со шрифтом Wingdings 3. Эти константы определяют семейства шрифтов для отображения специальных символов как значков. Директивы препроцессора задают для REGEN_ICON символ 'q', преобразованный через CharToString в строку и отображаемый шрифтом как круговая стрелка повторной генерации, а для EXPORT_ICON – символ '7', используемый как значок сохранения или экспорта. При желании можно выбрать любые другие символы из таблицы ниже и заменить их соответствующим образом.

Мы отметили символы, которые будем использовать. Затем ICON_SIZE получает значение 16 для единого размера значков, а ICON_SPACING – значение 5 для промежутков между ними. Для значка повторной генерации REGEN_COLOR задается зеленым, а для значка экспорта EXPORT_COLOR – черным. При необходимости их можно настроить в соответствии с желаемым визуальным оформлением. На следующем шаге добавляется учет объектов при расчете высоты строки.
void ComputeLinesAndHeight(const string &font, const int fontSize, const int timestampFontSize, const int adjustedLineHeight, const int adjustedTimestampHeight, const int messageMargin, const int maxTextWidth, const string &msgRoles[], const string &msgContents[], const string &msgTimestamps[], const int numMessages, int &totalHeight_out, int &totalLines_out, string &allLines_out[], string &lineRoles_out[], int &lineHeights_out[]) { ArrayResize(allLines_out, 0); ArrayResize(lineRoles_out, 0); ArrayResize(lineHeights_out, 0); totalLines_out = 0; totalHeight_out = 0; for (int m = 0; m < numMessages; m++) { string wrappedLines[]; WrapText(msgContents[m], font, fontSize, maxTextWidth, wrappedLines); int numLines = ArraySize(wrappedLines); int currSize = ArraySize(allLines_out); ArrayResize(allLines_out, currSize + numLines + 1); ArrayResize(lineRoles_out, currSize + numLines + 1); ArrayResize(lineHeights_out, currSize + numLines + 1); for (int l = 0; l < numLines; l++) { allLines_out[currSize + l] = wrappedLines[l]; lineRoles_out[currSize + l] = msgRoles[m]; lineHeights_out[currSize + l] = adjustedLineHeight; totalHeight_out += adjustedLineHeight; } allLines_out[currSize + numLines] = msgTimestamps[m]; lineRoles_out[currSize + numLines] = msgRoles[m] + "_timestamp"; lineHeights_out[currSize + numLines] = adjustedTimestampHeight; totalHeight_out += adjustedTimestampHeight; totalLines_out += numLines + 1; if (m < numMessages - 1) { totalHeight_out += messageMargin; } else if (m == numMessages - 1 && numMessages > 0) { if (totalHeight_out > 0) totalHeight_out -= messageMargin; // Adjust if last } } // Add buffer below loading messages (Preparing/Thinking) to ensure space for timestamp if (numMessages > 0 && StringFind(msgRoles[numMessages - 1], "AI") >= 0 && (StringFind(msgContents[numMessages - 1], "Preparing the Request") >= 0 || StringFind(msgContents[numMessages - 1], "Thinking...") >= 0)) { totalHeight_out += 30; // Extra space below thinking timestamp during wait } // Add padding if last message is AI and contains time note if (numMessages > 0 && StringFind(msgRoles[numMessages - 1], "AI") >= 0 && StringFind(msgContents[numMessages - 1], "(Response in ") >= 0) { totalHeight_out += 30; // Dedicated space for time note line + icons } }
В функции ComputeLinesAndHeight к каждому сообщению добавляется временная метка как отдельная строка. Роль этой строки получает суффикс "_timestamp", а ее высота рассчитывается отдельно; общая высота и число строк увеличиваются. Между сообщениями добавляется отступ, но для последнего сообщения он вычитается, чтобы в конце не оставалось лишнего пространства. Если последнее сообщение принадлежит ИИ и содержит "Preparing the Request" или "Thinking...", добавляется резерв по высоте 30, чтобы во время загрузки снизу оставалось место. Еще 30 добавляется, если сообщение содержит "(Response in ", чтобы оставить отступ под отметкой времени и значками. Для наглядности мы выделили те места, где были внесены изменения. Теперь обновим функцию отображения ответа, добавив в нее новые вспомогательные значки.
void UpdateResponseDisplay() { if (showing_small_history_popup || showing_big_history_popup || showing_search_popup) return; int total = ObjectsTotal(0, 0, -1); for (int j = total - 1; j >= 0; j--) { string name = ObjectName(0, j, 0, -1); if (StringFind(name, "ChatGPT_ResponseLine_") == 0 || StringFind(name, "ChatGPT_MessageBg_") == 0 || StringFind(name, "ChatGPT_MessageText_") == 0 || StringFind(name, "ChatGPT_Timestamp_") == 0 || StringFind(name, "ChatGPT_RegenIcon") == 0 || StringFind(name, "ChatGPT_ExportIcon") == 0) { ObjectDelete(0, name); } } string displayText = conversationHistory; int textX = g_mainContentX + g_sidePadding + g_textPadding; int textY = g_mainY + g_headerHeight + g_padding + g_textPadding; int fullMaxWidth = g_mainWidth - 2 * g_sidePadding - 2 * g_textPadding; if (displayText == "") { string objName = "ChatGPT_ResponseLine_0"; createLabel(objName, textX, textY, "Type your prompt here and click Send to chat with the AI.", clrGray, 10, "Arial", CORNER_LEFT_UPPER, ANCHOR_LEFT_UPPER); g_total_height = 0; g_visible_height = g_displayHeight - 2 * g_textPadding; if (scroll_visible) { DeleteScrollbar(); scroll_visible = false; } ChartRedraw(); return; } string parts[]; int numParts = StringSplit(displayText, '\n', parts); string msgRoles[]; string msgContents[]; string msgTimestamps[]; string currentRole = ""; string currentContent = ""; string currentTimestamp = ""; for (int p = 0; p < numParts; p++) { string line = parts[p]; string trimmed = line; StringTrimLeft(trimmed); StringTrimRight(trimmed); if (StringLen(trimmed) == 0) { if (currentRole != "") currentContent += "\n"; continue; } if (StringFind(trimmed, "You: ") == 0) { if (currentRole != "") { int size = ArraySize(msgRoles); ArrayResize(msgRoles, size + 1); ArrayResize(msgContents, size + 1); ArrayResize(msgTimestamps, size + 1); msgRoles[size] = currentRole; msgContents[size] = currentContent; msgTimestamps[size] = currentTimestamp; } currentRole = "User"; currentContent = StringSubstr(line, StringFind(line, "You: ") + 5); currentTimestamp = ""; continue; } else if (StringFind(trimmed, "AI: ") == 0) { if (currentRole != "") { int size = ArraySize(msgRoles); ArrayResize(msgRoles, size + 1); ArrayResize(msgContents, size + 1); ArrayResize(msgTimestamps, size + 1); msgRoles[size] = currentRole; msgContents[size] = currentContent; msgTimestamps[size] = currentTimestamp; } currentRole = "AI"; currentContent = StringSubstr(line, StringFind(line, "AI: ") + 4); currentTimestamp = ""; continue; } else if (IsTimestamp(trimmed)) { currentTimestamp = trimmed; int size = ArraySize(msgRoles); ArrayResize(msgRoles, size + 1); ArrayResize(msgContents, size + 1); ArrayResize(msgTimestamps, size + 1); msgRoles[size] = currentRole; msgContents[size] = currentContent; msgTimestamps[size] = currentTimestamp; currentRole = ""; currentContent = ""; currentTimestamp = ""; } else { if (currentRole != "") { currentContent += "\n" + line; } } } if (currentRole != "") { int size = ArraySize(msgRoles); ArrayResize(msgRoles, size + 1); ArrayResize(msgContents, size + 1); ArrayResize(msgTimestamps, size + 1); msgRoles[size] = currentRole; msgContents[size] = currentContent; msgTimestamps[size] = currentTimestamp; } int numMessages = ArraySize(msgRoles); if (numMessages == 0) { string objName = "ChatGPT_ResponseLine_0"; createLabel(objName, textX, textY, "Type your prompt here and click Send to chat with the AI.", clrGray, 10, "Arial", CORNER_LEFT_UPPER, ANCHOR_LEFT_UPPER); g_total_height = 0; g_visible_height = g_displayHeight - 2 * g_textPadding; if (scroll_visible) { DeleteScrollbar(); scroll_visible = false; } ChartRedraw(); return; } string font = "Arial"; int fontSize = 10; int timestampFontSize = 8; int lineHeight = TextGetHeight("A", font, fontSize); int timestampHeight = TextGetHeight("A", font, timestampFontSize); int adjustedLineHeight = lineHeight + g_lineSpacing; int adjustedTimestampHeight = timestampHeight + g_lineSpacing; int messageMargin = 25; // Increased for extra space int visibleHeight = g_displayHeight - 2 * g_textPadding; g_visible_height = visibleHeight; string tentativeAllLines[]; string tentativeLineRoles[]; int tentativeLineHeights[]; int tentativeTotalHeight, tentativeTotalLines; ComputeLinesAndHeight(font, fontSize, timestampFontSize, adjustedLineHeight, adjustedTimestampHeight, messageMargin, fullMaxWidth, msgRoles, msgContents, msgTimestamps, numMessages, tentativeTotalHeight, tentativeTotalLines, tentativeAllLines, tentativeLineRoles, tentativeLineHeights); bool need_scroll = tentativeTotalHeight > visibleHeight; bool should_show_scrollbar = false; int reserved_width = 0; if (ScrollbarMode != SCROLL_WHEEL_ONLY) { should_show_scrollbar = need_scroll && (ScrollbarMode == SCROLL_DYNAMIC_ALWAYS || (ScrollbarMode == SCROLL_DYNAMIC_HOVER && mouse_in_display)); if (should_show_scrollbar) { reserved_width = 16; } } string allLines[]; string lineRoles[]; int lineHeights[]; int totalHeight, totalLines; if (reserved_width > 0) { ComputeLinesAndHeight(font, fontSize, timestampFontSize, adjustedLineHeight, adjustedTimestampHeight, messageMargin, fullMaxWidth - reserved_width, msgRoles, msgContents, msgTimestamps, numMessages, totalHeight, totalLines, allLines, lineRoles, lineHeights); } else { totalHeight = tentativeTotalHeight; totalLines = tentativeTotalLines; ArrayCopy(allLines, tentativeAllLines); ArrayCopy(lineRoles, tentativeLineRoles); ArrayCopy(lineHeights, tentativeLineHeights); } g_total_height = totalHeight; bool prev_scroll_visible = scroll_visible; scroll_visible = should_show_scrollbar; if (scroll_visible != prev_scroll_visible) { if (scroll_visible) { CreateScrollbar(); } else { DeleteScrollbar(); } } int max_scroll = MathMax(0, totalHeight - visibleHeight); if (scroll_pos > max_scroll) scroll_pos = max_scroll; if (scroll_pos < 0) scroll_pos = 0; if (totalHeight > visibleHeight && scroll_pos == prev_scroll_pos && prev_scroll_pos == -1) { scroll_pos = max_scroll; } if (scroll_visible) { slider_height = CalculateSliderHeight(); ObjectSetInteger(0, SCROLL_SLIDER, OBJPROP_YSIZE, slider_height); UpdateSliderPosition(); UpdateButtonColors(); } int currentY = textY - scroll_pos; int endY = textY + visibleHeight; int startLineIndex = 0; int currentHeight = 0; for (int line = 0; line < totalLines; line++) { if (currentHeight >= scroll_pos) { startLineIndex = line; currentY = textY + (currentHeight - scroll_pos); break; } currentHeight += lineHeights[line]; if (line < totalLines - 1 && StringFind(lineRoles[line], "_timestamp") >= 0 && StringFind(lineRoles[line + 1], "_timestamp") < 0) { currentHeight += messageMargin; } } int numVisibleLines = 0; int visibleHeightUsed = 0; for (int line = startLineIndex; line < totalLines; line++) { int lineHeight = lineHeights[line]; if (visibleHeightUsed + lineHeight > visibleHeight) break; visibleHeightUsed += lineHeight; numVisibleLines++; if (line < totalLines - 1 && StringFind(lineRoles[line], "_timestamp") >= 0 && StringFind(lineRoles[line + 1], "_timestamp") < 0) { if (visibleHeightUsed + messageMargin > visibleHeight) break; visibleHeightUsed += messageMargin; } } int leftX = g_mainContentX + g_sidePadding + g_textPadding; int rightX = g_mainContentX + g_mainWidth - g_sidePadding - g_textPadding - reserved_width; color userColor = clrGray; color aiColor = clrBlue; color timestampColor = clrDarkGray; for (int li = 0; li < numVisibleLines; li++) { int lineIndex = startLineIndex + li; if (lineIndex >= totalLines) break; string line = allLines[lineIndex]; string role = lineRoles[lineIndex]; bool isTimestamp = StringFind(role, "_timestamp") >= 0; int currFontSize = isTimestamp ? timestampFontSize : fontSize; color textCol = isTimestamp ? timestampColor : (StringFind(role, "User") >= 0 ? userColor : aiColor); string currFont = font; if (StringFind(line, "Preparing the Request") >= 0) { textCol = clrDodgerBlue; currFont = "Arial Bold"; } if (StringFind(line, "Thinking...") >= 0) { textCol = clrRed; currFont = "Arial Bold"; } if (StringFind(line, "(Response in ") == 0) { textCol = clrGray; } string display_line = line; if (line == " ") { display_line = " "; textCol = clrWhite; } int textX_pos = (StringFind(role, "User") >= 0) ? rightX : leftX; ENUM_ANCHOR_POINT textAnchor = (StringFind(role, "User") >= 0) ? ANCHOR_RIGHT_UPPER : ANCHOR_LEFT_UPPER; string lineName = "ChatGPT_MessageText_" + IntegerToString(lineIndex); if (currentY >= textY && currentY < endY) { createLabel(lineName, textX_pos, currentY, display_line, textCol, currFontSize, currFont, CORNER_LEFT_UPPER, textAnchor); } // Add icons if this is the time note line and it's the last AI's second-last line if (StringFind(line, "(Response in ") == 0 && StringFind(role, "AI") >= 0 && lineIndex == totalLines - 2) { // Calculate time note width for positioning TextSetFont(currFont, currFontSize); uint tw, th; TextGetSize(line, tw, th); int iconX = leftX + (int)tw + 50; // X offset for space int iconY = currentY - 3; // To raise up on Y axis // Regenerate icon string regenName = "ChatGPT_RegenIcon"; createLabel(regenName, iconX, iconY, REGEN_ICON, REGEN_COLOR, ICON_SIZE, REGEN_ICON_FONT, CORNER_LEFT_UPPER, ANCHOR_LEFT_UPPER); ObjectSetInteger(0, regenName, OBJPROP_SELECTABLE, true); ObjectSetInteger(0, regenName, OBJPROP_ZORDER, 10); // Export icon iconX += ICON_SIZE + ICON_SPACING; string exportName = "ChatGPT_ExportIcon"; createLabel(exportName, iconX, iconY, EXPORT_ICON, EXPORT_COLOR, ICON_SIZE, EXPORT_ICON_FONT, CORNER_LEFT_UPPER, ANCHOR_LEFT_UPPER); ObjectSetInteger(0, exportName, OBJPROP_SELECTABLE, true); ObjectSetInteger(0, exportName, OBJPROP_ZORDER, 10); } currentY += lineHeights[lineIndex]; if (lineIndex < totalLines - 1 && StringFind(lineRoles[lineIndex], "_timestamp") >= 0 && StringFind(lineRoles[lineIndex + 1], "_timestamp") < 0) { currentY += messageMargin; } } ChartRedraw(); }
Сначала проверяется, открыто ли одно из всплывающих окон – малое или большое окно истории либо окно поиска. Если это так, функция завершает работу досрочно, чтобы, как и прежде, не обновлять основную область ответа. Затем перебираются все объекты графика и функцией ObjectDelete удаляются объекты, относящиеся к предыдущим строкам ответа, фонам сообщений, тексту, временным меткам, а также новым значкам повторной генерации и экспорта. Это очищает область вывода. Остальная часть кода осталась идентичной прежнему варианту. Измененные участки выделены и снабжены комментариями для наглядности. Далее разберем участок кода, в котором существенно переработана логика добавленных значков.
Сначала рассчитывается numVisibleLines: суммируется высота видимых строк с учетом отступов после временных меток, при этом значение не должно превышать visibleHeight. Затем задаются левая и правая позиции по оси x, а также цвета для сообщений пользователя, ИИ и временных меток. Затем перебираются видимые строки: для каждой получаются текст и роль, проверяется наличие временной метки, задаются размер, цвет и шрифт. Если строка попадает в видимую область, она выводится на экран. В зависимости от роли задаются позиция и точка привязки. Если строка попадает в видимую область по оси y, для нее создается метка через createLabel. Для отметки времени в последнем сообщении ИИ (lineIndex = totalLines - 2) ширина измеряется через TextGetSize, после чего рассчитываются iconX и iconY. Для значка повторной генерации создается метка ChatGPT_RegenIcon с текстом REGEN_ICON, заданными цветом, размером и шрифтом. Аналогично с нужным интервалом создается значок экспорта ChatGPT_ExportIcon; для обоих включается возможность выделения и задается порядок отображения (Z-order). Значение currentY увеличивается на высоту строки. После временных меток добавляется отступ, кроме последней строки. В конце вызывается ChartRedraw для обновления области вывода.
Теперь файл компонентов UI полностью обновлен. Остается вызвать соответствующие функции в основном файле, чтобы применить изменения. В начале основного файла в глобальной области задаются константы анимации для удобства управления ими.
// Loading indicator constants string PrepBase = "AI: Preparing the Request"; // Preparing Text string LoadingPlaceholder = "AI: Thinking..."; // Thinking text string SpinnerDots[] = {"", ".", "..", "..."}; // Cycling dots for animation int PreAnimationCycles = 6; // Number of cycles (~1s total) ulong StartTimeMs = 0; // For timing API call
Здесь строке PrepBase присваивается значение "AI: Preparing the Request" – это базовый текст первого сообщения о загрузке на этапе подготовки API-запроса. При необходимости этот текст начальной подготовки можно изменить. Переменной LoadingPlaceholder присваивается значение "AI: Thinking..." – текст, отображаемый во время ожидания ответа ИИ; его также можно изменить. Затем создается строковый массив SpinnerDots: пустая строка, одна точка, две точки и три точки. Его элементы циклически добавляются к сообщениям о загрузке для анимационного эффекта. Переменной PreAnimationCycles присваивается значение 6, задающее число циклов анимации. С учетом интервалов Sleep это соответствует общей длительности примерно в одну секунду.
Переменная StartTimeMs типа unsigned long инициализируется значением 0. В ней сохраняется начальное значение счетчика тиков для измерения времени ответа API. Затем при отправке сообщения эти переменные используются для имитации состояния загрузки. Ниже приведена обновленная функция.
void SubmitMessage(string prompt) { if (StringLen(prompt) == 0) return; string timestamp = TimeToString(TimeCurrent(), TIME_MINUTES); string response = ""; bool send_to_api = true; if (StringFind(prompt, "set title ") == 0) { string new_title = StringSubstr(prompt, 10); current_title = new_title; response = "Title set to " + new_title; send_to_api = false; UpdateCurrentHistory(); UpdateSidebarDynamic(); } // Save old height before adding prompt UpdateResponseDisplay(); int old_total = g_total_height; conversationHistory += "You: " + prompt + "\n" + timestamp + "\n"; // Get height after prompt UpdateResponseDisplay(); int after_prompt = g_total_height; int prompt_height = after_prompt - old_total; if (send_to_api) { conversationHistory += PrepBase + "\n" + timestamp + "\n\n"; // Get height after loading UpdateResponseDisplay(); int after_loading = g_total_height; int loading_height = after_loading - after_prompt; int new_content_height = prompt_height + loading_height; // Dynamic scroll: if fits, prompt at top (higher); else, loading at bottom if (new_content_height <= g_visible_height) { scroll_pos = MathMax(0, old_total); } else { scroll_pos = MathMax(0, after_loading - g_visible_height); } if (scroll_visible) { UpdateSliderPosition(); UpdateButtonColors(); } ChartRedraw(); for (int i = 0; i < PreAnimationCycles; i++) { // Sub-cycle for strict increasing: reset dots every 3 steps int subCycle = i % 3; string dots = ""; for (int d = 0; d <= subCycle; d++) { dots += "."; } int prepPos = StringFind(conversationHistory, PrepBase, 0); if (prepPos >= 0) { int endPos = StringFind(conversationHistory, "\n\n", prepPos) + 2; if (endPos < 2) endPos = StringLen(conversationHistory); string before = StringSubstr(conversationHistory, 0, prepPos); string after = StringSubstr(conversationHistory, endPos); conversationHistory = before + PrepBase + dots + "\n" + timestamp + "\n\n" + after; } UpdateResponseDisplay(); // Re-apply dynamic scroll after animation update (height same as loading) scroll_pos = (new_content_height <= g_visible_height) ? MathMax(0, old_total) : MathMax(0, g_total_height - g_visible_height); if (scroll_visible) { UpdateSliderPosition(); UpdateButtonColors(); } ChartRedraw(); Sleep(200); } int prepPos = StringFind(conversationHistory, PrepBase, 0); if (prepPos >= 0) { int endPos = StringFind(conversationHistory, "\n\n", prepPos) + 2; if (endPos < 2) endPos = StringLen(conversationHistory); string before = StringSubstr(conversationHistory, 0, prepPos); string after = StringSubstr(conversationHistory, endPos); conversationHistory = before + LoadingPlaceholder + "\n" + timestamp + "\n\n" + after; } else { conversationHistory += LoadingPlaceholder + "\n" + timestamp + "\n\n"; } UpdateResponseDisplay(); // Re-apply dynamic scroll after placeholder scroll_pos = (new_content_height <= g_visible_height) ? MathMax(0, old_total) : MathMax(0, g_total_height - g_visible_height); if (scroll_visible) { UpdateSliderPosition(); UpdateButtonColors(); } ChartRedraw(); StartTimeMs = GetTickCount(); Print("Chat ID: " + IntegerToString(current_chat_id) + ", Title: " + current_title); FileWrite(logFileHandle, "Chat ID: " + IntegerToString(current_chat_id) + ", Title: " + current_title); Print("User: " + prompt); FileWrite(logFileHandle, "User: " + prompt); response = GetChatGPTResponse(prompt); Print("AI: " + response); FileWrite(logFileHandle, "AI: " + response); ulong elapsedMs = GetTickCount() - StartTimeMs; int elapsedSec = (int)(elapsedMs / 1000); string timeNote = "\n(Response in " + IntegerToString(elapsedSec) + "s)"; int placeholderPos = StringFind(conversationHistory, LoadingPlaceholder, 0); if (placeholderPos >= 0) { int endPos = StringFind(conversationHistory, "\n\n", placeholderPos) + 2; if (endPos < 2) endPos = StringLen(conversationHistory); string before = StringSubstr(conversationHistory, 0, placeholderPos); string after = StringSubstr(conversationHistory, endPos); conversationHistory = before + "AI: " + response + timeNote + "\n" + timestamp + "\n\n" + after; } else { conversationHistory += "AI: " + response + timeNote + "\n" + timestamp + "\n\n"; } if (StringFind(current_title, "Chat ") == 0) { current_title = StringSubstr(prompt, 0, 30); if (StringLen(prompt) > 30) current_title += "..."; UpdateCurrentHistory(); UpdateSidebarDynamic(); } } else { conversationHistory += "AI: " + response + "\n" + timestamp + "\n\n"; } UpdateCurrentHistory(); UpdateResponseDisplay(); // For final response: always scroll to bottom (response may be long) scroll_pos = MathMax(0, g_total_height - g_visible_height); if (scroll_visible) { UpdateSliderPosition(); UpdateButtonColors(); } ChartRedraw(); }
В функции SubmitMessage сначала проверяется, содержит ли входная строка prompt какой-либо текст. Если строка пуста, функция завершается досрочно. Затем через TimeToString, TimeCurrent и TIME_MINUTES формируется текущая временная метка. Инициализируется пустая строка response, а send_to_api получает значение true. Затем путем поиска через StringFind проверяется, начинается ли prompt со строкового литерала "set title ". Новый заголовок извлекается StringSubstr, обновляется current_title, в response записывается подтверждение, а send_to_api получает значение false. После этого вызываются UpdateCurrentHistory и UpdateSidebarDynamic. Сначала вызывается UpdateResponseDisplay, чтобы получить прежнее значение g_total_height. Затем пользовательский промпт и временная метка добавляются в conversationHistory, а UpdateResponseDisplay вызывается повторно для определения высоты после добавления промпта. Разность этих значений записывается в prompt_height.
Если send_to_api имеет значение true, в conversationHistory добавляются PrepBase и временная метка. После вызова UpdateResponseDisplay рассчитываются loading_height и new_content_height как сумма высот промпта и загрузочного сообщения. Значение scroll_pos динамически задается через MathMax: оно получает прежнее значение общей высоты, если новый контент помещается в g_visible_height, иначе устанавливается в положение прокрутки вниз. Если scroll_visible имеет значение true, вызываются UpdateSliderPosition, UpdateButtonColors и ChartRedraw. В цикле от 0 до PreAnimationCycles - 1 значение subCycle вычисляется как i mod 3, а строка dots формируется из числа точек до subCycle + 1. Путем поиска через StringFind в conversationHistory находится prepPos; части до и после него извлекаются StringSubstr. История обновляется строкой PrepBase, точками и временной меткой, после чего вызывается UpdateResponseDisplay, повторно задается scroll_pos, при необходимости обновляются ползунок и кнопки, выполняется ChartRedraw и вызывается Sleep(200).
Затем prepPos ищется повторно. Если позиция найдена, соответствующая часть заменяется на LoadingPlaceholder и временную метку; иначе они добавляются в историю. После вызова UpdateResponseDisplay повторно задается scroll_pos, при необходимости обновляются ползунок и кнопки, а затем выполняется ChartRedraw. Переменной StartTimeMs присваивается значение GetTickCount. Через Print и FileWrite в журнал и файл записываются ID и заголовок чата, затем промпт пользователя. Ответ сохраняется в response после вызова GetChatGPTResponse, а затем также выводится и записывается в файл. Значение elapsedMs вычисляется как разность GetTickCount и StartTimeMs, а elapsedSec – как число целых секунд. Затем формируется timeNote со строкой времени ответа. Путем поиска через StringFind находится placeholderPos для LoadingPlaceholder; при наличии позиции он заменяется строковым литералом "AI: ", ответом, timeNote и временной меткой, иначе эти данные добавляются в историю через StringSubstr. Остальная часть остается без изменений. Наиболее существенные изменения выделены для наглядности. Важно отметить, что здесь невозможно организовать полноценное обновление интерфейса в реальном времени, поскольку веб-запрос блокирует взаимодействие с интерфейсом. Теперь нужны вспомогательные функции, обрабатывающие нажатия добавленных значков.
// Extract last AI response from history string GetLastAIResponse() { int ai_pos = StringFind(conversationHistory, "AI: ", -1); // Search backward if (ai_pos < 0) return ""; int end_pos = StringFind(conversationHistory, "\n\n", ai_pos); if (end_pos < 0) end_pos = StringLen(conversationHistory); string response = StringSubstr(conversationHistory, ai_pos + 4, end_pos - ai_pos - 4); StringTrimLeft(response); StringTrimRight(response); return response; } // Extract last user prompt from history string GetLastUserPrompt() { int you_pos = StringFind(conversationHistory, "You: ", -1); // Search backward if (you_pos < 0) return ""; int ts_start = StringFind(conversationHistory, "\n", you_pos + 5) + 1; string prompt = StringSubstr(conversationHistory, you_pos + 5, ts_start - you_pos - 6); StringTrimLeft(prompt); StringTrimRight(prompt); return prompt; } // Remove last AI block from history (AI: ... \n timestamp \n\n) void RemoveLastAIResponse() { int last_nn = StringFind(conversationHistory, "\n\n", -1); if (last_nn >= 0) { int ai_pos = StringFind(conversationHistory, "AI: ", last_nn - 100); // Rough backward search if (ai_pos >= 0 && ai_pos < last_nn) { conversationHistory = StringSubstr(conversationHistory, 0, ai_pos); } } UpdateCurrentHistory(); }
Здесь определяется функция GetLastAIResponse, извлекающая последнее сообщение ИИ из conversationHistory. Через StringFind со значением -1 выполняется обратный поиск "AI: ", а конец сообщения определяется по "\n\n" либо по длине строки, если разделитель не найден. Содержимое после "AI: " извлекается как подстрока, очищается функциями StringTrimLeft и StringTrimRight и возвращается; если сообщение не найдено, возвращается пустая строка. Функция GetLastUserPrompt получает последний промпт пользователя. Через StringFind выполняется обратный поиск "You: ", затем определяется следующая позиция "\n" после текста промпта. Извлекается подстрока от текста после "You: " до временной метки, очищается и возвращается; если промпт отсутствует, возвращается пустая строка.
Функция RemoveLastAIResponse удаляет из conversationHistory последний блок ИИ. Через StringFind находится последнее "\n\n", затем в пределах 100 символов перед ним выполняется обратный поиск "AI: ". Если ai_pos найден и находится в корректной позиции, история обрезается перед ним через StringSubstr, после чего вызывается UpdateCurrentHistory для сохранения изменений. Эти функции будут вызываться при нажатии значков, но сначала нужно добавить обработку таких нажатий. Ниже приведена необходимая логика.
void OnChartEvent(const int id, const long &lparam, const double &dparam, const string &sparam) { //--- rest of the logic else if (sparam == "ChatGPT_EditIcon") { string response = GetLastAIResponse(); if (response != "") { currentPrompt = response; DeletePlaceholder(); UpdatePromptDisplay(); p_scroll_pos = MathMax(0, p_total_height - p_visible_height); if (p_scroll_visible) { UpdatePromptSliderPosition(); UpdatePromptButtonColors(); } ChartRedraw(); } } else if (sparam == "ChatGPT_RegenIcon") { string prompt = GetLastUserPrompt(); if (prompt != "") { RemoveLastAIResponse(); SubmitMessage(prompt); // Regenerates } } else if (sparam == "ChatGPT_ExportIcon") { string response = GetLastAIResponse(); if (response != "") { int handle = FileOpen("LastAIResponse.txt", FILE_WRITE | FILE_TXT); if (handle != INVALID_HANDLE) { FileWrite(handle, response); FileClose(handle); Print("Exported to LastAIResponse.txt"); } } } //--- rest of the logic }
Здесь в обработчике событий OnChartEvent добавляется обработка нажатия значка редактирования. Когда sparam равен ChatGPT_EditIcon, через GetLastAIResponse извлекается последний ответ ИИ. Если ответ не пуст, он присваивается currentPrompt, вызывается DeletePlaceholder, а область ввода обновляется через UpdatePromptDisplay. Значение p_scroll_pos также устанавливается в нижнее положение через MathMax(0, p_total_height - p_visible_height). Если p_scroll_visible имеет значение true, перед перерисовкой вызываются UpdatePromptSliderPosition и UpdatePromptButtonColors.
Для значка повторной генерации, когда sparam равен ChatGPT_RegenIcon, через GetLastUserPrompt извлекается последний пользовательский промпт. Если он не пуст, последний ответ ИИ удаляется функцией RemoveLastAIResponse, а промпт повторно передается в SubmitMessage для получения нового ответа. Когда sparam равен ChatGPT_ExportIcon, извлекается последний ответ ИИ. Если он найден, файл "LastAIResponse.txt" открывается через FileOpen в текстовом режиме записи; проверяется, что хэндл handle не равен INVALID_HANDLE. Затем ответ записывается функцией FileWrite, файл закрывается через FileClose и выводится сообщение об успешном экспорте. Ниже показан пример выгрузки ответа в файл.

Итоговый интерфейс выглядит следующим образом.

На визуализации видно, что новые элементы интерфейса добавлены в программу и настроены. Поставленные задачи выполнены. Остается протестировать программу на исторических данных, и этому посвящен следующий раздел.
Тестирование на исторических данных
Мы провели тестирование; ниже представлена итоговая визуализация в виде одного изображения формата Graphics Interchange Format (GIF).

На визуализации видно, что компоненты UI работают корректно, но при нажатии значков обрабатывается первое сообщение вместо последнего. Это не соответствует ожидаемому результату. Поэтому порядок поиска нужно изменить на обратный, чтобы исправить этот недочет. Проблема в том, что ранее использовался простой цикл, хотя для обработки многострочных ответов и промптов требуется более сложный разбор.
// Extract last user prompt from history string GetLastUserPrompt() { string blocks[]; int num_blocks = SplitOnString(conversationHistory, "\n\n", blocks); if (num_blocks == 0) return ""; // Find the last You block (reverse) for (int i = num_blocks - 1; i >= 0; i--) { string block = blocks[i]; if (StringFind(block, "You: ") == 0) { // Extract content after "You: " up to timestamp int ts_pos = StringFind(block, "\n", 5); // After "You: " if (ts_pos > 0) { string prompt = StringSubstr(block, 5, ts_pos - 5); StringTrimLeft(prompt); StringTrimRight(prompt); Print("DEBUG: Full history before extract prompt: " + conversationHistory); Print("DEBUG: Last You block: " + block); Print("DEBUG: Extracted last prompt: " + prompt); return prompt; } } } return ""; } string GetLastAIResponse() { // Split entire history into lines string lines[]; int num_lines = StringSplit(conversationHistory, '\n', lines); if (num_lines == 0) { Print("DEBUG: No lines in history."); return ""; } Print("DEBUG: Total lines in history: " + IntegerToString(num_lines)); for (int j = 0; j < num_lines; j++) { Print("DEBUG: History Line " + IntegerToString(j) + ": " + lines[j]); } // Find start of last AI response (reverse search for "AI: ") int ai_start = -1; for (int i = num_lines - 1; i >= 0; i--) { string trimmed = lines[i]; StringTrimLeft(trimmed); StringTrimRight(trimmed); if (StringFind(trimmed, "AI: ") == 0) { ai_start = i; break; } } if (ai_start == -1) { Print("DEBUG: No AI: line found in history. Full history: " + conversationHistory); return ""; } Print("DEBUG: Last AI starts at line " + IntegerToString(ai_start)); string response_build = ""; // Extract from AI: line string first_line = lines[ai_start]; int prefix_pos = StringFind(first_line, "AI: "); if (prefix_pos >= 0) { first_line = StringSubstr(first_line, prefix_pos + 4); StringTrimLeft(first_line); StringTrimRight(first_line); if (StringLen(first_line) > 0 && StringFind(first_line, "(Response in ") != 0 && StringFind(first_line, "(Regenerated in ") != 0 && !IsTimestamp(first_line)) { response_build = first_line; } } // Collect subsequent lines until next message start (You: or AI: ) or end for (int j = ai_start + 1; j < num_lines; j++) { string orig_line = lines[j]; string trimmed = orig_line; StringTrimLeft(trimmed); StringTrimRight(trimmed); // Stop if new message starts if (StringFind(trimmed, "You: ") == 0 || StringFind(trimmed, "AI: ") == 0) { break; } // Skip notes and timestamps if (StringFind(trimmed, "(Response in ") == 0 || StringFind(trimmed, "(Regenerated in ") == 0 || IsTimestamp(trimmed)) { continue; } // Add original line (preserve empties as \n) if (response_build != "") response_build += "\n"; response_build += orig_line; } Print("DEBUG: Extracted last response: '" + response_build + "'"); return response_build; }
Для наглядности к соответствующим строкам добавлены комментарии и отладочные сообщения, позволяющие проверить получаемые данные. Если они не нужны, их можно закомментировать. Мы сохраним их для дальнейшего использования, а для финальной версии закомментируем. Кроме того, в функцию отправки добавлен дополнительный символ переноса строки, чтобы она корректно обрабатывала ответы с пустыми строками и переносами строк, как показано ниже.
void SubmitMessage(string prompt) { //--- conversationHistory += "You: " + prompt + "\n" + timestamp + "\n\n"; // Add extra \n for separation //--- }
После компиляции получаем итоговый результат, который нас полностью устраивает.

Заключение
В этой части мы доработали пользовательский интерфейс торговой системы с ИИ на языке MQL5. Добавлены анимации загрузки для этапов подготовки запроса и ожидания ответа, метрики времени для отображения длительности ответа в секундах, а также инструменты управления: кнопки повторной генерации для повторной отправки промптов и экспорт ответов в файлы. Эти возможности вместе с эффектами наведения, масштабируемыми изображениями и динамическими боковыми панелями делают интерфейс более отзывчивым и визуально привлекательным. Модульная структура кода упрощает его дальнейшее расширение. В следующих частях рассмотрим интеграцию анализа настроений и подтверждение сигналов на нескольких таймфреймах для более обоснованных торговых решений. Оставайтесь с нами.
Приложения
| № | Имя | Тип | Описание |
|---|---|---|---|
| 1 | AI_JSON_FILE.mqh | Библиотека классов JSON | Класс для сериализации и десериализации JSON |
| 2 | AI_CREATE_OBJECTS_FNS.mqh | Библиотека функций объектов | Функции для создания объектов визуализации, таких как метки и кнопки |
| 3 | AI_UI_COMPONENTS.mqh | Библиотека компонентов пользовательского интерфейса | Файл, содержащий компоненты пользовательского интерфейса и логику их организации |
| 4 | AI_BMP_FILES_ZIP | Zip-архив с растровыми файлами | Файл, содержащий растровые изображения |
| 5 | AI_ChatGPT_EA_Part_8.mq5 | Основной файл советника | Основной советник для интеграции ИИ |
Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/20722
Предупреждение: все права на данные материалы принадлежат MetaQuotes Ltd. Полная или частичная перепечатка запрещена.
Данная статья написана пользователем сайта и отражает его личную точку зрения. Компания MetaQuotes Ltd не несет ответственности за достоверность представленной информации, а также за возможные последствия использования описанных решений, стратегий или рекомендаций.
Особенности написания Пользовательских Индикаторов
Изучение Стандартной библиотеки MQL5 (Часть 6): Оптимизация сгенерированного советника
Возможности Мастера MQL5, которые вам нужно знать (Часть 86): Ускорение доступа к данным с помощью разреженной таблицы в пользовательском классе трейлинга
- Бесплатные приложения для трейдинга
- 8 000+ сигналов для копирования
- Экономические новости для анализа финансовых рынков
Вы принимаете политику сайта и условия использования
Статья очень понравилась.
Как вы думаете, какова ежемесячная стоимость использования API?
Я искал способ, как мы могли бы использовать ИИ на базе DeepSeek, который работает в системе и может быть интегрирован в эту программу.
Спасибо за креативный подход к внедрению этой функции в MT5.
Статья очень понравилась.
Как вы думаете, какова ежемесячная стоимость использования API?
Я искал способ, как мы могли бы использовать ИИ на базе DeepSeek, который работает в системе и может быть интегрирован в эту программу.
Спасибо за креативный подход к внедрению этой функции в MT5.
Спасибо за добрые отзывы и приветствие. Это будет зависеть от графика, на котором он запущен, и от выбранного режима. Если выбран автоматический режим, то он будет обмениваться данными при каждом баре, что увеличит количество необходимых токенов. Ещё лучше — вы можете задать сессию, в которой он будет работать автоматически, чтобы избежать ненужных запусков, либо определить новые правила или расширить те, которые мы использовали. Его можно использовать с любым ИИ, просто настройте ключи API.
Спасибо.
Статья очень понравилась.
Как вы думаете, какова ежемесячная стоимость использования API?
Я искал способ, как мы могли бы использовать ИИ на базе DeepSeek, который работает в системе и может быть интегрирован в эту программу.
Спасибо за креативный подход к внедрению этой функции в MT5.
Спасибо за добрые отзывы и приветствие. Это зависит от графика, на котором он запущен, и от выбранного режима. Если выбран автоматический режим, то он будет обмениваться данными при каждом баре, что увеличит количество необходимых токенов. Еще лучше — вы можете настроить сессию, в которой он будет работать автоматически, чтобы избежать ненужных запусков, либо определить новые правила или расширить существующие, которые он будет соблюдать, вместо тех, что мы использовали. Его можно использовать с любым ИИ — просто настройте ключи API.
Спасибо.
Это действительно потрясающе! Даже ChatGPT не сказал мне, что это возможно, и дал категоричный ответ, что это невозможно!
Привет, я тоже в восторге от этих статей. По результатам проведенных мной тестов могу сказать, что при использовании модели Gpt4.o в пределах объёма контекстного окна стоимость одного запроса составляет 0,01 доллара. Однако при тестировании с моделью Gpt4.1 стоимость составляет 0,05 доллара.
. Раньше я пользовался моделью ChatGPT 4.0, и она работала просто отлично!