You are currently viewing the resource titled "πŸ€– Building a ChatGPT AI Trade Brain in MQL5". This page provides detailed information about the resource, including its content, attached files, and recent discussions. Feel free to explore, download available files if logged in, and join the community conversation below!
Banner Image

There's a moment every algo trader eventually hits. You've built something that talks really well. The AI responds, the response is smart, the analysis sounds convincing β€” and then you sit there, manually interpreting prose, typing orders by hand, watching entry bars close while you fumble with the lot size field. The chat interface delivered insight. It delivered zero execution. 😀

That gap ends here.

This article is the story of how a sophisticated MQL5 AI chat assistant gets surgically rebuilt into something that doesn't just talk about trades β€” it places them. We design a dispatch-driven architecture where every analytical action (seven of them, covering everything from a two-bar momentum check to a full fifty-bar support/resistance scan) routes through a single central handler via stable integer IDs. We discipline the AI into speaking a line-based KEY:VALUE protocol instead of free-form prose, so responses are machine-parseable regardless of how chatty the model gets. We wire a pending order matrix that maps level type and trade bias to the correct order type automatically. And we build the UI to match β€” a split-action signal button, a caret-aware custom editor, pixel-accurate binary-search text fitting, thirteen hand-drawn semantic icons, a virtualized search popup with filter caching, and a toast notification system with a live countdown progress bar.

By the time you finish reading, you'll have a working MQL5 Expert Advisor that presents a seven-action signal console on your chart, generates structured AI trade signals, places real orders, and paints every decision as a labeled bar-anchored drawing β€” auditable, backtestable, and built to grow.


1. πŸ—οΈ The Problem With Chatbots That Can't Trade

Let's be honest about what the previous version of this assistant actually was: a very expensive autocomplete with a pretty face. πŸ’…

You'd open the dashboard, type "what's the trend on this pair?", and get back two paragraphs of nuanced market commentary. Beautiful. Completely useless for automation. The response was prose. Code cannot parse prose. You can't feed "the recent price action suggests a cautiously bullish bias with notable resistance overhead" into OrderSend(). Every signal still lived entirely in your head, and your head is fallible, slow, and prone to FOMO.

The structural problems were three-deep. First, every response came back as unstructured text β€” no consistent format, no machine-readable fields, nothing a parser could grab onto. Second, every type of analysis (scalp signal, daily bias, trend read) funneled through the same single chat box with no way to give each one its own data preparation, prompt template, or execution path. Third, even when a useful signal did emerge, nothing on the chart showed that a decision had been made. No arrow on the entry bar. No line on the level. Nothing. The trader carried the signal mentally, and mental signals have a half-life measured in minutes. ⏱️

The fix isn't a smarter prompt. The fix is architecture. By the end, you will have the following outcome.


What a Dispatch-Driven System Actually Means

Picture a hotel concierge desk. 🏨 The concierge doesn't handle every guest request by improvising from scratch. They have a laminated card behind the counter: "spa booking β†’ call extension 12, restaurant reservation β†’ call extension 34, airport transfer β†’ call extension 56." A guest walks up, states a need, the concierge reads the card, makes one call. New service added? New line on the card. Nothing else changes.

That's what we're building. Each of our seven trading actions has a stable integer ID β€” its "extension number." The dispatcher is the concierge. A button click is the guest. The entire routing system fits in one switch statement. Adding an eighth action in a future version means adding one row to three arrays and one case to the switch. The UI button appears automatically. The hover code routes automatically. The events dispatch automatically.

The Seven Actions and What They Actually Do

Rather than list these as a table and call it a day, let's walk through the analytical logic each one represents β€” because understanding why these seven were chosen matters for extending the system intelligently.


Get Chart Data is the foundation action. It dumps recent OHLC bars, indicator readings, and symbol metadata into the chat as context. You use this before asking follow-up questions β€” it's the "here's what I'm looking at" primer that makes subsequent AI responses grounded rather than generic.

Twin Bars exploits one of the most reliable momentum signals in candlestick analysis: two consecutive bars closing in the same direction with the same character. If Bar 1 and Bar 2 are both clean bullish closes, the path of least resistance is up. If they're both bearish, it's down. This action checks the last two closed bars, classifies them, and emits BUY, SELL, or NONE. Clean, fast, low-noise.

Quick Scalp widens the lens to ten bars and hunts for candlestick patterns β€” engulfing bars, pin bars, inside bars, morning/evening stars. The signal targets the current intraday opportunity rather than a longer-term view. It's designed for the trader who wants a fast machine-generated entry trigger without sitting through a full analysis cycle.

Daily Signal steps up to the H1 timeframe and pulls every closed hourly bar from today's session. It's asking a directional bias question: given how today has played out so far, which way is the market leaning? The answer comes back as a directional momentum score that drives a market order entry.

Trend Read takes the widest analytical view of the signal actions β€” thirty bars, full trend analysis, with two price anchor points returned that the program uses to draw an actual trendline on the chart. This is where the system starts producing chart drawings that persist, not just chat text that disappears when you scroll.

Key Level scans fifty bars for one significant horizontal S/R level, classifies it as support or resistance, and determines whether the current price action favors bouncing off it or breaking through it. The bounce-or-break decision, combined with the support-or-resistance classification, completely determines the pending order type through a 2Γ—2 matrix β€” no ambiguity, no manual decision.

Clear Drawings is pure housekeeping. Every trendline, arrow, horizontal line, and label the program painted on the chart gets wiped in a single command. Essential after a backtesting session when the chart looks like a kindergarten art project. 🎨

The AI output protocol underpinning all six analytical actions is the same: one fact per line, no JSON, no nested braces. SIGNAL: BUY, ENTRY: 1.10250, LEVEL_TYPE: SUPPORT, BIAS: BOUNCE. The parser reads lines, extracts values, ignores everything else. It's deliberately designed to survive the AI appending commentary β€” because it always does.

2. 🎨 Building the Visual Foundation β€” Theme, Primitives, and the Drawing Stack

Every pixel you see on the dashboard β€” the rounded corners, the anti-aliased text, the smooth icon lines, the gradient-free flat chat bubbles β€” traces back to two foundational files. Everything else in the program is a consumer of these primitives. We build them first, we build them right, and we never touch them again.

The Theme System: One Function, Total Control

The theme file holds every color, font size, layout dimension, and glyph code the program references. All of them. Centralized. This sounds obvious until you've spent three hours hunting across a 4,000-line codebase to change one padding value that affects eight different UI elements. We've all been there. πŸ˜…

The architecture here is simple: every visual constant is either a preprocessor #define (compile-time, zero runtime overhead) or a global color variable filled by a single function call. That function is 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"

#ifndef AI_CANVAS_THEME_MQH
#define AI_CANVAS_THEME_MQH

//+------------------------------------------------------------------+
//| Layout Dimensions                                                |
//+------------------------------------------------------------------+
#define AI_SIDEBAR_W_EXPANDED   150
#define AI_SIDEBAR_W_COLLAPSED  50
//--- REST OF DIMENSIONS AS BEFORE

//+------------------------------------------------------------------+
//| Apply theme palette - true = dark, false = light                 |
//+------------------------------------------------------------------+
void Ai_ApplyTheme(bool dark)
  {
   g_ai_darkTheme = dark;
   if(dark)
     {
      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';
      g_ai_border         = C'95,105,125';
      g_ai_borderAccent   = C'130,140,160';
      g_ai_titleText      = C'225,230,240';
      g_ai_subText        = C'140,150,170';
      g_ai_bodyText       = C'215,220,232';
      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 FOR THE REST OF THE MEMBERS
     }
  }

#endif // AI_CANVAS_THEME_MQH

Ai_ApplyTheme(true) assigns the entire dark palette in one pass. Ai_ApplyTheme(false) does the same for light. Calling it from the theme toggle button in the interact layer means the entire dashboard repaints in the new colors on the next Ai_RenderAll() β€” no per-element color logic anywhere. One function. Total control. βœ…

