MQL5でのAI搭載取引システムの構築(第8回):アニメーション、タイミング指標、応答管理ツールによるUIの改善
はじめに
前回の記事(第7回)では、MetaQuotes Language 5 (MQL5)において、AIを活用した取引システムをさらなるモジュール化し、コード構造の整理によって保守性を向上させました。また、カスタマイズ可能なロットサイズおよびマジックナンバーを備えた、AI生成シグナルに基づく自動売買機能も導入しました。第8回では、アニメーション、タイミングメトリクス、およびレスポンス管理ツールを備えた洗練されたユーザーインターフェース(UI)を構築します。本モデルでは、APIリクエスト中にローディングアニメーションを表示し、処理時間に関する応答を提供することでパフォーマンスを可視化します。さらに、AI出力を管理するための再生成ボタンおよびエクスポートボタンを実装し、ユーザー体験を強化します。本記事では以下のトピックを扱います。
記事を読み終える頃には、洗練されたAI駆動取引操作を備えた実用的なMQL5プログラムが完成し、自由にカスタマイズできる状態になります。それでは始めましょう。
拡張ユーザーインターフェース機能の理解
拡張されたユーザーインターフェース機能は、AIを活用した取引システムにおける操作向上に重点を置いています。具体的には、APIリクエストの準備フェーズおよび思考フェーズにおいてローディングアニメーションを表示し、処理状況に関する視覚的な応答を提供します。また、レスポンスの処理時間を秒単位のタイミングメトリクスとして表示することで、処理効率をユーザーに分かりやすく伝えます。さらに、レスポンス管理機能として、直前のプロンプトを再送信して新たなAI出力を取得する再生成ボタンや、応答内容をテキストファイルとして保存するエクスポートボタンを導入します。これによって、結果の確認や共有が容易になります。
これらの機能はモジュール化を意識して構築し、既存のUIコンポーネントを拡張する形で実装します。具体的には、ドットが循環するアニメーションループの追加、ティックカウントを用いたタイムスタンプ計測、さらにボタンクリックによって再生成やファイル出力をトリガーするイベントハンドラの実装をおこないます。また、サイドバーの状態管理を拡張し、動的なリサイズやオブジェクトの再配置に対応させます。ユーザーの操作に応じた条件付きレンダリングを導入することで、表示内容やスクロール位置をシームレスに更新しつつ、AIのコア機能に影響を与えない設計とします。以下に想定されるビジュアル表示の例を示します。

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」を付与した役割として追加の行として組み込み、タイムスタンプ用に調整された高さを適用します。これにより全体の高さと行数を増加させます。また、メッセージ間にメッセージマージン(余白)を追加しますが、最後のメッセージでない場合はそのまま加算し、最後のメッセージの場合は末尾に余分なスペースが残らないようにマージンを差し引きます。さらに、最後のメッセージがAIからのもので、かつ「Preparing the Request」または「Thinking...」を含む場合には、ローディング中の表示領域を確保するために高さに30px分の余白を追加します。また、「(Response in 」を含む場合には、アイコン付きの時間表示の下に余白を持たせるため、さらに30pxを追加します。該当する変更箇所については、分かりやすさのためにハイライトしています。次に、レスポンス表示を描画する関数を更新する必要があるため、新しいヘルパーアイコンもあわせて組み込んでいきます。
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関数で削除し、描画領域をクリアします。それ以降のコードは基本的に以前と同じですが、変更を加えた箇所については分かりやすさのためにハイライトし、コメントを追加しています。ここでは、追加したアイコン周りで大きく変更した部分から説明します。
まず、visibleHeightを超えないように注意しながら表示可能な高さを順に加算し、タイムスタンプの後に入るマージンも含めてnumVisibleLinesを計算します。続いて、左右のx座標を設定し、ユーザー、AI、タイムスタンプそれぞれの表示色を決定します。その後、表示される行をループ処理し、それぞれの行の内容と役割を取得します。タイムスタンプかどうかを判定し、それに応じてサイズ、色、フォントを設定します。また、表示可能なスペースに応じて表示行を調整します。役割に基づいて位置およびアンカーを設定し、y座標が表示範囲内に収まっている場合はcreateLabelを使用してラベルを生成します。最後のAIメッセージに含まれる時間ノート(lineIndexがtotalLines-2)については、TextGetSizeでテキスト幅を取得し、そこからiconXおよびiconYを算出します。続いて、REGEN_ICONを使用して再生成アイコンのラベル「ChatGPT_RegenIcon」を作成し、色、サイズ、フォントを設定します。同様に、一定間隔を空けてエクスポートアイコンChatGPT_ExportIconも配置し、selectableおよびzorderを設定します。各行の描画後には、行の高さ分だけ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...」として設定し、AIの応答待機中に表示されるテキストを定義します。こちらも必要に応じて変更できます。続いて、SpinnerDotsを空文字、1つのドット、2つのドット、3つのドットで構成される文字列配列として定義し、ローディングメッセージに付加することで循環アニメーション効果を実現します。さらに、アニメーションのループ回数を制御するためにPreAnimationCyclesを6に設定します。これはスリープ間隔を考慮した場合、おおよそ1秒程度のアニメーション時間に相当します。
また、APIレスポンスのタイミング計測のためにStartTimeMsをunsigned longとして0で初期化し、開始時刻のティックカウントを保持するために使用します。そしてメッセージ送信時には、これらの変数を用いてローディング状態をシミュレートします。更新された関数は以下のとおりです。
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の長さを確認し、空の場合は早期リターンします。その後、TimeCurrentとTIME_MINUTESを用いて現在時刻を取得し、TimeToStringで整形します。次に、responseを空で初期化し、send_to_apiをtrueに設定します。その後、promptが「set title 」で始まるかをStringFindで判定し、該当する場合はStringSubstrで新しいタイトルを抽出します。そしてcurrent_titleを更新し、responseに確認メッセージを設定し、send_to_apiをfalseに変更します。あわせてUpdateCurrentHistoryとUpdateSidebarDynamicを呼び出します。続いてUpdateResponseDisplayを呼び出して旧g_total_heightを取得し、ユーザープロンプトとタイムスタンプをconversationHistoryに追加します。その後もう一度UpdateResponseDisplayを実行し、プロンプト追加後の高さを取得し、prompt_heightを差分として計算します。
send_to_apiがtrueの場合、まずPrepBaseとタイムスタンプをconversationHistoryに追加します。その後UpdateResponseDisplayを呼び出してローディング追加後の高さを取得し、loading_heightを計算します。さらにnew_content_heightをプロンプトとローディングの合計として算出します。scroll_posはMathMaxを用いて動的に設定し、新しいコンテンツがg_visible_height内に収まる場合は旧値を維持し、それ以外の場合はローディング後の末尾位置へ移動します。scroll_visibleが有効な場合はUpdateSliderPositionとUpdateButtonColorsを呼び出し、ChartRedrawで画面を更新します。次に、0からPreAnimationCycles - 1までをループ処理します。各ループでsubCycleを「i mod 3」として計算し、dotsをサイクルに応じて構築します。prepPosをconversationHistory内でStringFindにより検索し、StringSubstrで前後を分割した上で、「PrepBase + dots + タイムスタンプ」としてhistoryを更新します。その後UpdateResponseDisplayを呼び出し、scroll_posを再計算し、必要に応じてスライダーとボタンを更新し、ChartRedrawを実行します。最後にSleep(200)で200ミリ秒待機します。
その後、再度prepPosを検索し、見つかった場合はLoadingPlaceholderとタイムスタンプに置き換えます。見つからない場合は末尾に追加します。その後UpdateResponseDisplayを呼び出し、scroll_posを再適用し、必要に応じてスライダー/ボタンを更新して、ChartRedrawをおこないます。続いてStartTimeMsをGetTickCountで記録し、PrintおよびFileWriteを用いてチャットIDとタイトルをログに出力します。同様にユーザープロンプトもログへ記録します。その後GetChatGPTResponseを呼び出してAI応答を取得し、結果をPrintおよびFileWriteで記録します。elapsedMsをGetTickCountとの差分として計算し、elapsedSecを秒単位で算出します。timeNoteをレスポンス時間表示として生成し、placeholderPosをLoadingPlaceholderで検索します。見つかった場合は「"AI: " + response + timeNote + タイムスタンプ」に置き換え、見つからない場合は末尾に追加します。この処理でもStringFindおよびStringSubstrを使用します。それ以外の部分は以前と同じです。今回の変更点は重要な部分に絞ってハイライトしています。なお、Webリクエストは処理をブロックするため、ライブシミュレーションはおこなえない点に注意が必要です。次に、追加したアイコンがクリックされた際に動作するヘルパー関数を実装していきます。
// 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から最新のAIメッセージを抽出します。StringFindを用いて「AI: 」を後方検索し(-1を指定して末尾側から探索)、区切りとして「\n\n」または文字列末尾を終端として取得します。その後、「AI: 」以降の部分をStringSubstrで切り出し、StringTrimLeftおよびStringTrimRightで空白を除去して結果を返します。該当するデータが見つからない場合は空文字を返します。次に、GetLastUserPrompt関数を作成し、最新のユーザー入力を取得します。こちらも同様にStringFindを用いて「You: 」を後方検索し、プロンプト後の次の「\n」の位置を終端として特定します。「You: 」の直後からタイムスタンプ直前までをStringSubstrで切り出し、トリム処理をおこなった上で返却します。該当しない場合は空文字を返します。
さらに、RemoveLastAIResponse関数を実装し、conversationHistoryから最後のAIブロックを削除します。まず最後の「\n\n」をStringFindで取得し、その位置から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を呼び出して最新のAIレスポンスを取得します。取得結果が空でない場合、その内容をcurrentPromptに代入し、DeletePlaceholderを呼び出してプレースホルダを削除します。その後、UpdatePromptDisplayを実行してプロンプト表示を更新します。さらに、スクロール位置をp_visible_heightを基準に、MathMaxを用いて0と「p_total_height − p_visible_height」の最大値として設定し、最下部へ移動させます。p_scroll_visibleが有効な場合はUpdatePromptSliderPositionおよびUpdatePromptButtonColorsを呼び出した後、ChartRedrawで画面を更新します。
次に、再生成アイコンの処理では、sparamがChatGPT_RegenIconの場合にGetLastUserPromptを使用して最新のユーザープロンプトを取得します。取得結果が空でない場合は、まずRemoveLastAIResponseを呼び出して直前のAI応答を削除し、その後SubmitMessageを再実行することで新しいレスポンスを生成します。また、エクスポートアイコンの処理では、sparamがChatGPT_ExportIconと一致する場合にGetLastAIResponseで最新のAIレスポンスを取得します。内容が存在する場合は、FileOpenを用いて"LastAIResponse.txt"をテキスト書き込みモードで開きます。handleがINVALID_HANDLEでないことを確認した上で、FileWriteを使用してレスポンスを書き込み、FileCloseでファイルを閉じます。その後、成功メッセージをログに出力します。ダウンロード操作の例を以下に示します。

最終的なUIは以下のようになります。

この可視化から分かるように、新しいUI要素を追加および調整することでプログラムを段階的にアップグレードできており、当初の目的は達成されています。残っている作業は、このプログラムのバックテストをおこなうことです。バックテストについては次のセクションで扱います。
バックテスト
テストを実施しました。以下はコンパイル後の可視化を単一の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; }
それぞれの該当行には、処理内容を分かりやすくするためのコメントを追加し、さらにデバッグ出力も挿入して、取得しているデータを正確に確認できるようにしています。必要ない場合はコメントアウトしても構いませんが、今回は後の検証や調整のために残しておき、最終的な仕上げ段階でまとめて整理する方針とします。また、SubmitMessage関数にも新たに改行処理を追加し、以下のように、空行や複数の改行を含むすべてのレスポンスを正しく処理できるようにしています。
void SubmitMessage(string prompt) { //--- conversationHistory += "You: " + prompt + "\n" + timestamp + "\n\n"; // Add extra \n for separation //--- }
コンパイルすると、最終的に以下の満足のいく結果が得られます。

結論
本記事ではMQL5におけるAI駆動取引システムのユーザーインターフェースを強化しました。具体的には、リクエストの準備フェーズおよび思考フェーズにおけるローディングアニメーション、レスポンス時間を秒単位で表示するタイミングメトリクス、さらにプロンプトの再実行を行う再生成ボタンや出力をファイルに保存するエクスポート機能などの管理ツールを導入しています。これらの機能に加えて、ホバーエフェクト、スケーリングされた画像、動的サイドバーなどを組み合わせることで、より応答性が高く視覚的にも洗練されたインターフェースを実現しています。同時に、コードはモジュール化されており、将来的な拡張にも対応しやすい設計になっています。今後の記事では、センチメント分析の統合やマルチタイムフレームでのシグナル確認など、より高度な取引判断を可能にする機能について扱う予定です。どうぞご期待ください。
添付ファイル
| シリアル番号 | 名前 | 種類 | 詳細 |
|---|---|---|---|
| 1 | AI_JSON_FILE.mqh | JSONクラスライブラリ | JSONのシリアライズおよびデシリアライズを扱うクラス |
| 2 | AI_CREATE_OBJECTS_FNS.mqh | オブジェクト関数ライブラリ | ラベルやボタンなどの可視化オブジェクトを作成する関数 |
| 3 | AI_UI_COMPONENTS.mqh | UIコンポーネントライブラリ | UIコンポーネントとその構成を含むファイル |
| 4 | AI_BMP_FILES_ZIP | 圧縮されたビットマップファイル | ビットマップ画像を含むファイル |
| 5 | AI_ChatGPT_EA_Part_8.mq5 | 主要EAファイル | AI統合を扱うメインのEA |
MetaQuotes Ltdにより英語から翻訳されました。
元の記事: https://www.mql5.com/en/articles/20722
警告: これらの資料についてのすべての権利はMetaQuotes Ltd.が保有しています。これらの資料の全部または一部の複製や再プリントは禁じられています。
この記事はサイトのユーザーによって執筆されたものであり、著者の個人的な見解を反映しています。MetaQuotes Ltdは、提示された情報の正確性や、記載されているソリューション、戦略、または推奨事項の使用によって生じたいかなる結果についても責任を負いません。
ラリー・ウィリアムズの『市場の秘密』(第3回):MQL5で非ランダムな市場の動きを証明する
エラー 146 (「トレードコンテキスト ビジー」) と、その対処方法
MQL5でカスタムインジケーターを作成する(第3回):扇形と円形によるマルチゲージの強化
- 無料取引アプリ
- 8千を超えるシグナルをコピー
- 金融ニュースで金融マーケットを探索
この記事が気に入りました。
毎月のAPIコストはどれくらいだと思いますか?
私は、システム上で動作し、このプログラムに使用できるDeepSeekベースのAIを使用できる方法を探していました。
MT5にこれを導入する創造的な方法をありがとうございます。
この記事が気に入った。
毎月のAPIコストはどれくらいだと思いますか?
私は、システム上で動作するDeepSeekベースのAIを使用して、このプログラムに使用できる方法を探していました。
MT5にこれを導入するクリエイティブな方法をありがとうございます。
ご丁寧なフィードバックと歓迎のお言葉をありがとうございます。それは、実行されているチャートと選択されているモードに依存します。自動モードが選択されている場合、それはすべてのバーで通信され、必要なトークンが増加します。さらに良いのは、不要な実行を避けるために自動的に実行されるセッションを定義したり、私たちが使用したものよりも新しい、または拡張されたルールを定義することができます。APIキーを設定するだけで、どのAIでも使用できます。
ありがとう。
この記事が気に入った。
毎月のAPIコストはどれくらいだと思いますか?
私は、システム上で動作するDeepSeekベースのAIを使用して、このプログラムに使用できる方法を探していました。
MT5にこれを導入するクリエイティブな方法をありがとうございます。
親切なフィードバックと歓迎をありがとう。それは実行されているチャートと選択されているモードによります。自動モードが選択されている場合、すべてのバーで通信が行われ、必要なトークンが増えます。さらに良いのは、不要な実行を避けるために自動的に実行されるセッションを定義したり、私たちが使用したものよりも新しい、または拡張されたルールを定義することができます。APIキーを設定するだけで、どのAIでも使用できる。
ありがとう。
これはマジですごい!chatGPTでさえ、これが可能だとは言ってくれなかったし、これは不可能だと確実な答えが返ってきた!
こんにちは、記事には大変満足しています。私が行ったテストでは、Gpt4.oモデルのコンテキストウィンドウの容量内では、リクエストあたりのコストは0.01ドルでした。しかし、Gpt4.1モデルでテストすると、コストは0.05ドルです。
過去にChatGPT4.0モデルを使用したことがありますが、かなり正確でした!