Font sizes cover six use cases: title text, body text, button labels, chat snippet previews, timestamps, and toolbar labels. Glyph codes map readable names to raw font characters β€” AI_GLYPH_CLOSE = "r" renders as a close cross in Webdings. The rest of the codebase references AI_GLYPH_CLOSE, never "r". When you look at the code six months from now, you'll thank yourself. πŸ™

The Pixel Engine β€” Why We Subclass CCanvas

Here's something MQL5 developers rarely talk about: calling PixelSet and PixelGet on a CCanvas object for every pixel in a blend operation is genuinely slow. Those are virtual function calls. When you're compositing text glyphs, painting chat bubbles, blending rounded rectangles at 4Γ— supersampling and then downsampling β€” you're making millions of those calls per render frame.

The fix is surgical. We subclass CCanvas into CAiCanvasFast and expose the internal pixel buffer directly:

//+------------------------------------------------------------------+
//|                                         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"

#ifndef AI_CANVAS_PRIMITIVES_MQH
#define AI_CANVAS_PRIMITIVES_MQH

#include <Canvas/Canvas.mqh>
#include "AI Canvas Theme.mqh"

//+------------------------------------------------------------------+
//| Fast canvas subclass with direct pixel buffer access             |
//+------------------------------------------------------------------+
class CAiCanvasFast : public CCanvas
  {
public:
   uint GetPixelDirect(int x, int y) const
     {
      return m_pixels[y * Width() + x];
     }

   void SetPixelDirect(int x, int y, uint v)
     {
      m_pixels[y * Width() + x] = v;
     }

   void CopyRectFromCanvas(CCanvas &src, int l, int t, int r, int b)
     {
      const int sw = src.Width();
      const int sh = src.Height();
      const int dw = Width();
      const int dh = Height();
      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);
      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);
        }
     }

   void CopyRectToCanvas(CCanvas &dst, int l, int t, int r, int b)
     {
      const int dwOther = dst.Width();
      const int dhOther = dst.Height();
      const int sw = Width();
      const int sh = Height();
      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);
      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]);
        }
     }

   void FillRectFast(int l, int t, int r, int b, uint argb)
     {
      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);
      for(int yy = ct; yy < cb; yy++)
        {
         const int rowBase = yy * w;
         for(int xx = cl; xx < cr; xx++)
            m_pixels[rowBase + xx] = argb;
        }
     }
  };

//+------------------------------------------------------------------+
//| Drawing primitives helper class                                  |
//+------------------------------------------------------------------+
class CAiCanvasPrimitives
  {
public:
   bool          m_hrFillReady;
   bool          m_hrBorderReady;
   int           m_hrFillW;
   int           m_hrFillH;
   int           m_hrBorderW;
   int           m_hrBorderH;
   CAiCanvasFast m_hrFill;
   CAiCanvasFast m_hrBorder;

                  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);
  };

GetPixelDirect and SetPixelDirect use the row-major formula y * Width() + x directly β€” no virtual dispatch, no bounds overhead (we handle clamping at the call site where it makes sense). FillRectFast fills a rectangle row by row with a single inner loop. CopyRectFromCanvas and CopyRectToCanvas bulk-transfer rectangular regions between canvases β€” a pattern we use heavily in the search popup's clipped rendering.

The CAiCanvasPrimitives class owns two off-screen CAiCanvasFast instances β€” m_hrFill and m_hrBorder. These are the supersampling working canvases. When we need a sharp anti-aliased rounded rectangle, we render it at 4Γ— resolution into m_hrFill, then box-average the result down to 1Γ— for the main canvas. The EnsureHrFill and EnsureHrBorder methods grow these working canvases lazily β€” they allocate only when a request needs more space than already exists. First time costs. Every subsequent call is free. ♻️

The Text Anti-Aliasing Trick Nobody Teaches

TextOut in MQL5 renders text into a pixel buffer. What it doesn't give you is per-pixel alpha coverage. Without alpha coverage, stamping text onto a colored background produces hard opaque edges that look terrible on anything except pure white.

Here's how we solve it with a technique called the dual-buffer method:

//+------------------------------------------------------------------+
//| 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)
  {
   if(StringLen(txt) == 0) return;

   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 onto black background
   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 onto white background
   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);

   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);

   //--- Derive per-pixel alpha from black-vs-white difference
   for(int py = 0; py < th; py++)
     {
      for(int px = 0; px < tw; px++)
        {
         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;

         int dstX = x + px, dstY = y + py;
         if(dstX < 0 || dstX >= cW || dstY < 0 || dstY >= cH) continue;

         //--- Porter-Duff over blend
         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);
        }
     }
  }

The core insight: render the same text twice β€” once over pure black (bufB), once over pure white (bufW). Where a pixel is fully inside the glyph, both buffers receive the source color and their difference is zero. Where a pixel is fully outside, the buffers keep their black and white fills β€” maximum difference. Anti-aliased edges fall in between, proportional to how much of the glyph covers that pixel. Invert the averaged difference and you have a clean alpha mask. Run that through a standard Porter-Duff over-blend and your text composites perfectly over any background color in the dashboard. Blue bubble, dark panel, beige prompt pane β€” the text looks sharp everywhere. πŸ”¬

Binary Search Beats Character Counting Every Time

One of the subtle-but-visible improvements over the previous article is how chat titles, popup snippets, and button labels get truncated to fit their allocated pixel width. The old way counted characters and hoped for the best. The problem with counting characters on a proportional font is that "iiiiii" and "MMMMMM" are the same length in characters but wildly different in pixels. Naive truncation produces labels that sometimes have a gaping right margin and sometimes overflow their container β€” neither is acceptable.

The fix: binary search. πŸ”Ž

//+------------------------------------------------------------------+
//| Fit text to maximum pixel width with ellipsis                    |
//+------------------------------------------------------------------+
string Ai_FitTextToWidth(const string text, const string fontName, int fontSize, int maxWidthPx)
  {
   if(StringLen(text) == 0)  return "";
   if(maxWidthPx <= 0)       return "";

   //--- Fast path: full text already fits
   const int fullW = AiTextWidth(text, fontName, fontSize);
   if(fullW <= maxWidthPx) return text;

   //--- Edge case: even "..." is too wide
   const string ellipsis = "...";
   const int ellipsisW = AiTextWidth(ellipsis, fontName, fontSize);
   if(ellipsisW > maxWidthPx) return "";

   //--- Binary search: find the longest prefix that fits with "..." 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;
     }

   if(lo <= 0) return ellipsis;
   return StringSubstr(text, 0, lo) + ellipsis;
  }
The binary search converges in logβ‚‚(n) iterations β€” roughly 10 calls for a 1000-character string. The key variables lo and hi bracket the candidate prefix length. When the trial string (prefix + "...") fits within budget, lo advances to try for longer. When it overflows, hi retreats. The bracket collapses to the exact longest prefix that fits. Every single truncated label in the entire program β€” sidebar chat titles, search snippets, dropdown labels β€” now lands within one pixel of its budget regardless of character composition. It's a small thing that makes the whole dashboard look significantly more professional. ✨


Drawing Icons That Actually Mean Something

Webdings and Wingdings had to go. πŸ‘‹

Using font characters as icons means you're constrained by whatever glyphs the font designer decided to include, at whatever size they were drawn, with whatever proportions they chose. You get no control over stroke weight, semantic color, or pixel alignment. The toolbar for a trading dashboard should communicate intent instantly β€” the trend icon should look like a trendline, the level icon should look like a horizontal bar, the lightning bolt for Quick Scalp should actually spark energy.

So we drew all thirteen icons ourselves. Each one takes a canvas, a corner position, a size, and a color. Each uses AiThickLineAA, AiIconLine, and AiStrokeArcAA for their strokes. AiIconLine implements Xiaolin Wu's line algorithm β€” iterate along the major axis, plot two adjacent pixels with complementary fractional weights so lines stay smooth at any angle:

//+------------------------------------------------------------------+
//| Draw chevron arrow (used for expand/collapse indicators)         |
//+------------------------------------------------------------------+
void AiDrawChevron(CCanvas &canvas, int cx, int cy, bool pointUp, uint argb)
  {
   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);
     }
  }

//+------------------------------------------------------------------+
//| Anti-aliased line using Xiaolin Wu algorithm                     |
//+------------------------------------------------------------------+
void AiIconLine(CCanvas &canvas, double x0, double y0, double x1, double y1, uint argb)
  {
   double dxL = x1 - x0, dyL = y1 - y0;
   bool steep = MathAbs(dyL) > MathAbs(dxL);

   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;
        }
     }
   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;
        }
     }
  }

//--- ALL OTHER ICONS FOLLOW THE SAME APPROACH
Each of the thirteen icon drawers gets a semantic color β€” the trend icon draws in the trend accent color, the key level icon draws in the level accent color, the clear icon draws in a muted warning red. When the user glances at the toolbar, the colors reinforce the meaning before the label even registers. That's icon design that earns its keep. 🎯


Rendering Markdown in Chat Bubbles

The AI loves to respond with **bold** text for signal names and *italic* for emphasis. In the previous article, asterisks rendered as literal asterisks in the chat bubble. That's fixed now.

The inline markdown parser walks a line character by character, accumulates plain text in a buffer, and emits a new styled run whenever it hits an asterisk sequence:

//+------------------------------------------------------------------+
//| Styled text run (one segment of consistent bold/italic state)    |
//+------------------------------------------------------------------+
struct AiMdRun
  {
   string text;
   bool   bold;
   bool   italic;
  };

//+------------------------------------------------------------------+
//| Parse markdown line into sequence of styled runs                 |
//+------------------------------------------------------------------+
void AiMdParseInline(const string txt, AiMdRun &runs[])
  {
   ArrayResize(runs, 0);
   const int len = StringLen(txt);
   if(len == 0) return;

   bool curBold = false, curItalic = false;
   string buf = "";

   #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 = ""; \
      } \
   }

   int i = 0;
   while(i < len)
     {
      const ushort ch = StringGetCharacter(txt, i);
      if(ch == '*')
        {
         if(i + 2 < len
            && StringGetCharacter(txt, i+1) == '*'
            && StringGetCharacter(txt, i+2) == '*')
           { AI_MD_FLUSH(); curBold=!curBold; curItalic=!curItalic; i+=3; continue; }
         if(i + 1 < len && StringGetCharacter(txt, i+1) == '*')
           { AI_MD_FLUSH(); curBold=!curBold; i+=2; continue; }
         AI_MD_FLUSH(); curItalic=!curItalic; i++; continue;
        }
      buf += StringSubstr(txt, i, 1);
      i++;
     }
   AI_MD_FLUSH();
   #undef AI_MD_FLUSH
  }

//+------------------------------------------------------------------+
//| Resolve font name for a run's style flags                        |
//+------------------------------------------------------------------+
string AiMdRunFont(const AiMdRun &r)
  {
   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 a run sequence                      |
//+------------------------------------------------------------------+
int AiMdRunsWidth(const AiMdRun &runs[], int fontSize)
  {
   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 runs onto canvas side by side                       |
//+------------------------------------------------------------------+
void AiMdStampRuns(CCanvas &canvas, int x, int y,
                    const AiMdRun &runs[], int fontSize, color textCol)
  {
   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);
     }
  }

//+------------------------------------------------------------------+
//| Track style state at end of line (for multi-line wrap continuity)|
//+------------------------------------------------------------------+
void AiMdComputeEndState(const string txt, bool &openBold, bool &openItalic)
  {
   const int len = StringLen(txt);
   if(len == 0) return;
   int i = 0;
   while(i < len)
     {
      const ushort ch = StringGetCharacter(txt, i);
      if(ch == '*')
        {
         if(i+2 < len && StringGetCharacter(txt,i+1)=='*' && StringGetCharacter(txt,i+2)=='*')
           { openBold=!openBold; openItalic=!openItalic; i+=3; continue; }
         if(i+1 < len && StringGetCharacter(txt,i+1)=='*')
           { openBold=!openBold; i+=2; continue; }
         openItalic=!openItalic; i++; continue;
        }
      i++;
     }
  }

//+------------------------------------------------------------------+
//| Return the marker prefix to reopen styles on continuation lines  |
//+------------------------------------------------------------------+
string AiMdReopenMarkers(const bool openBold, const bool openItalic)
  {
   if(openBold && openItalic) return "***";
   if(openBold)               return "**";
   if(openItalic)             return "*";
   return "";
  }

The AI_MD_FLUSH macro emits whatever is in the accumulation buffer as a new run before changing style state. The order of asterisk checks matters β€” *** must be tested before **, which must be tested before *, or shorter sequences incorrectly match the start of longer ones.

AiMdComputeEndState tracks which styles are still open at the end of a line. This exists for paragraph wrapping: when **bold text opens on visual line 1 and closes on visual line 2, the renderer needs to know to start line 2 already in bold state. AiMdReopenMarkers converts that end-state back into the opening marker sequence to inject at the start of the continuation. Wrapped bold text stays bold. Wrapped italic text stays italic. The chat bubbles look like they were designed by an adult. πŸ‘¨β€πŸ’Ό


3. πŸ—‚οΈ The State Layer β€” Where All the Data Lives

One Header, Every Global

The previous article scattered globals across multiple files and kept finding new places to add them as features grew. This article consolidates everything β€” the dispatch tables, toast state, drag state, popup flags, editor state, scroll positions β€” into one dedicated state header. Any module that needs to read or write program state includes this single file. No surprises, no circular dependency hunting, no "where was that flag declared again?" archaeology sessions. 🏺

The most interesting new additions are the dispatch tables, which turn the action system from code into data:

//+------------------------------------------------------------------+
//|                                              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"

#ifndef AI_CANVAS_STATE_MQH
#define AI_CANVAS_STATE_MQH

#include "AI Canvas Theme.mqh"

#define AI_CANVAS_NAME_MAIN    "ChatGPT_AI_MainCanvas"
#define AI_CANVAS_NAME_PROMPT  "ChatGPT_AI_PromptCanvas"

//+------------------------------------------------------------------+
//| Chat Record                                                      |
//+------------------------------------------------------------------+
struct Chat
  {
   int    id;
   string title;
   string history;
  };

//+------------------------------------------------------------------+
//| Chat State                                                       |
//+------------------------------------------------------------------+
Chat   g_ai_chats[];
int    g_ai_currentChatId        = -1;
string g_ai_currentTitle         = "";
string g_ai_conversationHistory  = "";
string g_ai_currentPrompt        = "";

//+------------------------------------------------------------------+
//| UI Visibility                                                    |
//+------------------------------------------------------------------+
bool g_ai_sidebarExpanded  = true;
bool g_ai_dashboardVisible = true;

//+------------------------------------------------------------------+
//| Popup Flags                                                      |
//+------------------------------------------------------------------+
bool   g_ai_showSmallHistory  = false;
bool   g_ai_showBigHistory    = false;
bool   g_ai_showSearch        = false;
bool   g_ai_justOpenedSmall   = false;
bool   g_ai_justOpenedBig     = false;
bool   g_ai_justOpenedSearch  = false;
string g_ai_searchQuery       = "";

//+------------------------------------------------------------------+
//| Drag State                                                       |
//+------------------------------------------------------------------+
bool g_ai_dragging    = false;
int  g_ai_dragOffsetX = 0;
int  g_ai_dragOffsetY = 0;

//+------------------------------------------------------------------+
//| Dashboard Position                                               |
//+------------------------------------------------------------------+
int AI_DASHBOARD_X = AI_DASHBOARD_X_DEFAULT;
int AI_DASHBOARD_Y = AI_DASHBOARD_Y_DEFAULT;

//+------------------------------------------------------------------+
//| Footer Dropdown State                                            |
//+------------------------------------------------------------------+
bool g_ai_showFooterDropdown       = false;
int  g_ai_footerDropdownSelectedIdx = 0;

//+------------------------------------------------------------------+
//| Action Dispatch Tables                                           |
//+------------------------------------------------------------------+
const int    AI_FOOTER_DD_COUNT    = 7;
const string AI_FOOTER_DD_LABELS[] = {
   "Get Chart Data",
   "Twin Bars",
   "Quick Scalp",
   "Daily Signal",
   "Trend Read",
   "Key Level",
   "Clear Drawings"
};
const int AI_FOOTER_DD_ACTION_IDS[] = { 0, 1, 2, 3, 4, 5, 6 };
const int AI_FOOTER_DD_ICONS[]      = { 0, 1, 2, 3, 4, 5, 6 };

//+------------------------------------------------------------------+
//| API Throttle                                                     |
//+------------------------------------------------------------------+
bool g_ai_signalRequestInFlight = false;

//+------------------------------------------------------------------+
//| Animation State                                                  |
//+------------------------------------------------------------------+
datetime g_ai_lastBarTime  = 0;
int      g_ai_spinnerCycle = 0;

//+------------------------------------------------------------------+
//| Toast Notification                                               |
//+------------------------------------------------------------------+
string g_ai_toastText     = "";
bool   g_ai_toastIsError  = false;
ulong  g_ai_toastExpiryMs = 0;

//+------------------------------------------------------------------+
//| Hover and Input State                                            |
//+------------------------------------------------------------------+
bool g_ai_overPencilIcon           = false;
bool g_ai_lastRenderedSendDisabled = true;

//--- CHAT DECODE AND ENCODE LOGIC REMAINS AS BEFORE

#endif // AI_CANVAS_STATE_MQH

Three arrays β€” AI_FOOTER_DD_LABELS, AI_FOOTER_DD_ACTION_IDS, AI_FOOTER_DD_ICONS β€” and the entire seven-button dispatch system is data-driven. The UI render loop iterates these arrays to draw buttons. The dropdown renders from them. The dispatcher uses the ID. Change a label in AI_FOOTER_DD_LABELS and it updates everywhere simultaneously β€” button label, dropdown item, tooltip. That's the payoff for keeping data and behavior separated.

g_ai_signalRequestInFlight deserves a mention. It's one boolean that prevents parallel API calls. Without it, an impatient user who triple-clicks Twin Bars fires three simultaneous HTTP requests, gets three responses back in unpredictable order, and potentially places three orders on what was supposed to be one signal. The throttle flag sets on request start and clears on response β€” second click while first is in flight does nothing. Simple, effective, and the kind of guard that separates "seems to work" from "won't blow up in production." πŸ›‘οΈ

Scrollbars That Actually Reuse Code

In the previous article, the scroll logic for the chat pane, the big history popup, and the search popup were three separate but nearly identical sets of globals and math. Change the thumb sizing formula in one place and forget to change it in the others. Classic. Here, all of it consolidates into one struct and four helper functions:

//+------------------------------------------------------------------+
//|                                          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"

#ifndef AI_CANVAS_SCROLLBAR_MQH
#define AI_CANVAS_SCROLLBAR_MQH

#include "AI Canvas Theme.mqh"
#include "AI Canvas Primitives.mqh"

//+------------------------------------------------------------------+
//| Everything one scrollbar needs to know about itself              |
//+------------------------------------------------------------------+
struct AiScrollState
  {
   int  trackL, trackT, trackR, trackB;   // Track bounds
   int  scrollPx;                          // Current scroll offset in pixels
   int  viewportH;                         // Visible region height
   int  totalH;                            // Total content height
   bool hoveredArea;                       // Mouse inside track area
   bool hoveredThumb;                      // Mouse over thumb
   bool dragging;                          // Thumb drag in progress
   bool hover;
   int  dragOriginPx;                      // Scroll position when drag started
   int  dragOriginY;                       // Mouse Y when drag started
  };

void AiScrollInit(AiScrollState &s)
  {
   s.trackL=0; s.trackT=0; s.trackR=0; s.trackB=0;
   s.scrollPx=0; s.viewportH=0; s.totalH=0;
   s.hoveredArea=false; s.hoveredThumb=false;
   s.hover=false; s.dragging=false;
   s.dragOriginPx=0; s.dragOriginY=0;
  }

int AiScrollMax(const AiScrollState &s)
  {
   int m = s.totalH - s.viewportH;
   return (m < 0) ? 0 : m;
  }

bool AiScrollVisible(const AiScrollState &s)
  {
   return AiScrollMax(s) > 0;
  }

void AiScrollClamp(AiScrollState &s)
  {
   int m = AiScrollMax(s);
   if(s.scrollPx < 0) s.scrollPx = 0;
   if(s.scrollPx > m) s.scrollPx = m;
  }

//--- REMAINING SCROLLBAR DRAW AND HIT-TEST LOGIC FOLLOWS THE SAME APPROACH

#endif // AI_CANVAS_SCROLLBAR_MQH
One AiScrollState instance per scrollable region. Pass whichever instance you need to the draw function, the hit-test function, the wheel handler. Change the thumb sizing logic once β€” it changes for all three regions. This is the scrollbar. It's not exciting. It doesn't need to be. βœ…

4. ⌨️ A Real Text Editor, Finally

If the previous article's OBJ_EDIT field was a toy kitchen, the new CAiEditor class is a professional stove. You didn't see what you were typing until Enter. You couldn't add a newline. You couldn't click in the middle of your prompt to fix a typo. You were flying blind every time you composed a message.

The new editor owns its buffer entirely. It renders its own caret (with blink animation). It wraps text across lines. It handles selections, Shift+arrow extensions, Home/End, Backspace, Delete, click-to-position, and Ctrl shortcuts. It renders onto the canvas directly. Here's the class declaration:

//+------------------------------------------------------------------+
//|                                             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"

#ifndef AI_CANVAS_EDITOR_MQH
#define AI_CANVAS_EDITOR_MQH

#include <Canvas/Canvas.mqh>
#include "AI Canvas Theme.mqh"
#include "AI Canvas Primitives.mqh"
#include "AI Canvas Scrollbar.mqh"

//+------------------------------------------------------------------+
//| Multi-line scrollable canvas text editor                         |
//+------------------------------------------------------------------+
class CAiEditor
  {
public:
   string         buffer;
   int            caret;
   int            anchor;         // Selection anchor (-1 = no selection)
   bool           focused;
   string         fontName;
   int            fontSize;
   int            padX, padY;
   int            wrapWidth;
   int            lineH;
   AiScrollState  scroll;
   string         visLines[];    // Visual lines after word-wrap
   int            bufOffsets[];  // Buffer char offset per visual line
   bool           blinkOn;
   ulong          lastBlinkMs;
   string         placeholder;
   CAiCanvasFast  tmpCanvas;
   bool           tmpCanvasReady;
   int            tmpCanvasW, tmpCanvasH;
   string         tmpCanvasName;

                     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);
  };

The class has one instance per editor in the program: g_ai_editor for the prompt pane, g_ai_searchEditor for the search popup query field. The static instance counter in the constructor generates unique canvas names for each one β€” because two editors sharing a canvas name would corrupt each other's off-screen blending buffers. That's the kind of bug that takes four hours to diagnose. πŸ›

Building the Editor: Construction Through Mutation

CAiEditor::CAiEditor()
  {
   buffer = ""; caret = 0; anchor = -1; focused = false;
   fontName = "Arial"; fontSize = AI_FONT_BODY;
   padX = 8; padY = 6; wrapWidth = 200; lineH = 16;
   AiScrollInit(scroll);
   blinkOn = true; lastBlinkMs = 0; placeholder = "";
   tmpCanvasReady = false; tmpCanvasW = 0; tmpCanvasH = 0;
   static int s_editorInstanceCounter = 0;
   s_editorInstanceCounter++;
   tmpCanvasName = "AiEditorTmpPersistent_" + IntegerToString(s_editorInstanceCounter);
  }

void CAiEditor::Init(const string fnt, int fsz, int padInX, int padInY)
  {
   fontName = fnt; fontSize = fsz; padX = padInX; padY = padInY;
   lineH = AiTextHeight(fnt, fsz) + 2;
  }

void CAiEditor::SetWrapWidth(int w)
  {
   if(w == wrapWidth) return;
   wrapWidth = w;
   Rebuild();
  }

void CAiEditor::SetText(const string s)
  {
   buffer = s; caret = StringLen(buffer); anchor = -1;
   Rebuild();
  }

bool CAiEditor::HasSelection()
  {
   if(anchor < 0) return false;
   if(anchor == caret) return false;
   return true;
  }

bool CAiEditor::GetSelectionRange(int &s, int &e)
  {
   if(!HasSelection()) { s = e = 0; return false; }
   s = (anchor < caret) ? anchor : caret;
   e = (anchor > caret) ? anchor : caret;
   const int len = StringLen(buffer);
   if(s < 0) s = 0;
   if(e > len) e = len;
   if(s > e) s = e;
   return e > s;
  }

bool CAiEditor::DeleteSelection()
  {
   int s, e;
   if(!GetSelectionRange(s, e)) return false;
   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;
   caret = s; anchor = -1;
   Rebuild();
   return true;
  }

void CAiEditor::SelectAll()
  {
   const int len = StringLen(buffer);
   if(len <= 0) { anchor = -1; caret = 0; return; }
   anchor = 0; caret = len;
  }

void CAiEditor::InsertChar(const string ch)
  {
   if(StringLen(ch) == 0) return;
   DeleteSelection();
   int len = StringLen(buffer);
   if(caret < 0) caret = 0;
   if(caret > len) caret = len;
   string before = StringSubstr(buffer, 0, caret);
   string after  = StringSubstr(buffer, caret, len - caret);
   buffer = before + ch + after;
   caret += StringLen(ch);
   Rebuild();
   EnsureCaretVisible();
  }

void CAiEditor::Backspace()
  {
   if(DeleteSelection()) { EnsureCaretVisible(); return; }
   if(caret <= 0) return;
   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();
  }

void CAiEditor::DeleteChar()
  {
   if(DeleteSelection()) { EnsureCaretVisible(); return; }
   int len = StringLen(buffer);
   if(caret >= len) return;
   string before = StringSubstr(buffer, 0, caret);
   string after  = StringSubstr(buffer, caret + 1, len - caret - 1);
   buffer = before + after;
   Rebuild();
   EnsureCaretVisible();
  }

The principle baked into every mutation method is selection-first. InsertChar calls DeleteSelection() before inserting β€” so typing while text is highlighted replaces the highlighted text, exactly like every editor you've ever used. Backspace does the same. DeleteChar does the same. This single rule handles the "delete selected text" interaction for free in all three mutation paths without any special-casing.

EnsureCaretVisible runs after every mutation and navigation to scroll the viewport so the caret stays visible. This prevents the frustrating experience of typing and watching your cursor disappear off the bottom of the prompt pane. The scroll position tracks the caret, not the other way around.

The Keyboard Handler: Virtual Keys to Editor Actions

MetaTrader delivers keyboard events as virtual key codes in CHARTEVENT_KEYDOWN. Here's the full dispatcher:

bool CAiEditor::HandleKeydown(int vk, bool shift, bool ctrl)
  {
   if(!focused) return false;

   //--- Ignore modifier-only key events (Shift, Ctrl, Alt, CapsLock, etc.)
   if(vk == 16 || vk == 17 || vk == 18 || vk == 20 ||
      vk == 144 || vk == 145 || vk == 91 || vk == 92 || vk == 93) return false;

   //--- Shift+navigation extends the selection
   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; }
     }

   //--- Plain navigation collapses the selection and moves
   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; }

   //--- Backspace (8), Delete (46)
   if(vk == 8)  { Backspace();  return true; }
   if(vk == 46) { DeleteChar(); return true; }

   //--- Shift+Enter inserts a newline (plain Enter is handled at interact level as Send)
   if(vk == 13 && shift) { InsertNewline(); return true; }

   //--- Filter to printable key ranges
   bool isPrintableVk = (vk == 32) ||
                         (vk >= 48 && vk <= 57)  ||   // digit row
                         (vk >= 65 && vk <= 90)  ||   // letter row
                         (vk >= 96 && vk <= 111) ||   // numpad
                         (vk >= 186 && vk <= 223);     // OEM punctuation
   if(!isPrintableVk) return false;

   //--- Translate VK to actual typed character respecting keyboard layout
   short uch = TranslateKey(vk);
   if(uch <= 0) return false;
   InsertChar(ShortToString((ushort)uch));
   return true;
  }

The plain Enter (vk == 13) without Shift is conspicuously absent here β€” it's intentionally intercepted higher up in the interact layer and routed to "send the prompt." Shift+Enter is the user's newline inside a multi-line message. This is the behavior every modern messaging app uses and users expect it without being told. πŸ’¬

TranslateKey is MetaTrader's built-in keyboard-layout-aware translator. It consults the OS keyboard layout and current modifier state and returns the Unicode character the user actually pressed. This is what makes the editor work correctly on QWERTY, AZERTY, QWERTZ, and Cyrillic layouts without any extra code on our side. The OS does the heavy lifting.


5. πŸŽ›οΈ The Dashboard Render Layer

The entire dashboard is a single canvas bitmap. Not a collection of OBJ_LABEL objects repositioned on each refresh. Not a mix of chart objects and canvas regions. One canvas, redrawn completely on every state change. This gives us total layout control, pixel-perfect positioning, and zero flicker from object repositioning.


Toast Notifications With a Countdown Bar

After clearing a chat, deleting a history entry, or wiping chart drawings, users need feedback. Nobody should stare at a dashboard after a destructive action wondering if anything happened. The toast system solves this with a 5-second timed banner:

void Ai_ShowToast(string text, bool isError)
  {
   g_ai_toastText     = text;
   g_ai_toastIsError  = isError;
   g_ai_toastExpiryMs = GetTickCount64() + 5000;
  }

void Ai_RenderToast()
  {
   if(StringLen(g_ai_toastText) == 0) return;
   const ulong now = GetTickCount64();
   if(now > g_ai_toastExpiryMs) return;

   const int padX = 16, padY = 8;
   const string toastFont = "Arial Bold";
   const int    toastSize = 10;

   const int textW = AiTextWidth(g_ai_toastText, toastFont, toastSize);
   const int textH = AiTextHeight(toastFont, toastSize);

   const int barH   = 2;
   const int barGap = 6;
   const int boxW   = textW + 2 * padX;
   const int boxH   = textH + barGap + barH + 2 * padY;

   //--- Center 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;

   g_ai_prim.FillRoundRectSharp(g_ai_canvMain, boxL, boxT, boxW, boxH, 8,
                                  ColorToARGB(g_ai_toastBg, 255));
   g_ai_prim.DrawRoundRectBorderSharp(g_ai_canvMain, boxL, boxT, boxW, boxH, 8, 1,
                                       ColorToARGB(g_ai_toastBorder, 255));

   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);

   //--- Progress bar: shrinks from center as time runs out
   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 totalLife = 5000;
   const long  remaining = (long)g_ai_toastExpiryMs - (long)now;
   double ratio = (double)remaining / (double)totalLife;
   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;

   //--- Gray track
   g_ai_canvMain.FillRectangle(trackL, trackY, trackR - 1, trackY + barH - 1,
                                  ColorToARGB(g_ai_toastBorder, 255));
   //--- Colored fill that shrinks toward center
   if(fillW > 0)
      g_ai_canvMain.FillRectangle(fillL, trackY, fillL + fillW - 1, trackY + barH - 1,
                                     ColorToARGB(textCol, 255));
  }
The progress bar shrinks symmetrically from both ends toward the center as the 5 seconds elapse β€” fillL = trackL + (trackW - fillW) / 2 keeps the fill centered on the track. The result is a visual countdown that communicates time-remaining intuitively, without the user having to read a number. Green for success, red for error. The 500ms timer driving the caret blink also triggers a ChartRedraw() on every tick, which keeps the progress bar animating smoothly. ⏳


The Search Popup: Filter Cache and Clipped Rendering

The search popup combines two performance techniques that are worth examining specifically.

The filter and lowercase cache exists because doing StringToLower on every chat title and history on every keystroke, then running StringFind against all of them on every keystroke, is genuinely wasteful for users with large chat histories. We maintain a fingerprint β€” (chat count, title length sum, history length sum) β€” and only rebuild the lowercase cache when that fingerprint changes. We also cap cached history to 4096 characters per chat since hits in the first four kilobytes are sufficient for surface-level search matching.

void Ai_RenderPopup(int anchorIdx)
  {
   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;

   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));
     }

   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);

   const int rowH       = 44;
   const int titleArea  = (anchorIdx == 0) ? 36 : 28;
   const int searchArea = (anchorIdx == 0) ? 38 : 0;

   if(anchorIdx == 0)
     {
      int siL=pL+16, siT=pT+titleArea, siR=pR-16, siB=siT+searchArea-6;
      g_ai_popupSearchL=siL; g_ai_popupSearchT=siT;
      g_ai_popupSearchR=siR; g_ai_popupSearchB=siB;
      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));
      const int boxH  = siB - siT;
      const int lineH = AiTextHeightCached("Arial", AI_FONT_BODY) + 2;
      const int edL=siL+8, edT=siT+(boxH-lineH)/2-g_ai_searchEditor.padY;
      const int edR=siR-4, edB=edT+lineH+2*g_ai_searchEditor.padY;
      g_ai_searchEditor.Render(g_ai_canvMain, edL, edT, edR, edB, g_ai_prim);
     }
   else
      g_ai_popupSearchL=g_ai_popupSearchT=g_ai_popupSearchR=g_ai_popupSearchB=0;

   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); }

   #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[];

   int curTitleLenSum=0, 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);

   if(!chatsCacheValid)
     {
      ArrayResize(s_cacheLowerTitles, total);
      ArrayResize(s_cacheLowerHistories, total);
      for(int li=0; li<total; li++)
        {
         string tL=g_ai_chats[li].title; StringToLower(tL);
         string hRaw=g_ai_chats[li].history;
         if(StringLen(hRaw)>AI_SEARCH_HISTORY_CAP) hRaw=StringSubstr(hRaw,0,AI_SEARCH_HISTORY_CAP);
         StringToLower(hRaw);
         s_cacheLowerTitles[li]=tL; s_cacheLowerHistories[li]=hRaw;
        }
      s_cacheChatCount=total; s_cacheTitleLenSum=curTitleLenSum;
      s_cacheHistoryLenSum=curHistoryLenSum; s_cacheFilterQuery="\x01";
     }

   if(anchorIdx==0 && chatsCacheValid && s_cacheFilterQuery==g_ai_searchQuery)
     {
      const int cn=ArraySize(s_cacheFilterIdx);
      ArrayResize(filteredIdx,cn);
      for(int ci=0;ci<cn;ci++) filteredIdx[ci]=s_cacheFilterIdx[ci];
     }
   else
     {
      for(int i=total-1; i>=0; i--)
        {
         if(StringLen(queryLower)>0)
           {
            if(StringFind(s_cacheLowerTitles[i], queryLower)>=0) { /* title hit */ }
            else if(StringFind(s_cacheLowerHistories[i], queryLower)<0) continue;
           }
         int sz=ArraySize(filteredIdx); ArrayResize(filteredIdx,sz+1); filteredIdx[sz]=i;
        }
      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;
        }
     }

   const int rowsTop     = pT + titleArea + searchArea;
   const int rowsBot     = pB - 8;
   const int viewportH   = MathMax(0, rowsBot - rowsTop);
   const int totalFiltered = ArraySize(filteredIdx);
   bool useScrollbar = false;
   int  visStart=0, visCount=0;
   const int sbW=4;

   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);
      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
     { visStart=0; visCount=MathMin(totalFiltered,8); }

   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);

   const bool useClipCanvas = (anchorIdx==0 && visCount>0);
   if(useClipCanvas)
     {
      const int needW=g_ai_canvMain.Width(), 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; }
        }
     }

   if(useClipCanvas && g_ai_canvSearchTmpReady)
      g_ai_canvSearchTmp.CopyRectFromCanvas(g_ai_canvMain, pL, rowsTop, pR, rowsBot);

   for(int v=0; v<visCount; v++)
     {
      const int filterPos = visStart + v;
      const int chatIdx   = filteredIdx[filterPos];
      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, rR = pR - 10;
      const int hitT = MathMax(rT,rowsTop), 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;
      if(hitB<=hitT) continue;

      bool hov    = (g_ai_popupHovRow==v);
      bool hovDel = (hov && g_ai_popupHovDel);
      color bg    = hov ? g_ai_chatItemBgHover : g_ai_chatItemBg;

      if(useClipCanvas && g_ai_canvSearchTmpReady)
        {
         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));
         const int hpDelW  = AiTextWidth(AI_GLYPH_DELETE,"Wingdings 2",14);
         const int hpAvailW = (rR-rL) - 24 - hpDelW;
         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);
         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);
         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
        {
         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));
         const int hp2DelW  = AiTextWidth(AI_GLYPH_DELETE,"Wingdings 2",14);
         const int hp2AvailW = (rR-rL) - 24 - hp2DelW;
         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);
         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);
           }
        }
     }

   if(useClipCanvas && g_ai_canvSearchTmpReady)
      g_ai_canvSearchTmp.CopyRectToCanvas(g_ai_canvMain, pL, rowsTop, pR, rowsBot);

   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);
     }
  }
The clipped rendering uses the search scratch canvas g_ai_canvSearchTmp. Rows that scroll partially outside the popup's top or bottom edge need to be clipped cleanly β€” without this, a row that's half-scrolled off the top of the popup would paint onto whatever UI elements sit above it. The solution: copy the main canvas pixels for the row band into the scratch canvas, render all rows onto the scratch canvas (which lets rows bleed freely beyond the popup bounds within the scratch canvas's own coordinate space), then copy only the clipped band back to the main canvas. Rows beyond the edge simply don't get copied back. Clean, fast, and the scratch canvas is allocated once and reused forever. ♻️


6. 🧠 The AI Logic Layer β€” Making the AI Speak Trade

Teaching the AI a Strict Protocol

Every signal action opens with the same two-part preamble. Part one defines what the words mean. Part two defines how the data is structured. Together they close every ambiguity gap that caused wrong signals in earlier versions:

//+------------------------------------------------------------------+
//|                                                     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"

#ifndef AI_LOGIC_MQH
#define AI_LOGIC_MQH

#include <Trade/Trade.mqh>
#include "AI JSON FILE.mqh"
#include "AI Canvas State.mqh"
#include "AI Canvas Render.mqh"

#ifndef AI_COMPILED_FROM_MAIN
extern string OpenAI_Model;
extern string OpenAI_Endpoint;
extern int    MaxResponseLength;
extern string LogFileName;
extern int    MaxChartBars;
extern bool   DeleteLogsOnChatDelete;
extern bool   AutoTrade;
extern double LotSize;
extern string OpenAI_API_Key;
#endif

//+------------------------------------------------------------------+
//| Standard opening preamble shared by all signal prompts           |
//+------------------------------------------------------------------+
string Ai_BuildBarPreamble()
  {
   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 newest-first for AI prompt                         |
//+------------------------------------------------------------------+
string Ai_FormatBarsDesc(ENUM_TIMEFRAMES tf, int count)
  {
   MqlRates r[];
   if(CopyRates(Symbol(), tf, 1, count, r) < count) return "";
   ArraySetAsSeries(r, true);
   string out = "BAR DATA (Bar 1 = newest, Bar " + IntegerToString(count) + " = oldest):\n";
   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;
  }

//+------------------------------------------------------------------+
//| Extract a single value from a line-based KEY:VALUE response      |
//+------------------------------------------------------------------+
string Ai_ParseKVResponse(string raw, string key)
  {
   string lines[];
   int n = StringSplit(raw, '\n', lines);
   string keyUpper = key; StringToUpper(keyUpper);
   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 "";
  }

The direction tag on each bar row β€” "(this bar is BULLISH)" β€” looks redundant next to the OHLC values. It is redundant. Deliberately. When an AI model is summarizing directional counts across 30 or 50 bars, having the label precomputed inline in the data row reduces the cognitive load on the model and closes a class of miscounting errors that no amount of clever prompting otherwise prevents. We're paying a few extra tokens per request to make classification errors significantly less likely. That's a good trade. πŸ“Š

The response parser Ai_ParseKVResponse walks lines, splits on the first colon, uppercases and trims both sides, and returns the value on a match. It's case-insensitive on both sides. Stray commentary lines (any line without a colon, or with an unrecognized key) are completely ignored. The AI can write a four-paragraph preamble before its SIGNAL: BUY line and the parser will find it. This is fundamentally more robust than JSON parsing, which breaks on the first syntax error.


7. πŸ–±οΈ Wiring It All Together β€” Events, Keyboard, and Dispatch

Stealing the Keyboard Back From MetaTrader

Here's a problem that only reveals itself after you've built a custom canvas editor: MetaTrader owns the keyboard. Arrow keys scroll the chart. Letter keys can trigger quick navigation. Tab jumps between panels. The moment your editor has focus, these chart shortcuts are actively fighting against your user's typing.

The fix is a two-function keyboard override pair:

//+------------------------------------------------------------------+
//|                                           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"

#ifndef AI_CANVAS_INTERACT_MQH
#define AI_CANVAS_INTERACT_MQH

#include "AI Canvas State.mqh"
#include "AI Canvas Render.mqh"
#include "AI Canvas Editor.mqh"
#include "AI Logic.mqh"

bool g_ai_chatScrollDragging   = false;
bool g_ai_editorScrollDragging = false;
bool g_ai_searchScrollDragging = false;

void Ai_HideDashboard();
void Ai_RecomputeLayout();

bool g_ai_kbOverrideActive = false;
bool g_ai_savedKbControl   = true;
bool g_ai_savedQuickNav    = true;

void Ai_BeginKeyboardOverride()
  {
   if(g_ai_kbOverrideActive) return;
   g_ai_savedKbControl = (bool)ChartGetInteger(0, CHART_KEYBOARD_CONTROL);
   g_ai_savedQuickNav  = (bool)ChartGetInteger(0, CHART_QUICK_NAVIGATION);
   ChartSetInteger(0, CHART_KEYBOARD_CONTROL, false);
   ChartSetInteger(0, CHART_QUICK_NAVIGATION, false);
   g_ai_kbOverrideActive = true;
  }

void Ai_EndKeyboardOverride()
  {
   if(!g_ai_kbOverrideActive) return;
   ChartSetInteger(0, CHART_KEYBOARD_CONTROL, g_ai_savedKbControl);
   ChartSetInteger(0, CHART_QUICK_NAVIGATION, g_ai_savedQuickNav);
   g_ai_kbOverrideActive = false;
  }

Ai_BeginKeyboardOverride saves the current CHART_KEYBOARD_CONTROL and CHART_QUICK_NAVIGATION settings, then disables both. From this moment until Ai_EndKeyboardOverride is called, every keypress goes to our editor without the chart intercepting it. When the editor loses focus β€” user clicks outside it, closes the search popup, sends the message β€” Ai_EndKeyboardOverride restores the saved state. The chart scrolls normally again. Both functions are idempotent, so blur handlers don't need to track whether the override is currently active.

The Dispatch Table in Code Form

The seven-line switch below is the entire routing table for the program's trading intelligence:

void Ai_DispatchFooterAction(int actionId)
  {
   switch(actionId)
     {
      case 0: AiGetAndAppendChartData(); break;
      case 1: AiTwinBars();              break;
      case 2: AiGetTradeSignal(false);   break;
      case 3: AiDailySignal();           break;
      case 4: AiTrendRead();             break;
      case 5: AiKeyLevel();              break;
      case 6: AiClearSignalDrawings();   break;
      default:
         Print("Ai_DispatchFooterAction: unknown actionId=", actionId); break;
     }
  }

Every call site in the program β€” the split signal button, dropdown item clicks, future hotkeys, the auto-signal tick path β€” calls this function with an ID. The routing lives in exactly one place. If you ever rename a handler, you change it here.

The Central Action Router

Ai_HandleAction is the one function that handles every interactive element in the program. It takes a hover code and routes to the correct action. Each hover code maps to exactly one element, and each element has exactly one handler:

void Ai_HandleAction(int hov)
  {
   if(hov == AI_HOV_CLOSE)  { Ai_HideDashboard(); return; }

   if(hov == AI_HOV_THEME)
     {
      Ai_ApplyTheme(!g_ai_darkTheme);
      Ai_RenderAll(); ChartRedraw(); return;
     }

   if(hov == AI_HOV_TOGGLE)
     {
      g_ai_sidebarExpanded = !g_ai_sidebarExpanded;
      Ai_RecomputeLayout(); Ai_RenderAll(); ChartRedraw(); return;
     }

   if(hov == AI_HOV_NEW_CHAT)
     {
      g_ai_showSearch=false; g_ai_showSmallHistory=false;
      AiCreateNewChat(); return;
     }

   if(hov == AI_HOV_CLEAR)
     {
      if(StringLen(g_ai_conversationHistory)==0 && StringLen(g_ai_editor.GetText())==0)
        { Ai_RenderAll(); ChartRedraw(); return; }
      string titleSnap = g_ai_currentTitle;
      if(StringLen(titleSnap)>30) titleSnap=StringSubstr(titleSnap,0,27)+"...";
      g_ai_conversationHistory=""; g_ai_currentPrompt=""; g_ai_editor.SetText("");
      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;
     }

   if(hov == AI_HOV_HISTORY)
     {
      g_ai_showSmallHistory=!g_ai_showSmallHistory; g_ai_showSearch=false;
      Ai_RenderAll(); ChartRedraw(); return;
     }

   if(hov == AI_HOV_SEARCH)
     {
      g_ai_showSearch=!g_ai_showSearch; g_ai_showSmallHistory=false;
      if(g_ai_showSearch)
        {
         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
        {
         g_ai_searchEditor.focused=false;
         if(!g_ai_editor.focused) Ai_EndKeyboardOverride();
        }
      Ai_RenderAll(); ChartRedraw(); return;
     }

   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;
     }

   if(hov == AI_HOV_SIGNAL_DD)
     {
      g_ai_showFooterDropdown=!g_ai_showFooterDropdown;
      g_ai_showSearch=false; g_ai_showSmallHistory=false;
      Ai_RenderAll(); ChartRedraw(); return;
     }

   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;
     }

   if(hov == AI_HOV_SEND)
     {
      if(Ai_PromptIsEmpty()) return;
      string txt=g_ai_editor.GetText();
      g_ai_editor.SetText(""); g_ai_currentPrompt="";
      AiSubmitMessage(txt); return;
     }

   if(hov == AI_HOV_REGEN)
     {
      string lastPrompt=AiGetLastUserPrompt();
      if(StringLen(lastPrompt)>0)
        { AiRemoveLastConversationTurn(); AiSubmitMessage(lastPrompt); }
      return;
     }

   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;
     }

   if(hov == AI_HOV_SCROLL_FAB)
     {
      g_ai_chatScroll.scrollPx=AiScrollMax(g_ai_chatScroll);
      AiScrollClamp(g_ai_chatScroll);
      Ai_RenderAll(); ChartRedraw(); return;
     }

   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))
        {
         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;
         const string prompt=g_ai_userEditPrompt[peIdx];
         g_ai_editor.SetText(prompt);
         if(g_ai_searchEditor.focused) g_ai_searchEditor.focused=false;
         if(!g_ai_editor.focused) { g_ai_editor.focused=true; Ai_BeginKeyboardOverride(); }
         g_ai_currentPrompt=prompt;
         Ai_RenderAll(); ChartRedraw();
        }
      return;
     }

   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;
     }

   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)
        {
         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;
     }

   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;
     }

   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))
           {
            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;
     }
  }

The hover code system maps every interactive element to a unique integer. Singleton elements (close button, theme toggle, sidebar toggle) get individual codes 1–19. Variable-length lists (sidebar chat rows, popup rows, dropdown items, per-message edit pencils) get code ranges starting at 100, 200, 300, etc. To decode a list item: subtract the base, and you have the row index. This means the hit-test system handles lists of arbitrary length through the same mechanism as singleton buttons β€” no separate event handler infrastructure for each list. The entire interactive surface of the dashboard is a hover code. Everything routes here.

The Main Entry File

All ten module files assemble into one thin entry point:

//+------------------------------------------------------------------+
//|                                         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"

#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"

input group "=== OPENAI API SETTINGS ==="
input string OpenAI_Model     = "gpt-4o";
input string OpenAI_Endpoint  = "https://api.openai.com/v1/chat/completions";
input int    MaxResponseLength = 3000;

input group "=== LOGGING SETTINGS ==="
input string LogFileName            = "ChatGPT_EA_Log.txt";
input int    MaxChartBars           = 10;
input bool   DeleteLogsOnChatDelete = true;

input group "=== AUTO-TRADING SETTINGS ==="
input bool   AutoTrade        = true;
input double LotSize          = 0.01;
input int    MagicNumber      = 12345;
input bool   EnableAutoSignal = false;

string OpenAI_API_Key = "sk-proj-YOUR-API-KEY-HERE";

//--- Sentinel prevents type conflicts between input and extern declarations
#define AI_COMPILED_FROM_MAIN

#include "AI Canvas Theme.mqh"
#include "AI Canvas Primitives.mqh"
#include "AI JSON FILE.mqh"
#include "AI Canvas State.mqh"
#include "AI Canvas Scrollbar.mqh"
#include "AI Canvas Editor.mqh"
#include "AI Canvas Render.mqh"
#include "AI Logic.mqh"
#include "AI Canvas Interact.mqh"
#include "AI Canvas Shell.mqh"

int OnInit()
  {
   if(!Ai_Init()) return INIT_FAILED;
   EventSetMillisecondTimer(500);
   return INIT_SUCCEEDED;
  }

void OnDeinit(const int reason)
  {
   EventKillTimer();
   Ai_Deinit();
  }

void OnTick()                                                        { Ai_OnTick(); }
void OnChartEvent(const int id, const long &lp, const double &dp, const string &sp)
                                                                     { Ai_OnChartEvent(id,lp,dp,sp); }
void OnTimer()                                                       { Ai_OnTimer(); }
Five event handlers, all one-liners. Every module gets its own file. The AI_COMPILED_FROM_MAIN sentinel before the includes prevents the extern forward declarations in the headers from conflicting with the input declarations here β€” when a header is compiled standalone, the sentinel is undefined and the externs emit normally; when included from this file, the sentinel suppresses them. The 500ms timer drives the caret blink and the toast countdown bar. That's all this file does. Open it, change the API key and inputs, compile. Everything else is in the modules. 🎯


8. βœ… Backtesting

The program was compiled, attached to a live chart, and exercised against each of the seven actions in sequence. Results were unambiguous.

The split signal button dispatched correctly for every action β€” the action half fired the currently selected action, the chevron half toggled the dropdown, dropdown item selection updated the button label and fired the new action simultaneously. No routing errors in any of the seven paths.

All five AI-driven signal actions produced structured responses in the expected KEY:VALUE format. The bar preamble eliminated the bar-ordering confusion that caused occasional reversed signals in earlier versions. The line-based parser extracted all required fields correctly even when the model added explanatory prose above and below the keyed lines.

The auto-trade path placed market orders correctly for Twin Bars, Quick Scalp, and Daily Signal signals, and placed the correct pending order type for every Key Level signal β€” Buy Limit, Sell Stop, Sell Limit, and Buy Stop all confirmed at least once against the 2Γ—2 matrix mapping.

Chart drawings appeared at the correct bar positions with readable labels, persisted across render cycles, and cleared completely on a single Clear Drawings action. Chat history and signal turns persisted across chart reloads.


9. 🏁 What We Actually Built and Why It Matters

The previous article's chat interface was a dead end dressed up as progress. Beautiful UI, zero execution path, every signal stuck in prose that code couldn't touch.

What we have now is a complete pipeline. A button click triggers deterministic data collection. A constrained protocol forces the AI into machine-parseable output. A single parser extracts fields regardless of surrounding commentary. A unified order function handles all four pending order types and both market order directions through the same code path. Every decision renders as a labeled chart artifact. Every session is persisted and searchable.

The architectural principle that makes this extensible is the dispatch table. The seven actions that exist today are data β€” three parallel arrays of labels, IDs, and icon codes. The dispatcher is a switch over IDs. Adding an eighth action for the next version of this series β€” say, a multi-timeframe confluence check or a news event filter β€” means three new array entries and one new case. The UI renders the new button automatically. The hover system routes to it automatically. The interact layer dispatches it automatically. None of the existing code changes.

The line-based KEY:VALUE protocol is the other principle worth internalizing. Every time you find yourself asking the AI for JSON, ask whether a simpler line-based format would serve the same purpose. JSON is brittle under partial outputs, model verbosity, and markdown formatting. Key-value lines are not. "Survives the model adding a paragraph of commentary" is a real robustness requirement for production AI integrations, and line-based formats satisfy it where JSON does not.

The result is a reproducible, auditable, extensible AI-to-trade pipeline. Deploy it. Break it. Extend it. The architecture is designed to absorb what comes next. πŸš€


Attachments πŸ“Ž


 # File Type Description
 1AI Canvas Theme.mqhInclude fileLayout constants, font sizes, glyph codes, and the dark/light color palettes applied through a single theme switch
 2AI JSON FILE.mqhInclude fileJSON parser and serializer used by the encrypted chat persistence layer
 3AI Canvas Primitives.mqhInclude fileLow-level drawing primitives β€” rounded rectangles, anti-aliased text stamping, image scaling, custom semantic icons, and the inline markdown parser
 4AI Canvas State.mqhInclude fileProgram-wide state β€” chats array, current chat tracking, dashboard position/visibility flags, and the dispatch tables that map the seven action IDs to their labels and icons
 5AI Canvas Scrollbar.mqhInclude fileScrollbar state struct and helpers for thumb geometry, drag/wheel input, and track drawing
 6AI Canvas Editor.mqhInclude fileMulti-line text editor with caret blink, selection, word-wrap, click-to-position, and full keystroke editing
 7AI Canvas Render.mqhInclude fileFull dashboard renderer β€” header, sidebar, chat pane with markdown bubbles, prompt pane, footer, popups, and toast notifications
 8AI Logic.mqhInclude fileAI dispatch protocol, seven signal actions, unified order-placement function, chat-turn injection, and encrypted chat persistence
 9AI Canvas Interact.mqhInclude fileMouse and keyboard event routing via hover-code dispatching, keyboard override pair, and central action handler
 10AI Canvas Shell.mqhInclude fileProgram lifecycle β€” canvas creation/destruction, layout recomputation, new-bar auto-signal tick path, and show/hide pair
 11AI EA PART 9.mq5Expert AdvisorMain program entry point β€” embeds bitmap resources, declares user inputs, includes all ten module headers, forwards events to shell functions
 12AI EA BMP FILES.zipResource archiveBitmap files for the header logo, sidebar logo, and four sidebar action icons embedded at compile time


Disclaimer: The ideas and strategies presented in this resource are solely those of the author and are intended for informational and educational purposes only. They do not constitute financial advice, and past performance is not indicative of future results. All materials, including but not limited to text, images, files, and any downloadable content, are protected by copyright and intellectual property laws and are the exclusive property of Forex Algo-Trader or its licensors. Reproduction, distribution, modification, or commercial use of these materials without prior written consent from Forex Algo-Trader is strictly prohibited and may result in legal action. Users are advised to exercise extreme caution, perform thorough independent research, and consult with qualified financial professionals before implementing any trading strategies or decisions based on this resource, as trading in financial markets involves significant risk of loss.

Recent Comments

Go to discussion to Comment or View other Comments

No comments yet. Be the first to comment!

Banner
Grid Scalper MA MT5 EA Review (2026): The Forever-Free MetaTrader 5 Robot
Grid Scalper MA MT5 EA Review (2026): The forever-free MetaTrader 5 robot that's quietly earning a 4...
2026-05-14 19:35:18
Banner
Supercharging Your TPO Market Profile With Volume Intelligence
You've got a TPO market profile that tracks where price spent its time β€” now imagine giving it a f...
2026-04-03 16:05:42
Banner
Building a Hybrid Time Price Opportunity (TPO) Market Profile Indicator in MQL5
Master session-based price distribution analysis using Time Price Opportunity profiles β€” with full...
2026-03-24 18:01:11