| //+------------------------------------------------------------------+
|
| //| AI Logic.mqh |
|
| //| Copyright 2026, Allan Munene Mutiiria. |
|
| //| https://t.me/Forex_Algo_Trader |
|
| //+------------------------------------------------------------------+
|
| #property copyright "Copyright 2026, Allan Munene Mutiiria."
|
| #property link "https://t.me/Forex_Algo_Trader"
|
|
|
| //--- Include guard
|
| #ifndef AI_LOGIC_MQH
|
| #define AI_LOGIC_MQH
|
|
|
| //--- Include required libraries
|
| #include <Trade/Trade.mqh>
|
| #include "AI JSON FILE.mqh"
|
| #include "AI Canvas State.mqh"
|
| #include "AI Canvas Render.mqh"
|
|
|
| //+------------------------------------------------------------------+
|
| //| Forward Extern Declarations |
|
| //+------------------------------------------------------------------+
|
| #ifndef AI_COMPILED_FROM_MAIN
|
| extern string OpenAI_Model; // OpenAI model identifier
|
| extern string OpenAI_Endpoint; // OpenAI API endpoint URL
|
| extern int MaxResponseLength; // Maximum response token length
|
| extern string LogFileName; // Log file name
|
| extern int MaxChartBars; // Maximum chart bars to send
|
| extern bool DeleteLogsOnChatDelete; // Delete logs when chat is deleted
|
| extern bool AutoTrade; // Enable auto-trade execution
|
| extern double LotSize; // Trade lot size
|
| extern int MagicNumber; // Magic number identifying this EA's trades
|
| extern string OpenAI_API_Key; // OpenAI API key
|
| //--- Feature inputs for context and tools; vision uses the manual attach action
|
| extern bool Enable_AI_Context; // Attach live market context
|
| extern int Context_Candles; // Recent closed candles in context
|
| extern bool Enable_AI_Tools; // Let the AI call native tools
|
| extern int AI_Tool_Rounds; // Max tool-call rounds
|
| //--- Feature inputs for AI trade actions and their safety limits
|
| extern bool Enable_AI_Actions; // Let the AI place and manage trades
|
| extern bool Action_AutoApprove; // Execute AI actions without approval
|
| extern double Action_MaxLots; // Max lots per AI action
|
| extern int Action_MaxPositions; // Max open positions allowed for AI actions
|
| extern bool Action_ChartSymbolOnly; // Restrict AI actions to the chart symbol
|
| #endif
|
|
|
| //+------------------------------------------------------------------+
|
| //| Global Variables |
|
| //+------------------------------------------------------------------+
|
| CTrade g_ai_trade; // Trade object for order execution
|
| int g_ai_logFileHandle = INVALID_HANDLE; // Log file handle
|
| string g_ai_chatsFileName = "AI EA Data\\Chats\\ChatGPT_Chats.txt"; // Chats persistence path
|
|
|
| string AI_PREP_BASE = "AI: Preparing the Request"; // Pre-animation base text
|
| string AI_LOADING_PLACEHOLDER= "AI: Thinking..."; // Loading placeholder text
|
| int AI_PRE_ANIM_CYCLES = 6; // Pre-animation cycle count
|
| ulong g_ai_startTimeMs = 0; // Request start time tracker
|
|
|
| //+------------------------------------------------------------------+
|
| //| Convert timeframe enum to string |
|
| //+------------------------------------------------------------------+
|
| string AiPeriodToString(ENUM_TIMEFRAMES period)
|
| {
|
| //--- Map standard timeframes to short labels
|
| switch(period)
|
| {
|
| case PERIOD_M1:
|
| return "M1";
|
| case PERIOD_M5:
|
| return "M5";
|
| case PERIOD_M15:
|
| return "M15";
|
| case PERIOD_M30:
|
| return "M30";
|
| case PERIOD_H1:
|
| return "H1";
|
| case PERIOD_H4:
|
| return "H4";
|
| case PERIOD_D1:
|
| return "D1";
|
| case PERIOD_W1:
|
| return "W1";
|
| case PERIOD_MN1:
|
| return "MN1";
|
| default:
|
| return IntegerToString(period);
|
| }
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Build chart data string for AI prompt |
|
| //+------------------------------------------------------------------+
|
| string AiGetChartDataString()
|
| {
|
| //--- Get chart symbol and timeframe info
|
| string symbol = Symbol();
|
| ENUM_TIMEFRAMES tf = (ENUM_TIMEFRAMES)_Period;
|
| string timeframe = AiPeriodToString(tf);
|
| //--- Get visible bars on chart
|
| long visibleBarsLong = ChartGetInteger(0, CHART_VISIBLE_BARS);
|
| int visibleBars = (int)visibleBarsLong;
|
| //--- Copy recent rates
|
| MqlRates rates[];
|
| int copied = CopyRates(symbol, tf, 1, MaxChartBars, rates);
|
| //--- Bail on copy failure
|
| if(copied != MaxChartBars)
|
| {
|
| Print("Failed to copy rates: ", GetLastError());
|
| return "";
|
| }
|
| //--- Order newest first
|
| ArraySetAsSeries(rates, true);
|
| //--- Build header line
|
| string data = "Chart Details: Symbol=" + symbol + ", Timeframe=" + timeframe +
|
| ", Visible Bars=" + IntegerToString(visibleBars) + "\n";
|
| data += "Recent Bars Data (Bar 1 is latest COMPLETE bar):\n";
|
| //--- Append OHLC data per bar
|
| for(int i = 0; i < copied; i++)
|
| {
|
| data += "Bar " + IntegerToString(i + 1) + ": Date=" + TimeToString(rates[i].time, TIME_DATE | TIME_MINUTES) +
|
| ", Open=" + DoubleToString(rates[i].open, _Digits) +
|
| ", High=" + DoubleToString(rates[i].high, _Digits) +
|
| ", Low=" + DoubleToString(rates[i].low, _Digits) +
|
| ", Close=" + DoubleToString(rates[i].close, _Digits) +
|
| ", Volume="+ IntegerToString((int)rates[i].tick_volume) + "\n";
|
| }
|
| return data;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Escape string for JSON encoding |
|
| //+------------------------------------------------------------------+
|
| string AiJsonEscape(string value)
|
| {
|
| //--- Replace JSON-reserved characters
|
| StringReplace(value, "\\", "\\\\");
|
| StringReplace(value, "\"", "\\\"");
|
| StringReplace(value, "\n", "\\n");
|
| StringReplace(value, "\r", "\\r");
|
| StringReplace(value, "\t", "\\t");
|
| StringReplace(value, "\f", "\\f");
|
| //--- Escape remaining control characters via unicode hex
|
| for(int i = 0; i < StringLen(value); i++)
|
| {
|
| ushort charCode = StringGetCharacter(value, i);
|
| if(charCode < 32 || charCode == 127)
|
| {
|
| string hex = StringFormat("\\u%04x", charCode);
|
| string before = StringSubstr(value, 0, i);
|
| string after = StringSubstr(value, i + 1);
|
| value = before + hex + after;
|
| i += 5;
|
| }
|
| }
|
| return value;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Build OpenAI messages array from conversation history |
|
| //+------------------------------------------------------------------+
|
| string AiBuildMessagesFromHistory(string newPrompt, string systemPrefix = "", string finalUserContentJson = "")
|
| {
|
| //--- Split history into lines
|
| string lines[];
|
| int numLines = StringSplit(g_ai_conversationHistory, '\n', lines);
|
| //--- Initialize accumulator and current message state
|
| string messages = "[";
|
| //--- Optional leading system message (live market context)
|
| if(StringLen(systemPrefix) > 0)
|
| messages += "{\"role\":\"system\",\"content\":\"" + AiJsonEscape(systemPrefix) + "\"},";
|
| string currentRole = "";
|
| string currentContent = "";
|
| //--- Walk lines and group into role-tagged messages
|
| for(int i = 0; i < numLines; i++)
|
| {
|
| string line = lines[i];
|
| string trimmed = line;
|
| StringTrimLeft(trimmed);
|
| StringTrimRight(trimmed);
|
| //--- Skip blank and timestamp lines
|
| if(StringLen(trimmed) == 0 || AiIsTimestamp(trimmed))
|
| continue;
|
| //--- Skip the inline-image marker since the image is sent via the vision content array
|
| if(StringFind(trimmed, "[img:") == 0)
|
| continue;
|
| //--- Detect new user block
|
| if(StringFind(trimmed, "You: ") == 0)
|
| {
|
| if(currentRole != "")
|
| {
|
| string roleJson = (currentRole == "User") ? "user" : "assistant";
|
| messages += "{\"role\":\"" + roleJson + "\",\"content\":\"" + AiJsonEscape(currentContent) + "\"},";
|
| }
|
| currentRole = "User";
|
| currentContent = StringSubstr(line, StringFind(line, "You: ") + 5);
|
| }
|
| //--- Detect new AI block
|
| else
|
| if(StringFind(trimmed, "AI: ") == 0)
|
| {
|
| if(currentRole != "")
|
| {
|
| string roleJson = (currentRole == "User") ? "user" : "assistant";
|
| messages += "{\"role\":\"" + roleJson + "\",\"content\":\"" + AiJsonEscape(currentContent) + "\"},";
|
| }
|
| currentRole = "AI";
|
| currentContent = StringSubstr(line, StringFind(line, "AI: ") + 4);
|
| }
|
| //--- Continuation line of current block
|
| else
|
| if(currentRole != "")
|
| {
|
| currentContent += "\n" + line;
|
| }
|
| }
|
| //--- Flush trailing block
|
| if(currentRole != "")
|
| {
|
| string roleJson = (currentRole == "User") ? "user" : "assistant";
|
| messages += "{\"role\":\"" + roleJson + "\",\"content\":\"" + AiJsonEscape(currentContent) + "\"},";
|
| }
|
| //--- Append the user prompt, using the multimodal content array verbatim when supplied
|
| if(StringLen(finalUserContentJson) > 0)
|
| messages += "{\"role\":\"user\",\"content\":" + finalUserContentJson + "}]";
|
| else
|
| messages += "{\"role\":\"user\",\"content\":\"" + AiJsonEscape(newPrompt) + "\"}]";
|
| return messages;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Send request to ChatGPT and return response |
|
| //+------------------------------------------------------------------+
|
| string AiGetChatGPTResponse(string prompt, double temperature = 1.0)
|
| {
|
| //--- Build messages JSON from history plus new prompt
|
| string messages = AiBuildMessagesFromHistory(prompt);
|
| Print("Messages JSON: ", messages);
|
| if(g_ai_logFileHandle != INVALID_HANDLE)
|
| FileWrite(g_ai_logFileHandle, "Messages JSON: " + messages);
|
| //--- Build request body
|
| string requestData = "{\"model\":\"" + OpenAI_Model + "\",\"messages\":" + messages +
|
| ",\"max_tokens\":" + IntegerToString(MaxResponseLength) +
|
| ",\"temperature\":" + DoubleToString(temperature, 2) + "}";
|
| Print("Request data (temp=", DoubleToString(temperature, 2), "): ", requestData);
|
| if(g_ai_logFileHandle != INVALID_HANDLE)
|
| FileWrite(g_ai_logFileHandle, "Request data (temp=" + DoubleToString(temperature, 2) + "): " + requestData);
|
| //--- Convert request to char array
|
| char postData[];
|
| int dataLen = StringToCharArray(requestData, postData, 0, WHOLE_ARRAY, CP_UTF8);
|
| ArrayResize(postData, dataLen - 1);
|
| //--- Build HTTP headers
|
| string headers = "Authorization: Bearer " + OpenAI_API_Key + "\r\n" +
|
| "Content-Type: application/json; charset=UTF-8\r\n" +
|
| "Content-Length: " + IntegerToString(dataLen - 1) + "\r\n\r\n";
|
| //--- Issue web request
|
| char result[];
|
| string resultHeaders;
|
| int res = WebRequest("POST", OpenAI_Endpoint, headers, 10000, postData, result, resultHeaders);
|
| //--- Handle HTTP error
|
| if(res != 200)
|
| {
|
| string response = CharArrayToString(result, 0, WHOLE_ARRAY, CP_UTF8);
|
| string errMsg = "API request failed: HTTP Code " + IntegerToString(res) +
|
| ", Error: " + IntegerToString(GetLastError()) + ", Response: " + response;
|
| Print(errMsg);
|
| return errMsg;
|
| }
|
| //--- Decode response body
|
| string response = CharArrayToString(result, 0, WHOLE_ARRAY, CP_UTF8);
|
| Print("API response: ", response);
|
| if(g_ai_logFileHandle != INVALID_HANDLE)
|
| FileWrite(g_ai_logFileHandle, "API response: " + response);
|
| //--- Parse JSON response
|
| JsonValue jsonObject;
|
| int index = 0;
|
| char charArray[];
|
| int arrayLength = StringToCharArray(response, charArray, 0, WHOLE_ARRAY, CP_UTF8);
|
| if(!jsonObject.DeserializeFromArray(charArray, arrayLength, index))
|
| {
|
| string errMsg = "Error: Failed to parse API response JSON: " + response;
|
| Print(errMsg);
|
| return errMsg;
|
| }
|
| //--- Surface API error if present
|
| JsonValue *err = jsonObject.FindChildByKey("error");
|
| if(err != NULL)
|
| {
|
| string errMsg = "API Error: " + err["message"].ToString();
|
| Print(errMsg);
|
| return errMsg;
|
| }
|
| //--- Extract message content
|
| string content = jsonObject["choices"][0]["message"]["content"].ToString();
|
| if(StringLen(content) > 0)
|
| {
|
| StringReplace(content, "\\n", "\n");
|
| StringTrimLeft(content);
|
| StringTrimRight(content);
|
| return content;
|
| }
|
| //--- Fallback error
|
| string errMsg = "Error: No content in API response: " + response;
|
| Print(errMsg);
|
| return errMsg;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Live market context, chart vision and native tools |
|
| //+------------------------------------------------------------------+
|
|
|
| //+------------------------------------------------------------------+
|
| //| Get human-readable timeframe string |
|
| //+------------------------------------------------------------------+
|
| string AiTimeframeName(ENUM_TIMEFRAMES tf)
|
| {
|
| switch(tf)
|
| {
|
| case PERIOD_M1:
|
| return "M1";
|
| case PERIOD_M5:
|
| return "M5";
|
| case PERIOD_M15:
|
| return "M15";
|
| case PERIOD_M30:
|
| return "M30";
|
| case PERIOD_H1:
|
| return "H1";
|
| case PERIOD_H4:
|
| return "H4";
|
| case PERIOD_D1:
|
| return "D1";
|
| case PERIOD_W1:
|
| return "W1";
|
| case PERIOD_MN1:
|
| return "MN1";
|
| }
|
| return EnumToString(tf);
|
| }
|
|
|
| //--- Forward declaration so the context can state broker volume rules
|
| string AiFormatLots(const string sym, const double lots);
|
|
|
| //+------------------------------------------------------------------+
|
| //| Build the live market-context system message |
|
| //+------------------------------------------------------------------+
|
| string AiBuildMarketContext()
|
| {
|
| if(!Enable_AI_Context)
|
| return "";
|
| const string sym = Symbol();
|
| const ENUM_TIMEFRAMES tf = (ENUM_TIMEFRAMES)Period();
|
| const int digits = (int)SymbolInfoInteger(sym, SYMBOL_DIGITS);
|
| const double point = SymbolInfoDouble(sym, SYMBOL_POINT);
|
| const double bid = SymbolInfoDouble(sym, SYMBOL_BID);
|
| const double ask = SymbolInfoDouble(sym, SYMBOL_ASK);
|
| const double spreadPts = (point > 0.0) ? (ask - bid) / point : 0.0;
|
| string ctx = "LIVE MARKET CONTEXT (auto-attached, do not repeat back verbatim):\n";
|
| ctx += "Symbol: " + sym + " Timeframe: " + AiTimeframeName(tf) + "\n";
|
| ctx += "Server time: " + TimeToString(TimeCurrent(), TIME_DATE|TIME_MINUTES) + "\n";
|
| ctx += "Bid: " + DoubleToString(bid, digits) + " Ask: " + DoubleToString(ask, digits)
|
| + " Spread: " + DoubleToString(spreadPts, 1) + " pts\n";
|
| //--- State the volume rules and the action ceiling so neither is guessed
|
| ctx += "Volume: min " + AiFormatLots(sym, SymbolInfoDouble(sym, SYMBOL_VOLUME_MIN))
|
| + ", max " + AiFormatLots(sym, SymbolInfoDouble(sym, SYMBOL_VOLUME_MAX))
|
| + ", step " + AiFormatLots(sym, SymbolInfoDouble(sym, SYMBOL_VOLUME_STEP))
|
| + ", this EA allows at most " + AiFormatLots(sym, Action_MaxLots) + " per action\n";
|
| //--- State the quoting scale so point distances are never guessed
|
| const int ppp = (digits == 3 || digits == 5) ? 10 : 1;
|
| ctx += "Quoting: " + IntegerToString(digits) + " digits, 1 point = "
|
| + DoubleToString(point, digits) + ", 1 pip = " + IntegerToString(ppp) + " point(s)"
|
| + ", min stop distance " + IntegerToString((int)SymbolInfoInteger(sym, SYMBOL_TRADE_STOPS_LEVEL))
|
| + " points\n";
|
| int nbars = Context_Candles;
|
| if(nbars < 1)
|
| nbars = 1;
|
| if(nbars > 60)
|
| nbars = 60;
|
| MqlRates rates[];
|
| ArraySetAsSeries(rates, false);
|
| int copied = CopyRates(sym, tf, 1, nbars, rates);
|
| if(copied > 0)
|
| {
|
| ctx += "Recent " + IntegerToString(copied) + " closed candles (time O,H,L,C):\n";
|
| for(int i = 0; i < copied; i++)
|
| ctx += " " + TimeToString(rates[i].time, TIME_DATE|TIME_MINUTES) + " "
|
| + DoubleToString(rates[i].open, digits) + ","
|
| + DoubleToString(rates[i].high, digits) + ","
|
| + DoubleToString(rates[i].low, digits) + ","
|
| + DoubleToString(rates[i].close, digits) + "\n";
|
| }
|
| int totalPos = PositionsTotal();
|
| if(totalPos <= 0)
|
| ctx += "Open positions: none\n";
|
| else
|
| {
|
| ctx += "Open positions (" + IntegerToString(totalPos) + "):\n";
|
| for(int i = 0; i < totalPos; i++)
|
| {
|
| ulong ticket = PositionGetTicket(i);
|
| if(ticket == 0 || !PositionSelectByTicket(ticket))
|
| continue;
|
| const string psym = PositionGetString(POSITION_SYMBOL);
|
| const int pdig = (int)SymbolInfoInteger(psym, SYMBOL_DIGITS);
|
| ctx += " #" + IntegerToString((long)ticket) + " " + psym + " "
|
| + ((PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY) ? "BUY" : "SELL") + " "
|
| + DoubleToString(PositionGetDouble(POSITION_VOLUME), 2) + " lots @ "
|
| + DoubleToString(PositionGetDouble(POSITION_PRICE_OPEN), pdig)
|
| + " SL " + DoubleToString(PositionGetDouble(POSITION_SL), pdig)
|
| + " TP " + DoubleToString(PositionGetDouble(POSITION_TP), pdig)
|
| + " P/L " + DoubleToString(PositionGetDouble(POSITION_PROFIT), 2) + "\n";
|
| }
|
| }
|
| ctx += "Account: balance " + DoubleToString(AccountInfoDouble(ACCOUNT_BALANCE), 2)
|
| + " " + AccountInfoString(ACCOUNT_CURRENCY)
|
| + ", equity " + DoubleToString(AccountInfoDouble(ACCOUNT_EQUITY), 2)
|
| + ", free margin " + DoubleToString(AccountInfoDouble(ACCOUNT_MARGIN_FREE), 2) + "\n";
|
| return ctx;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Base64-encode a byte buffer via the native crypto library |
|
| //+------------------------------------------------------------------+
|
| string AiBase64Encode(const uchar &data[])
|
| {
|
| uchar key[];
|
| uchar out[];
|
| int n = CryptEncode(CRYPT_BASE64, data, key, out);
|
| if(n <= 0)
|
| return "";
|
| string s = CharArrayToString(out, 0, n, CP_UTF8);
|
| StringReplace(s, "\r", "");
|
| StringReplace(s, "\n", "");
|
| return s;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Capture and persist a chat message screenshot |
|
| //+------------------------------------------------------------------+
|
| string AiCaptureMessageImage(string &outB64, int &outW, int &outH)
|
| {
|
| outB64 = "";
|
| outW = 0;
|
| outH = 0;
|
| //--- Capture at the chart's true pixel size (automatic full size)
|
| int cw = (int)ChartGetInteger(0, CHART_WIDTH_IN_PIXELS);
|
| int ch = (int)ChartGetInteger(0, CHART_HEIGHT_IN_PIXELS);
|
| if(cw < 320)
|
| cw = 320;
|
| if(cw > 4096)
|
| cw = 4096;
|
| if(ch < 240)
|
| ch = 240;
|
| if(ch > 4096)
|
| ch = 4096;
|
| //--- Unique, stable base name (persists with the chat)
|
| const string base = "aiimg_" + IntegerToString((int)ChartID())
|
| + "_" + IntegerToString((int)GetTickCount());
|
| //--- Hide the panel canvases so the screenshot captures the clean chart only
|
| ObjectSetInteger(0, AI_CANVAS_NAME_MAIN, OBJPROP_TIMEFRAMES, OBJ_NO_PERIODS);
|
| ObjectSetInteger(0, AI_CANVAS_NAME_PROMPT, OBJPROP_TIMEFRAMES, OBJ_NO_PERIODS);
|
| ChartRedraw();
|
| //--- Native-size PNG (API payload + archival) and BMP (canvas display)
|
| bool okPng = ChartScreenShot(0, AI_IMG_DIR + base + ".png", cw, ch, ALIGN_RIGHT);
|
| ChartScreenShot(0, AI_IMG_DIR + base + ".bmp", cw, ch, ALIGN_RIGHT);
|
| //--- Restore the panel immediately
|
| ObjectSetInteger(0, AI_CANVAS_NAME_MAIN, OBJPROP_TIMEFRAMES, OBJ_ALL_PERIODS);
|
| ObjectSetInteger(0, AI_CANVAS_NAME_PROMPT, OBJPROP_TIMEFRAMES, OBJ_ALL_PERIODS);
|
| ChartRedraw();
|
| if(!okPng)
|
| {
|
| Print("Vision: screenshot failed, err=", GetLastError());
|
| return "";
|
| }
|
| outW = cw;
|
| outH = ch; // true native dimensions (aspect for inline + viewer)
|
| //--- Read PNG bytes -> base64 for the request
|
| int ph = FileOpen(AI_IMG_DIR + base + ".png", FILE_READ | FILE_BIN);
|
| if(ph != INVALID_HANDLE)
|
| {
|
| uchar bytes[];
|
| int sz = (int)FileReadArray(ph, bytes, 0, WHOLE_ARRAY);
|
| FileClose(ph);
|
| if(sz > 0)
|
| outB64 = AiBase64Encode(bytes);
|
| }
|
| return base;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Capture a clean chart and stage it for the next message |
|
| //+------------------------------------------------------------------+
|
| void Ai_AttachChartScreenshot()
|
| {
|
| //--- Drop a previous un-sent attachment's files first
|
| if(g_ai_attachPending && StringLen(g_ai_attachBase) > 0)
|
| {
|
| FileDelete(AI_IMG_DIR + g_ai_attachBase + ".png");
|
| FileDelete(AI_IMG_DIR + g_ai_attachBase + ".bmp");
|
| }
|
| g_ai_attachPending = false;
|
| g_ai_attachBase = "";
|
| g_ai_attachB64 = "";
|
| g_ai_attachW = 0;
|
| g_ai_attachH = 0;
|
| string b64 = "";
|
| int w = 0, h = 0;
|
| string base = AiCaptureMessageImage(b64, w, h);
|
| if(StringLen(base) == 0 || StringLen(b64) == 0)
|
| {
|
| Ai_ShowToast("Could not capture chart screenshot", true);
|
| return;
|
| }
|
| g_ai_attachPending = true;
|
| g_ai_attachBase = base;
|
| g_ai_attachB64 = b64;
|
| g_ai_attachW = w;
|
| g_ai_attachH = h;
|
| Ai_ShowToast("Chart attached - sends with your next message", false);
|
| Ai_RenderAll();
|
| ChartRedraw();
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Remove a staged attachment and delete its files |
|
| //+------------------------------------------------------------------+
|
| void Ai_RemoveAttachment()
|
| {
|
| if(StringLen(g_ai_attachBase) > 0)
|
| {
|
| FileDelete(AI_IMG_DIR + g_ai_attachBase + ".png");
|
| FileDelete(AI_IMG_DIR + g_ai_attachBase + ".bmp");
|
| }
|
| g_ai_attachPending = false;
|
| g_ai_attachBase = "";
|
| g_ai_attachB64 = "";
|
| g_ai_attachW = 0;
|
| g_ai_attachH = 0;
|
| Ai_RenderAll();
|
| ChartRedraw();
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Build multimodal user content with text and chart image |
|
| //+------------------------------------------------------------------+
|
| string AiBuildVisionContent(const string escapedText, const string b64)
|
| {
|
| const string detail = "high"; // deliberately-attached chart: read it closely
|
| string content = "[";
|
| content += "{\"type\":\"text\",\"text\":\"" + escapedText + "\"},";
|
| content += "{\"type\":\"image_url\",\"image_url\":{\"url\":\"data:image/png;base64,"
|
| + b64 + "\",\"detail\":\"" + detail + "\"}}";
|
| content += "]";
|
| return content;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Build the tools schema advertised to the API |
|
| //+------------------------------------------------------------------+
|
| string AiToolsDefinitionJson()
|
| {
|
| //--- Advertise nothing when both tool families are disabled
|
| if(!Enable_AI_Tools && !Enable_AI_Actions)
|
| return "";
|
| string t = "[";
|
| bool first = true;
|
| //--- Advertise the read-only data tools
|
| if(Enable_AI_Tools)
|
| {
|
| //--- Append the five data tools as one comma-joined block
|
| t += "{\"type\":\"function\",\"function\":{\"name\":\"get_indicator\","
|
| "\"description\":\"Latest value of a standard indicator on the current or given timeframe.\","
|
| "\"parameters\":{\"type\":\"object\",\"properties\":{"
|
| "\"name\":{\"type\":\"string\",\"enum\":[\"RSI\",\"MA\",\"MACD\",\"ATR\",\"STOCH\"]},"
|
| "\"period\":{\"type\":\"integer\"},"
|
| "\"timeframe\":{\"type\":\"string\"}},\"required\":[\"name\"]}}},";
|
| t += "{\"type\":\"function\",\"function\":{\"name\":\"get_open_positions\","
|
| "\"description\":\"List all open positions.\","
|
| "\"parameters\":{\"type\":\"object\",\"properties\":{}}}},";
|
| t += "{\"type\":\"function\",\"function\":{\"name\":\"get_pending_orders\","
|
| "\"description\":\"List working pending orders with the tickets needed to modify or delete them.\","
|
| "\"parameters\":{\"type\":\"object\",\"properties\":{}}}},";
|
| t += "{\"type\":\"function\",\"function\":{\"name\":\"get_chart_objects\","
|
| "\"description\":\"List drawn chart objects (trendlines, lines, rectangles, fibos) with price levels.\","
|
| "\"parameters\":{\"type\":\"object\",\"properties\":{}}}},";
|
| t += "{\"type\":\"function\",\"function\":{\"name\":\"get_trade_history\","
|
| "\"description\":\"Summarize recent closed trades.\","
|
| "\"parameters\":{\"type\":\"object\",\"properties\":{\"days\":{\"type\":\"integer\"}}}}},";
|
| t += "{\"type\":\"function\",\"function\":{\"name\":\"get_economic_calendar\","
|
| "\"description\":\"Upcoming calendar events for the symbol's currencies.\","
|
| "\"parameters\":{\"type\":\"object\",\"properties\":{\"days\":{\"type\":\"integer\"}}}}}";
|
| first = false;
|
| }
|
| //--- Advertise the trade action and drawing tools
|
| if(Enable_AI_Actions)
|
| {
|
| //--- Join onto any earlier block with a comma
|
| if(!first)
|
| t += ",";
|
| t += "{\"type\":\"function\",\"function\":{\"name\":\"get_position_owners\","
|
| "\"description\":\"Count open positions by owner: this EA, manual trades or other robots. Call this before any bulk close, then ask the user which scope they mean.\","
|
| "\"parameters\":{\"type\":\"object\",\"properties\":{"
|
| "\"symbol\":{\"type\":\"string\"}}}}},";
|
| t += "{\"type\":\"function\",\"function\":{\"name\":\"place_order\","
|
| "\"description\":\"Place a market or pending order. Requires user approval before execution.\","
|
| "\"parameters\":{\"type\":\"object\",\"properties\":{"
|
| "\"symbol\":{\"type\":\"string\"},"
|
| "\"direction\":{\"type\":\"string\",\"enum\":[\"buy\",\"sell\"]},"
|
| "\"order_kind\":{\"type\":\"string\",\"enum\":[\"market\",\"limit\",\"stop\"]},"
|
| "\"lots\":{\"type\":\"number\",\"description\":\"Trade volume in lots.\"},"
|
| "\"price\":{\"type\":\"number\",\"description\":\"Absolute trigger price for pending orders, never a distance.\"},"
|
| "\"sl\":{\"type\":\"number\",\"description\":\"Stop loss as an absolute price level. Use sl_points instead when the user gives a distance.\"},"
|
| "\"tp\":{\"type\":\"number\",\"description\":\"Take profit as an absolute price level. Use tp_points instead when the user gives a distance.\"},"
|
| "\"sl_points\":{\"type\":\"integer\",\"description\":\"Stop distance from entry in broker points. The EA converts it, so pass the number the user said and never compute a price yourself.\"},"
|
| "\"tp_points\":{\"type\":\"integer\",\"description\":\"Target distance from entry in broker points. The EA converts it, so pass the number the user said and never compute a price yourself.\"},"
|
| "\"comment\":{\"type\":\"string\"}},\"required\":[\"direction\",\"lots\"]}}},";
|
| t += "{\"type\":\"function\",\"function\":{\"name\":\"modify_position\","
|
| "\"description\":\"Change the SL or TP of an open position, move it to breakeven or trail it.\","
|
| "\"parameters\":{\"type\":\"object\",\"properties\":{"
|
| "\"ticket\":{\"type\":\"integer\"},"
|
| "\"sl\":{\"type\":\"number\",\"description\":\"Stop loss as an absolute price level, never a distance.\"},"
|
| "\"tp\":{\"type\":\"number\",\"description\":\"Take profit as an absolute price level, never a distance.\"},"
|
| "\"sl_points\":{\"type\":\"integer\",\"description\":\"Stop distance from the entry price in broker points, converted by the EA.\"},"
|
| "\"tp_points\":{\"type\":\"integer\",\"description\":\"Target distance from the entry price in broker points, converted by the EA.\"},"
|
| "\"breakeven\":{\"type\":\"boolean\",\"description\":\"Move the stop loss to the entry price.\"},"
|
| "\"trail_points\":{\"type\":\"integer\",\"description\":\"Stop distance from current price in broker points. One pip is ten points on 5-digit and 3-digit symbols, so 20 pips is 200 points.\"}},"
|
| "\"required\":[\"ticket\"]}}},";
|
| t += "{\"type\":\"function\",\"function\":{\"name\":\"close_position\","
|
| "\"description\":\"Close a position by ticket, or sweep a symbol. A symbol sweep needs scope; call without it first to see who owns what, then ask the user before sweeping.\","
|
| "\"parameters\":{\"type\":\"object\",\"properties\":{"
|
| "\"ticket\":{\"type\":\"integer\"},"
|
| "\"symbol\":{\"type\":\"string\"},"
|
| "\"fraction\":{\"type\":\"number\",\"description\":\"Portion of the position to close as a decimal, so 0.5 is half.\"},"
|
| "\"lots\":{\"type\":\"number\",\"description\":\"Exact volume to close in lots, which overrides fraction.\"},"
|
| "\"scope\":{\"type\":\"string\",\"enum\":[\"this_ea\",\"manual\",\"other_eas\",\"everything\"],"
|
| "\"description\":\"Whose positions a symbol sweep may close. Required for a sweep and ignored when a ticket is given. Never assume it, ask the user first.\"}}}}},";
|
| t += "{\"type\":\"function\",\"function\":{\"name\":\"close_all\","
|
| "\"description\":\"Close open positions in bulk. Call without scope first to see who owns what, then ask the user which scope to close before calling again.\","
|
| "\"parameters\":{\"type\":\"object\",\"properties\":{"
|
| "\"filter\":{\"type\":\"string\",\"enum\":[\"all\",\"profit_only\",\"loss_only\",\"chart_symbol\"],"
|
| "\"description\":\"Which results to include, independent of who owns the position.\"},"
|
| "\"scope\":{\"type\":\"string\",\"enum\":[\"this_ea\",\"manual\",\"other_eas\",\"everything\"],"
|
| "\"description\":\"Whose positions may be closed. this_ea is only trades this EA opened, manual is hand-placed trades, other_eas belongs to other robots. Never assume it, ask the user first.\"}},"
|
| "\"required\":[\"scope\"]}}},";
|
| t += "{\"type\":\"function\",\"function\":{\"name\":\"modify_order\","
|
| "\"description\":\"Change the price, SL or TP of a pending order.\","
|
| "\"parameters\":{\"type\":\"object\",\"properties\":{"
|
| "\"ticket\":{\"type\":\"integer\"},"
|
| "\"price\":{\"type\":\"number\",\"description\":\"New absolute trigger price.\"},"
|
| "\"sl\":{\"type\":\"number\",\"description\":\"Stop loss as an absolute price level, never a distance.\"},"
|
| "\"tp\":{\"type\":\"number\",\"description\":\"Take profit as an absolute price level, never a distance.\"}},"
|
| "\"required\":[\"ticket\"]}}},";
|
| t += "{\"type\":\"function\",\"function\":{\"name\":\"delete_order\","
|
| "\"description\":\"Delete a pending order by ticket.\","
|
| "\"parameters\":{\"type\":\"object\",\"properties\":{"
|
| "\"ticket\":{\"type\":\"integer\"}},\"required\":[\"ticket\"]}}},";
|
| t += "{\"type\":\"function\",\"function\":{\"name\":\"draw_on_chart\","
|
| "\"description\":\"Draw a horizontal level or a text label on the chart at a price.\","
|
| "\"parameters\":{\"type\":\"object\",\"properties\":{"
|
| "\"kind\":{\"type\":\"string\",\"enum\":[\"hline\",\"label\"]},"
|
| "\"price\":{\"type\":\"number\",\"description\":\"Absolute price level to draw at.\"},"
|
| "\"text\":{\"type\":\"string\"}},\"required\":[\"price\"]}}}";
|
| }
|
| t += "]";
|
| return t;
|
| }
|
| //--- Parse a timeframe string to an enum
|
| ENUM_TIMEFRAMES AiParseTimeframe(string s)
|
| {
|
| StringToUpper(s);
|
| if(s == "" || s == "CURRENT" || s == "CHART")
|
| return (ENUM_TIMEFRAMES)Period();
|
| if(s == "M1")
|
| return PERIOD_M1;
|
| if(s == "M5")
|
| return PERIOD_M5;
|
| if(s == "M15")
|
| return PERIOD_M15;
|
| if(s == "M30")
|
| return PERIOD_M30;
|
| if(s == "H1")
|
| return PERIOD_H1;
|
| if(s == "H4")
|
| return PERIOD_H4;
|
| if(s == "D1")
|
| return PERIOD_D1;
|
| if(s == "W1")
|
| return PERIOD_W1;
|
| if(s == "MN1")
|
| return PERIOD_MN1;
|
| return (ENUM_TIMEFRAMES)Period();
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Tool: get indicator values |
|
| //+------------------------------------------------------------------+
|
| string AiToolGetIndicator(JsonValue &args)
|
| {
|
| string name = args["name"].ToString();
|
| StringToUpper(name);
|
| int period = (int)args["period"].ToInteger();
|
| if(period <= 0)
|
| period = 14;
|
| ENUM_TIMEFRAMES tf = AiParseTimeframe(args["timeframe"].ToString());
|
| const string sym = Symbol();
|
| int handle = INVALID_HANDLE, bufcount = 1;
|
| if(name == "RSI")
|
| handle = iRSI(sym, tf, period, PRICE_CLOSE);
|
| else
|
| if(name == "MA")
|
| handle = iMA(sym, tf, period, 0, MODE_SMA, PRICE_CLOSE);
|
| else
|
| if(name == "ATR")
|
| handle = iATR(sym, tf, period);
|
| else
|
| if(name == "MACD")
|
| {
|
| handle = iMACD(sym, tf, 12, 26, 9, PRICE_CLOSE);
|
| bufcount = 2;
|
| }
|
| else
|
| if(name == "STOCH")
|
| {
|
| handle = iStochastic(sym, tf, period, 3, 3, MODE_SMA, STO_LOWHIGH);
|
| bufcount = 2;
|
| }
|
| else
|
| return "Unknown indicator '" + name + "'.";
|
| if(handle == INVALID_HANDLE)
|
| return "Failed to create " + name + " handle (err " + IntegerToString(GetLastError()) + ").";
|
| double b0[];
|
| double b1[];
|
| string res;
|
| if(CopyBuffer(handle, 0, 0, 2, b0) < 2)
|
| {
|
| IndicatorRelease(handle);
|
| return name + ": no data yet.";
|
| }
|
| if(bufcount == 2 && CopyBuffer(handle, 1, 0, 2, b1) < 2)
|
| {
|
| IndicatorRelease(handle);
|
| return name + ": partial data.";
|
| }
|
| if(name == "MACD")
|
| res = "MACD on " + sym + ": main=" + DoubleToString(b0[1], 5) + ", signal=" + DoubleToString(b1[1], 5);
|
| else
|
| if(name == "STOCH")
|
| res = "Stochastic on " + sym + ": %K=" + DoubleToString(b0[1], 2) + ", %D=" + DoubleToString(b1[1], 2);
|
| else
|
| res = name + "(" + IntegerToString(period) + ") on " + sym + " " + EnumToString(tf)
|
| + ": " + DoubleToString(b0[1], 5) + " (prev " + DoubleToString(b0[0], 5) + ")";
|
| IndicatorRelease(handle);
|
| return res;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Get short label for an order type |
|
| //+------------------------------------------------------------------+
|
| string Ai_OrderTypeLabel(ENUM_ORDER_TYPE t)
|
| {
|
| //--- Map order type to readable label
|
| if(t == ORDER_TYPE_BUY)
|
| return "BUY (market)";
|
| if(t == ORDER_TYPE_SELL)
|
| return "SELL (market)";
|
| if(t == ORDER_TYPE_BUY_LIMIT)
|
| return "BUY LIMIT";
|
| if(t == ORDER_TYPE_SELL_LIMIT)
|
| return "SELL LIMIT";
|
| if(t == ORDER_TYPE_BUY_STOP)
|
| return "BUY STOP";
|
| if(t == ORDER_TYPE_SELL_STOP)
|
| return "SELL STOP";
|
| return "UNKNOWN";
|
| }
|
|
|
|
|
| //--- Forward declaration so the read tools can label trade ownership
|
| string AiOwnerGroup(const long magic);
|
|
|
| //+------------------------------------------------------------------+
|
| //| Tool: get open positions |
|
| //+------------------------------------------------------------------+
|
| string AiToolGetPositions()
|
| {
|
| int total = PositionsTotal();
|
| if(total <= 0)
|
| return "No open positions.";
|
| string r = IntegerToString(total) + " open position(s):";
|
| for(int i = 0; i < total; i++)
|
| {
|
| ulong ticket = PositionGetTicket(i);
|
| if(ticket == 0 || !PositionSelectByTicket(ticket))
|
| continue;
|
| const string psym = PositionGetString(POSITION_SYMBOL);
|
| const int pdig = (int)SymbolInfoInteger(psym, SYMBOL_DIGITS);
|
| r += "\n#" + IntegerToString((long)ticket) + " " + psym + " "
|
| + ((PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY) ? "BUY" : "SELL") + " "
|
| + DoubleToString(PositionGetDouble(POSITION_VOLUME), 2) + " @ "
|
| + DoubleToString(PositionGetDouble(POSITION_PRICE_OPEN), pdig)
|
| + " SL " + DoubleToString(PositionGetDouble(POSITION_SL), pdig)
|
| + " TP " + DoubleToString(PositionGetDouble(POSITION_TP), pdig)
|
| + " P/L " + DoubleToString(PositionGetDouble(POSITION_PROFIT), 2)
|
| + " owner " + AiOwnerGroup(PositionGetInteger(POSITION_MAGIC));
|
| }
|
| return r;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Tool: get pending orders |
|
| //+------------------------------------------------------------------+
|
| string AiToolGetPendingOrders()
|
| {
|
| //--- Report an empty book when nothing is waiting
|
| int total = OrdersTotal();
|
| if(total <= 0)
|
| return "No pending orders.";
|
| string r = IntegerToString(total) + " pending order(s):";
|
| //--- Describe every order with the ticket the action tools need
|
| for(int i = 0; i < total; i++)
|
| {
|
| //--- Select the next order in the book
|
| const ulong ticket = OrderGetTicket(i);
|
| if(ticket == 0 || !OrderSelect(ticket))
|
| continue;
|
| const string osym = OrderGetString(ORDER_SYMBOL);
|
| const int odig = (int)SymbolInfoInteger(osym, SYMBOL_DIGITS);
|
| r += "\n#" + IntegerToString((long)ticket) + " " + osym + " "
|
| + Ai_OrderTypeLabel((ENUM_ORDER_TYPE)OrderGetInteger(ORDER_TYPE)) + " "
|
| + DoubleToString(OrderGetDouble(ORDER_VOLUME_CURRENT), 2) + " @ "
|
| + DoubleToString(OrderGetDouble(ORDER_PRICE_OPEN), odig)
|
| + " SL " + DoubleToString(OrderGetDouble(ORDER_SL), odig)
|
| + " TP " + DoubleToString(OrderGetDouble(ORDER_TP), odig)
|
| + " owner " + AiOwnerGroup(OrderGetInteger(ORDER_MAGIC));
|
| }
|
| return r;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Tool: get chart objects |
|
| //+------------------------------------------------------------------+
|
| string AiToolGetChartObjects()
|
| {
|
| int total = ObjectsTotal(0, -1, -1);
|
| if(total <= 0)
|
| return "No objects drawn on the chart.";
|
| const int dig = (int)SymbolInfoInteger(Symbol(), SYMBOL_DIGITS);
|
| string r = "";
|
| int shown = 0;
|
| for(int i = 0; i < total && shown < 40; i++)
|
| {
|
| string nm = ObjectName(0, i, -1, -1);
|
| ENUM_OBJECT ot = (ENUM_OBJECT)ObjectGetInteger(0, nm, OBJPROP_TYPE);
|
| string line = "";
|
| switch(ot)
|
| {
|
| case OBJ_HLINE:
|
| line = "Horizontal line @ " + DoubleToString(ObjectGetDouble(0, nm, OBJPROP_PRICE, 0), dig);
|
| break;
|
| case OBJ_VLINE:
|
| line = "Vertical line @ " + TimeToString((datetime)ObjectGetInteger(0, nm, OBJPROP_TIME, 0));
|
| break;
|
| case OBJ_TREND:
|
| line = "Trendline " + DoubleToString(ObjectGetDouble(0, nm, OBJPROP_PRICE, 0), dig) + " -> " + DoubleToString(ObjectGetDouble(0, nm, OBJPROP_PRICE, 1), dig);
|
| break;
|
| case OBJ_RECTANGLE:
|
| line = "Rectangle " + DoubleToString(ObjectGetDouble(0, nm, OBJPROP_PRICE, 0), dig) + " / " + DoubleToString(ObjectGetDouble(0, nm, OBJPROP_PRICE, 1), dig);
|
| break;
|
| case OBJ_FIBO:
|
| line = "Fibonacci " + DoubleToString(ObjectGetDouble(0, nm, OBJPROP_PRICE, 0), dig) + " -> " + DoubleToString(ObjectGetDouble(0, nm, OBJPROP_PRICE, 1), dig);
|
| break;
|
| default:
|
| continue;
|
| }
|
| r += (shown > 0 ? "\n" : "") + line;
|
| shown++;
|
| }
|
| if(shown == 0)
|
| return "No price-level objects drawn.";
|
| return "Drawn objects:\n" + r;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Tool: get trade history |
|
| //+------------------------------------------------------------------+
|
| string AiToolGetTradeHistory(JsonValue &args)
|
| {
|
| int days = (int)args["days"].ToInteger();
|
| if(days <= 0)
|
| days = 7;
|
| datetime from = TimeCurrent() - (datetime)days * 86400;
|
| if(!HistorySelect(from, TimeCurrent()))
|
| return "Could not load trade history.";
|
| int deals = HistoryDealsTotal(), shown = 0;
|
| double net = 0.0;
|
| string r = "";
|
| for(int i = deals - 1; i >= 0 && shown < 20; i--)
|
| {
|
| ulong ticket = HistoryDealGetTicket(i);
|
| if(ticket == 0)
|
| continue;
|
| if(HistoryDealGetInteger(ticket, DEAL_ENTRY) != DEAL_ENTRY_OUT)
|
| continue;
|
| double profit = HistoryDealGetDouble(ticket, DEAL_PROFIT)
|
| + HistoryDealGetDouble(ticket, DEAL_SWAP)
|
| + HistoryDealGetDouble(ticket, DEAL_COMMISSION);
|
| net += profit;
|
| r += "\n" + TimeToString((datetime)HistoryDealGetInteger(ticket, DEAL_TIME), TIME_DATE|TIME_MINUTES)
|
| + " " + HistoryDealGetString(ticket, DEAL_SYMBOL) + " "
|
| + DoubleToString(HistoryDealGetDouble(ticket, DEAL_VOLUME), 2) + " lots P/L " + DoubleToString(profit, 2);
|
| shown++;
|
| }
|
| if(shown == 0)
|
| return "No closed trades in the last " + IntegerToString(days) + " day(s).";
|
| return "Last " + IntegerToString(shown) + " closed trade(s), net " + DoubleToString(net, 2) + ":" + r;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Tool: get economic calendar |
|
| //+------------------------------------------------------------------+
|
| string AiToolGetCalendar(JsonValue &args)
|
| {
|
| int days = (int)args["days"].ToInteger();
|
| if(days <= 0)
|
| days = 2;
|
| datetime from = TimeCurrent(), to = TimeCurrent() + (datetime)days * 86400;
|
| string base = SymbolInfoString(Symbol(), SYMBOL_CURRENCY_BASE);
|
| string prof = SymbolInfoString(Symbol(), SYMBOL_CURRENCY_PROFIT);
|
| MqlCalendarValue values[];
|
| int n = CalendarValueHistory(values, from, to, NULL, NULL);
|
| if(n <= 0)
|
| return "No calendar data available (broker calendar may be off).";
|
| string r = "";
|
| int shown = 0;
|
| for(int i = 0; i < n && shown < 15; i++)
|
| {
|
| MqlCalendarEvent ev;
|
| if(!CalendarEventById(values[i].event_id, ev))
|
| continue;
|
| MqlCalendarCountry cn;
|
| if(!CalendarCountryById(ev.country_id, cn))
|
| continue;
|
| if(ev.importance == CALENDAR_IMPORTANCE_NONE)
|
| continue;
|
| if(cn.currency != base && cn.currency != prof)
|
| continue;
|
| string imp = (ev.importance == CALENDAR_IMPORTANCE_HIGH) ? "HIGH"
|
| : (ev.importance == CALENDAR_IMPORTANCE_MODERATE ? "med" : "low");
|
| r += "\n" + TimeToString(values[i].time, TIME_DATE|TIME_MINUTES) + " "
|
| + cn.currency + " [" + imp + "] " + ev.name;
|
| shown++;
|
| }
|
| if(shown == 0)
|
| return "No notable " + base + "/" + prof + " events in the next " + IntegerToString(days) + " day(s).";
|
| return "Upcoming events:" + r;
|
| }
|
|
|
| //--- Forward declaration so the dispatcher can route drawing requests
|
| string AiToolDrawOnChart(const string argsJson);
|
|
|
| //--- Forward declaration so the dispatcher can route ownership lookups
|
| string AiToolGetPositionOwners(JsonValue &args);
|
|
|
| //+------------------------------------------------------------------+
|
| //| Dispatch a tool call by name |
|
| //+------------------------------------------------------------------+
|
| string AiDispatchTool(const string toolName, const string argsJson)
|
| {
|
| JsonValue args;
|
| string a = argsJson;
|
| if(StringLen(a) == 0)
|
| a = "{}";
|
| int idx = 0;
|
| char buf[];
|
| int len = StringToCharArray(a, buf, 0, WHOLE_ARRAY, CP_UTF8);
|
| args.DeserializeFromArray(buf, len, idx);
|
| if(toolName == "get_indicator")
|
| return AiToolGetIndicator(args);
|
| if(toolName == "get_open_positions")
|
| return AiToolGetPositions();
|
| if(toolName == "get_pending_orders")
|
| return AiToolGetPendingOrders();
|
| if(toolName == "get_chart_objects")
|
| return AiToolGetChartObjects();
|
| if(toolName == "get_trade_history")
|
| return AiToolGetTradeHistory(args);
|
| if(toolName == "get_economic_calendar")
|
| return AiToolGetCalendar(args);
|
| if(toolName == "get_position_owners")
|
| return AiToolGetPositionOwners(args);
|
| if(toolName == "draw_on_chart")
|
| return AiToolDrawOnChart(argsJson);
|
| return "Unknown tool: " + toolName;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Read a string child node safely |
|
| //+------------------------------------------------------------------+
|
| string AiJsonStr(JsonValue *node, string key)
|
| {
|
| if(node == NULL)
|
| return "";
|
| JsonValue *v = node.FindChildByKey(key);
|
| return (v == NULL) ? "" : v.ToString();
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Create horizontal line on chart |
|
| //+------------------------------------------------------------------+
|
| void AiCreateHLine(string name, double price, color clr, ENUM_LINE_STYLE style, string tooltip)
|
| {
|
| //--- Create and configure horizontal line
|
| ObjectCreate(0, name, OBJ_HLINE, 0, 0, price);
|
| ObjectSetInteger(0, name, OBJPROP_COLOR, clr);
|
| ObjectSetInteger(0, name, OBJPROP_STYLE, style);
|
| ObjectSetInteger(0, name, OBJPROP_WIDTH, 1);
|
| ObjectSetString(0, name, OBJPROP_TEXT, tooltip);
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Create text label on chart |
|
| //+------------------------------------------------------------------+
|
| void AiCreateTextLabel(string name, datetime time, double price, string text, color clr,
|
| ENUM_ANCHOR_POINT anchor, double angle = 0)
|
| {
|
| //--- Create and configure text object
|
| ObjectCreate(0, name, OBJ_TEXT, 0, time, price);
|
| ObjectSetString(0, name, OBJPROP_TEXT, text);
|
| ObjectSetInteger(0, name, OBJPROP_COLOR, clr);
|
| ObjectSetInteger(0, name, OBJPROP_FONTSIZE, 10);
|
| ObjectSetString(0, name, OBJPROP_FONT, "Arial Bold");
|
| ObjectSetInteger(0, name, OBJPROP_ANCHOR, anchor);
|
| ObjectSetInteger(0, name, OBJPROP_SELECTABLE, false);
|
| ObjectSetDouble(0, name, OBJPROP_ANGLE, angle);
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| AI trade action framework |
|
| //+------------------------------------------------------------------+
|
| //--- Sentinel returned by the tool loop while an action awaits approval
|
| #define AI_ACTION_PENDING_SENTINEL "__AI_ACTION_PENDING__"
|
|
|
| //--- Milliseconds a fresh card ignores clicks so a queued one cannot decide it
|
| #define AI_ACTION_ARM_DELAY_MS 350
|
|
|
| //--- Forward declaration so early framework code can persist the chat
|
| bool AiUpdateCurrentHistory();
|
|
|
| //+------------------------------------------------------------------+
|
| //| Check whether a tool name is a trade action |
|
| //+------------------------------------------------------------------+
|
| bool AiIsActionTool(const string toolName)
|
| {
|
| //--- Match against the full trade-action tool set
|
| return (toolName == "place_order"
|
| || toolName == "modify_position"
|
| || toolName == "close_position"
|
| || toolName == "close_all"
|
| || toolName == "modify_order"
|
| || toolName == "delete_order");
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Check whether a JSON object carries a key |
|
| //+------------------------------------------------------------------+
|
| bool AiJsonHas(JsonValue &obj, const string key)
|
| {
|
| //--- Probe the child list without creating the key
|
| return (obj.FindChildByKey(key) != NULL);
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Read a numeric JSON field with a fallback |
|
| //+------------------------------------------------------------------+
|
| double AiJsonNum(JsonValue &obj, const string key, const double defVal)
|
| {
|
| //--- Return the fallback when the key is absent
|
| JsonValue *v = obj.FindChildByKey(key);
|
| return (v == NULL) ? defVal : v.ToDouble();
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Read an integer JSON field with a fallback |
|
| //+------------------------------------------------------------------+
|
| long AiJsonInt(JsonValue &obj, const string key, const long defVal)
|
| {
|
| //--- Return the fallback when the key is absent
|
| JsonValue *v = obj.FindChildByKey(key);
|
| return (v == NULL) ? defVal : v.ToInteger();
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Read a text JSON field with a fallback |
|
| //+------------------------------------------------------------------+
|
| string AiJsonText(JsonValue &obj, const string key, const string defVal)
|
| {
|
| //--- Return the fallback when the key is absent
|
| JsonValue *v = obj.FindChildByKey(key);
|
| return (v == NULL) ? defVal : v.ToString();
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Read a boolean JSON field with a fallback |
|
| //+------------------------------------------------------------------+
|
| bool AiJsonFlag(JsonValue &obj, const string key, const bool defVal)
|
| {
|
| //--- Return the fallback when the key is absent
|
| JsonValue *v = obj.FindChildByKey(key);
|
| return (v == NULL) ? defVal : v.ToBoolean();
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Get how many broker points make one pip on a symbol |
|
| //+------------------------------------------------------------------+
|
| int AiPointsPerPip(const string sym)
|
| {
|
| //--- Fractional-pip symbols quote one extra digit, so ten points make a pip
|
| const int dig = (int)SymbolInfoInteger(sym, SYMBOL_DIGITS);
|
| return ((dig == 3 || dig == 5) ? 10 : 1);
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Convert a point distance into an absolute price level |
|
| //+------------------------------------------------------------------+
|
| double AiLevelFromPoints(const string sym, const double refPrice, const long points, const bool below)
|
| {
|
| //--- Offset the reference price by the requested number of broker points
|
| const double pt = SymbolInfoDouble(sym, SYMBOL_POINT);
|
| const double off = (double)points * pt;
|
| const double lvl = below ? (refPrice - off) : (refPrice + off);
|
| return NormalizeDouble(lvl, (int)SymbolInfoInteger(sym, SYMBOL_DIGITS));
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Format a volume without collapsing a small ask to zero |
|
| //+------------------------------------------------------------------+
|
| string AiFormatLots(const string sym, const double lots)
|
| {
|
| //--- Start from the precision implied by the broker volume step
|
| const double step = SymbolInfoDouble(sym, SYMBOL_VOLUME_STEP);
|
| int digits = 2;
|
| if(step > 0.0)
|
| {
|
| //--- Count the decimals the step itself needs
|
| digits = 0;
|
| double probe = step;
|
| while(digits < 3 && MathAbs(probe - MathRound(probe)) > 0.0000001)
|
| {
|
| probe *= 10.0;
|
| digits++;
|
| }
|
| }
|
| //--- Widen while the printed value would misstate the real one
|
| if(digits < 2)
|
| digits = 2;
|
| while(digits < 3
|
| && MathAbs(lots - NormalizeDouble(lots, digits)) > 0.0000001)
|
| digits++;
|
| return DoubleToString(lots, digits);
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Format a price at the quoting precision of its symbol |
|
| //+------------------------------------------------------------------+
|
| string AiFormatPrice(const string sym, const double price)
|
| {
|
| //--- Use the symbol digits so gold, indices and JPY pairs read correctly
|
| return DoubleToString(price, (int)SymbolInfoInteger(sym, SYMBOL_DIGITS));
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Resolve the target symbol of an action |
|
| //+------------------------------------------------------------------+
|
| string AiActionResolveSymbol(JsonValue &args)
|
| {
|
| //--- Read the symbol argument and fall back to the chart symbol
|
| string sym = AiJsonText(args, "symbol", "");
|
| StringTrimLeft(sym);
|
| StringTrimRight(sym);
|
| if(StringLen(sym) == 0)
|
| sym = Symbol();
|
| return sym;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Name the owner group a magic number belongs to |
|
| //+------------------------------------------------------------------+
|
| string AiOwnerGroup(const long magic)
|
| {
|
| //--- Split trades into this EA, hand-placed orders and other robots
|
| if(magic == (long)MagicNumber)
|
| return "this_ea";
|
| if(magic == 0)
|
| return "manual";
|
| return "other_eas";
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Test a position owner against a requested scope |
|
| //+------------------------------------------------------------------+
|
| bool AiScopeMatches(const string scope, const long magic)
|
| {
|
| //--- Everything matches the widest scope
|
| if(scope == "everything")
|
| return true;
|
| return (AiOwnerGroup(magic) == scope);
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Count open positions per owner group for a symbol filter |
|
| //+------------------------------------------------------------------+
|
| string AiOwnershipBreakdown(const string symFilter)
|
| {
|
| //--- Tally each owner group and its floating result
|
| int nEa = 0, nManual = 0, nOther = 0;
|
| double plEa = 0.0, plManual = 0.0, plOther = 0.0;
|
| for(int i = PositionsTotal() - 1; i >= 0; i--)
|
| {
|
| //--- Select the next position and apply the symbol filter
|
| const ulong t = PositionGetTicket(i);
|
| if(t == 0 || !PositionSelectByTicket(t))
|
| continue;
|
| const string psym = PositionGetString(POSITION_SYMBOL);
|
| if(StringLen(symFilter) > 0 && psym != symFilter)
|
| continue;
|
| const double pl = PositionGetDouble(POSITION_PROFIT);
|
| const string grp = AiOwnerGroup(PositionGetInteger(POSITION_MAGIC));
|
| if(grp == "this_ea")
|
| {
|
| nEa++;
|
| plEa += pl;
|
| }
|
| else
|
| if(grp == "manual")
|
| {
|
| nManual++;
|
| plManual += pl;
|
| }
|
| else
|
| {
|
| nOther++;
|
| plOther += pl;
|
| }
|
| }
|
| //--- Report nothing to close when no position survived the filter
|
| if(nEa + nManual + nOther == 0)
|
| return "No open positions" + (StringLen(symFilter) > 0 ? (" on " + symFilter) : "") + ".";
|
| //--- Spell out the choices so the assistant can ask before closing anything
|
| string r = "Ask the user which scope to close, then call again with the scope argument.\n";
|
| r += "this_ea: " + IntegerToString(nEa) + " position(s), P/L " + DoubleToString(plEa, 2) + "\n";
|
| r += "manual: " + IntegerToString(nManual) + " position(s), P/L " + DoubleToString(plManual, 2) + "\n";
|
| r += "other_eas: " + IntegerToString(nOther) + " position(s), P/L " + DoubleToString(plOther, 2) + "\n";
|
| r += "everything: " + IntegerToString(nEa + nManual + nOther) + " position(s), P/L "
|
| + DoubleToString(plEa + plManual + plOther, 2);
|
| return r;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Tool: report who owns the open positions |
|
| //+------------------------------------------------------------------+
|
| string AiToolGetPositionOwners(JsonValue &args)
|
| {
|
| //--- Honor an explicit symbol, otherwise follow the chart-symbol restriction
|
| string sym = AiJsonText(args, "symbol", "");
|
| StringTrimLeft(sym);
|
| StringTrimRight(sym);
|
| if(StringLen(sym) == 0 && Action_ChartSymbolOnly)
|
| sym = Symbol();
|
| return AiOwnershipBreakdown(sym);
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Validate an action against the safety limits |
|
| //+------------------------------------------------------------------+
|
| string AiValidateAction(const string sym, const double lots, const bool opensExposure)
|
| {
|
| //--- Block everything when actions are disabled
|
| if(!Enable_AI_Actions)
|
| return "AI trade actions are disabled in the EA inputs.";
|
| //--- Enforce the chart-symbol restriction
|
| if(Action_ChartSymbolOnly && sym != Symbol())
|
| return "Actions are restricted to " + Symbol() + " by the EA inputs.";
|
| //--- Reject symbols the terminal cannot select
|
| if(!SymbolSelect(sym, true))
|
| return "Unknown symbol '" + sym + "'.";
|
| //--- Enforce the per-action lot ceiling
|
| if(lots > Action_MaxLots + 0.0000001)
|
| return "Requested " + AiFormatLots(sym, lots) + " lots exceeds the Action_MaxLots limit of "
|
| + AiFormatLots(sym, Action_MaxLots) + ".";
|
| //--- Enforce the open-position ceiling for exposure-adding actions
|
| if(opensExposure && Action_MaxPositions > 0 && PositionsTotal() >= Action_MaxPositions)
|
| return "Open position limit (" + IntegerToString(Action_MaxPositions) + ") reached.";
|
| //--- Report a clean pass
|
| return "";
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Clamp a lot size to the symbol volume constraints |
|
| //+------------------------------------------------------------------+
|
| double AiNormalizeLots(const string sym, double lots)
|
| {
|
| //--- Pull the broker volume constraints
|
| const double minLot = SymbolInfoDouble(sym, SYMBOL_VOLUME_MIN);
|
| const double maxLot = SymbolInfoDouble(sym, SYMBOL_VOLUME_MAX);
|
| const double step = SymbolInfoDouble(sym, SYMBOL_VOLUME_STEP);
|
| //--- Snap to the volume step
|
| if(step > 0.0)
|
| lots = MathFloor(lots / step + 0.5) * step;
|
| //--- Clamp into the allowed range
|
| if(lots < minLot)
|
| lots = minLot;
|
| if(lots > maxLot)
|
| lots = maxLot;
|
| return lots;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Execute a place_order action |
|
| //+------------------------------------------------------------------+
|
| string AiActionPlaceOrder(JsonValue &args)
|
| {
|
| //--- Read the order arguments with safe defaults
|
| const string sym = AiActionResolveSymbol(args);
|
| string dir = AiJsonText(args, "direction", "");
|
| string kind = AiJsonText(args, "order_kind", "market");
|
| double lots = AiJsonNum(args, "lots", LotSize);
|
| double price = AiJsonNum(args, "price", 0.0);
|
| double sl = AiJsonNum(args, "sl", 0.0);
|
| double tp = AiJsonNum(args, "tp", 0.0);
|
| string comment = AiJsonText(args, "comment", "AI action");
|
| StringToUpper(dir);
|
| StringToUpper(kind);
|
| //--- Resolve the trade direction
|
| const bool isBuy = (StringFind(dir, "BUY") >= 0);
|
| const bool isSell = (StringFind(dir, "SELL") >= 0);
|
| if(!isBuy && !isSell)
|
| return "place_order needs direction buy or sell.";
|
| //--- Reject a non-numeric or non-positive volume before any rounding
|
| if(!MathIsValidNumber(lots) || lots <= 0.0)
|
| return "Blocked: lots must be a positive number.";
|
| //--- Treat a blank order kind as a market order
|
| if(StringLen(kind) == 0)
|
| kind = "MARKET";
|
| //--- Reject any order kind outside the advertised set
|
| if(kind != "MARKET" && kind != "LIMIT" && kind != "STOP")
|
| return "Blocked: order_kind must be market, limit or stop.";
|
| //--- Run the safety gate before touching the trade server
|
| string err = AiValidateAction(sym, lots, true);
|
| if(StringLen(err) > 0)
|
| return "Blocked: " + err;
|
| //--- Re-check the ceiling after snapping, since broker limits can raise the volume
|
| const double askedLots = lots;
|
| lots = AiNormalizeLots(sym, lots);
|
| err = AiValidateAction(sym, lots, false);
|
| if(StringLen(err) > 0)
|
| return "Blocked: " + AiFormatLots(sym, askedLots) + " lots becomes "
|
| + AiFormatLots(sym, lots) + " after broker volume rounding. " + err;
|
| //--- Map direction and kind onto the order type
|
| const bool isMarket = (kind == "MARKET");
|
| const bool isLimit = (kind == "LIMIT");
|
| const bool isStop = (kind == "STOP");
|
| ENUM_ORDER_TYPE type = isBuy ? ORDER_TYPE_BUY : ORDER_TYPE_SELL;
|
| if(isLimit)
|
| type = isBuy ? ORDER_TYPE_BUY_LIMIT : ORDER_TYPE_SELL_LIMIT;
|
| if(isStop)
|
| type = isBuy ? ORDER_TYPE_BUY_STOP : ORDER_TYPE_SELL_STOP;
|
| if(!isMarket && price <= 0.0)
|
| return "Pending orders need a positive trigger price.";
|
| //--- Resolve the price the stops are measured from
|
| const double refPx = isMarket
|
| ? (isBuy ? SymbolInfoDouble(sym, SYMBOL_ASK) : SymbolInfoDouble(sym, SYMBOL_BID))
|
| : price;
|
| //--- Convert point distances into levels when no absolute level was given
|
| const long slPts = AiJsonInt(args, "sl_points", 0);
|
| const long tpPts = AiJsonInt(args, "tp_points", 0);
|
| if(sl <= 0.0 && slPts > 0)
|
| sl = AiLevelFromPoints(sym, refPx, slPts, isBuy);
|
| if(tp <= 0.0 && tpPts > 0)
|
| tp = AiLevelFromPoints(sym, refPx, tpPts, !isBuy);
|
| //--- Normalize prices to the symbol digits
|
| const int digits = (int)SymbolInfoInteger(sym, SYMBOL_DIGITS);
|
| sl = NormalizeDouble(sl, digits);
|
| tp = NormalizeDouble(tp, digits);
|
| price = NormalizeDouble(price, digits);
|
| //--- Reject stops that sit on the wrong side of the entry
|
| if(sl > 0.0 && (isBuy ? (sl >= refPx) : (sl <= refPx)))
|
| return "Blocked: the stop loss must be " + (isBuy ? "below" : "above") + " the entry price.";
|
| if(tp > 0.0 && (isBuy ? (tp <= refPx) : (tp >= refPx)))
|
| return "Blocked: the take profit must be " + (isBuy ? "above" : "below") + " the entry price.";
|
| //--- Dispatch to a market or pending open
|
| bool ok = false;
|
| if(isMarket)
|
| ok = g_ai_trade.PositionOpen(sym, type, lots, 0.0, sl, tp, comment);
|
| else
|
| ok = g_ai_trade.OrderOpen(sym, type, lots, 0.0, price, sl, tp, ORDER_TIME_GTC, 0, comment);
|
| if(!ok)
|
| return "Trade server refused: " + g_ai_trade.ResultRetcodeDescription();
|
| //--- Report the executed order
|
| return "Executed: " + Ai_OrderTypeLabel(type) + " " + AiFormatLots(sym, lots) + " " + sym
|
| + " ticket #" + IntegerToString((long)g_ai_trade.ResultOrder()) + ".";
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Execute a modify_position action |
|
| //+------------------------------------------------------------------+
|
| string AiActionModifyPosition(JsonValue &args)
|
| {
|
| //--- Require and select the target position
|
| const long ticket = AiJsonInt(args, "ticket", 0);
|
| if(ticket <= 0)
|
| return "modify_position needs a ticket.";
|
| if(!PositionSelectByTicket((ulong)ticket))
|
| return "Position #" + IntegerToString(ticket) + " not found.";
|
| //--- Run the safety gate for the position symbol
|
| const string sym = PositionGetString(POSITION_SYMBOL);
|
| string err = AiValidateAction(sym, 0.0, false);
|
| if(StringLen(err) > 0)
|
| return "Blocked: " + err;
|
| //--- Start from the current stop levels
|
| const int dig = (int)SymbolInfoInteger(sym, SYMBOL_DIGITS);
|
| const bool isBuy = (PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY);
|
| double sl = PositionGetDouble(POSITION_SL);
|
| double tp = PositionGetDouble(POSITION_TP);
|
| //--- Apply the breakeven shortcut
|
| if(AiJsonFlag(args, "breakeven", false))
|
| sl = PositionGetDouble(POSITION_PRICE_OPEN);
|
| //--- Apply the trailing-distance shortcut from the current market price
|
| const long trail = AiJsonInt(args, "trail_points", 0);
|
| if(trail > 0)
|
| {
|
| //--- Compute the candidate stop from the relevant market side
|
| const double pt = SymbolInfoDouble(sym, SYMBOL_POINT);
|
| const double px = isBuy ? SymbolInfoDouble(sym, SYMBOL_BID) : SymbolInfoDouble(sym, SYMBOL_ASK);
|
| const double cand = isBuy ? (px - (double)trail * pt) : (px + (double)trail * pt);
|
| //--- Accept it only when it tightens risk, because a trail never moves backwards
|
| const bool improves = (sl <= 0.0) || (isBuy ? (cand > sl) : (cand < sl));
|
| if(!improves)
|
| return "No change: trailing to " + AiFormatPrice(sym, cand)
|
| + " would widen the stop from " + AiFormatPrice(sym, sl) + ".";
|
| sl = cand;
|
| }
|
| //--- Convert point distances measured from the entry price
|
| const double entryPx = PositionGetDouble(POSITION_PRICE_OPEN);
|
| const long mSlPts = AiJsonInt(args, "sl_points", 0);
|
| const long mTpPts = AiJsonInt(args, "tp_points", 0);
|
| if(mSlPts > 0)
|
| sl = AiLevelFromPoints(sym, entryPx, mSlPts, isBuy);
|
| if(mTpPts > 0)
|
| tp = AiLevelFromPoints(sym, entryPx, mTpPts, !isBuy);
|
| //--- Let explicit levels override the shortcuts
|
| if(AiJsonHas(args, "sl"))
|
| sl = AiJsonNum(args, "sl", sl);
|
| if(AiJsonHas(args, "tp"))
|
| tp = AiJsonNum(args, "tp", tp);
|
| sl = NormalizeDouble(sl, dig);
|
| tp = NormalizeDouble(tp, dig);
|
| //--- Push the modification to the trade server
|
| if(!g_ai_trade.PositionModify((ulong)ticket, sl, tp))
|
| return "Trade server refused: " + g_ai_trade.ResultRetcodeDescription();
|
| return "Executed: #" + IntegerToString(ticket) + " " + sym + " now SL " + DoubleToString(sl, dig)
|
| + " TP " + DoubleToString(tp, dig) + ".";
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Resolve the volume slice a close request will actually cut |
|
| //+------------------------------------------------------------------+
|
| double AiResolveCloseSlice(const string sym, const double vol, const double fraction, const double lots)
|
| {
|
| //--- Start from the requested slice
|
| double part = vol;
|
| if(lots > 0.0)
|
| part = lots;
|
| else
|
| if(fraction > 0.0 && fraction < 1.0)
|
| part = vol * fraction;
|
| //--- Snap the slice DOWN to the step so a partial never closes more than asked
|
| const double step = SymbolInfoDouble(sym, SYMBOL_VOLUME_STEP);
|
| const double minLot = SymbolInfoDouble(sym, SYMBOL_VOLUME_MIN);
|
| if(step > 0.0)
|
| part = MathFloor(part / step + 0.0000001) * step;
|
| //--- Clamp into what the broker can actually close
|
| if(part < minLot)
|
| part = minLot;
|
| if(part > vol)
|
| part = vol;
|
| return part;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Close one position fully or partially by ticket |
|
| //+------------------------------------------------------------------+
|
| string AiCloseOneTicket(const ulong ticket, const double fraction, const double lots)
|
| {
|
| //--- Select the target position
|
| if(!PositionSelectByTicket(ticket))
|
| return "Position #" + IntegerToString((long)ticket) + " not found.";
|
| //--- Run the safety gate for the position symbol
|
| const string sym = PositionGetString(POSITION_SYMBOL);
|
| string err = AiValidateAction(sym, 0.0, false);
|
| if(StringLen(err) > 0)
|
| return "Blocked: " + err;
|
| //--- Resolve the volume slice to close
|
| const double vol = PositionGetDouble(POSITION_VOLUME);
|
| double part = AiResolveCloseSlice(sym, vol, fraction, lots);
|
| //--- Full close when the slice covers the whole volume
|
| if(part >= vol - 0.0000001)
|
| {
|
| //--- Submit the full close and report
|
| if(!g_ai_trade.PositionClose(ticket))
|
| return "Trade server refused: " + g_ai_trade.ResultRetcodeDescription();
|
| return "Executed: closed #" + IntegerToString((long)ticket) + " " + sym + " fully ("
|
| + AiFormatLots(sym, vol) + " lots).";
|
| }
|
| //--- Submit the partial close and report the remainder
|
| if(!g_ai_trade.PositionClosePartial(ticket, part))
|
| return "Trade server refused: " + g_ai_trade.ResultRetcodeDescription();
|
| return "Executed: closed " + AiFormatLots(sym, part) + " lots of #" + IntegerToString((long)ticket)
|
| + " " + sym + ", remaining " + AiFormatLots(sym, vol - part) + " lots.";
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Execute a close_position action |
|
| //+------------------------------------------------------------------+
|
| string AiActionClosePosition(JsonValue &args)
|
| {
|
| //--- Read the targeting arguments
|
| const long ticket = AiJsonInt(args, "ticket", 0);
|
| const double fraction = AiJsonNum(args, "fraction", 0.0);
|
| const double lots = AiJsonNum(args, "lots", 0.0);
|
| //--- Ticket targeting closes exactly one position
|
| if(ticket > 0)
|
| return AiCloseOneTicket((ulong)ticket, fraction, lots);
|
| //--- Symbol targeting needs an explicit scope before it may sweep
|
| const string sym = AiActionResolveSymbol(args);
|
| string scope = AiJsonText(args, "scope", "");
|
| StringToLower(scope);
|
| if(scope != "this_ea" && scope != "manual" && scope != "other_eas" && scope != "everything")
|
| return "Blocked: a symbol sweep needs a scope. " + AiOwnershipBreakdown(sym);
|
| //--- Walk every position on the symbol that matches the scope
|
| string outMsg = "";
|
| int closedCount = 0;
|
| for(int i = PositionsTotal() - 1; i >= 0; i--)
|
| {
|
| //--- Select the next candidate position
|
| const ulong t = PositionGetTicket(i);
|
| if(t == 0 || !PositionSelectByTicket(t))
|
| continue;
|
| if(PositionGetString(POSITION_SYMBOL) != sym)
|
| continue;
|
| if(!AiScopeMatches(scope, PositionGetInteger(POSITION_MAGIC)))
|
| continue;
|
| //--- Close the slice and collect the report line
|
| string r = AiCloseOneTicket(t, fraction, lots);
|
| outMsg += (closedCount > 0 ? "\n" : "") + r;
|
| closedCount++;
|
| }
|
| if(closedCount == 0)
|
| return "No " + scope + " positions on " + sym + ".";
|
| return outMsg;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Execute a close_all action |
|
| //+------------------------------------------------------------------+
|
| string AiActionCloseAll(JsonValue &args)
|
| {
|
| //--- Run the safety gate once for the sweep
|
| string err = AiValidateAction(Symbol(), 0.0, false);
|
| if(StringLen(err) > 0)
|
| return "Blocked: " + err;
|
| //--- Read the sweep filter
|
| string filter = AiJsonText(args, "filter", "all");
|
| StringToLower(filter);
|
| //--- Refuse to guess ownership and hand back the choices when no scope was given
|
| string scope = AiJsonText(args, "scope", "");
|
| StringToLower(scope);
|
| if(scope != "this_ea" && scope != "manual" && scope != "other_eas" && scope != "everything")
|
| return "Blocked: close_all needs a scope. "
|
| + AiOwnershipBreakdown(Action_ChartSymbolOnly ? Symbol() : "");
|
| //--- Walk every open position from the end
|
| int closedCount = 0;
|
| double netPl = 0.0;
|
| for(int i = PositionsTotal() - 1; i >= 0; i--)
|
| {
|
| //--- Select the next candidate position
|
| const ulong t = PositionGetTicket(i);
|
| if(t == 0 || !PositionSelectByTicket(t))
|
| continue;
|
| const string sym = PositionGetString(POSITION_SYMBOL);
|
| //--- Apply the symbol restriction and the sweep filter
|
| if(Action_ChartSymbolOnly && sym != Symbol())
|
| continue;
|
| if(filter == "chart_symbol" && sym != Symbol())
|
| continue;
|
| if(!AiScopeMatches(scope, PositionGetInteger(POSITION_MAGIC)))
|
| continue;
|
| const double pl = PositionGetDouble(POSITION_PROFIT);
|
| if(filter == "profit_only" && pl <= 0.0)
|
| continue;
|
| if(filter == "loss_only" && pl >= 0.0)
|
| continue;
|
| //--- Close the position and accumulate the tally
|
| if(g_ai_trade.PositionClose(t))
|
| {
|
| closedCount++;
|
| netPl += pl;
|
| }
|
| }
|
| if(closedCount == 0)
|
| return "No positions matched scope '" + scope + "' with filter '" + filter + "'.";
|
| return "Executed: closed " + IntegerToString(closedCount) + " " + scope
|
| + " position(s), realized P/L about " + DoubleToString(netPl, 2) + ".";
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Execute a modify_order action |
|
| //+------------------------------------------------------------------+
|
| string AiActionModifyOrder(JsonValue &args)
|
| {
|
| //--- Require and select the target pending order
|
| const long ticket = AiJsonInt(args, "ticket", 0);
|
| if(ticket <= 0)
|
| return "modify_order needs a ticket.";
|
| if(!OrderSelect((ulong)ticket))
|
| return "Pending order #" + IntegerToString(ticket) + " not found.";
|
| //--- Run the safety gate for the order symbol
|
| const string sym = OrderGetString(ORDER_SYMBOL);
|
| string err = AiValidateAction(sym, 0.0, false);
|
| if(StringLen(err) > 0)
|
| return "Blocked: " + err;
|
| //--- Start from the current order levels and apply overrides
|
| const int dig = (int)SymbolInfoInteger(sym, SYMBOL_DIGITS);
|
| double price = OrderGetDouble(ORDER_PRICE_OPEN);
|
| double sl = OrderGetDouble(ORDER_SL);
|
| double tp = OrderGetDouble(ORDER_TP);
|
| if(AiJsonHas(args, "price"))
|
| price = AiJsonNum(args, "price", price);
|
| if(AiJsonHas(args, "sl"))
|
| sl = AiJsonNum(args, "sl", sl);
|
| if(AiJsonHas(args, "tp"))
|
| tp = AiJsonNum(args, "tp", tp);
|
| price = NormalizeDouble(price, dig);
|
| sl = NormalizeDouble(sl, dig);
|
| tp = NormalizeDouble(tp, dig);
|
| //--- Push the modification to the trade server
|
| if(!g_ai_trade.OrderModify((ulong)ticket, price, sl, tp, ORDER_TIME_GTC, 0))
|
| return "Trade server refused: " + g_ai_trade.ResultRetcodeDescription();
|
| return "Executed: order #" + IntegerToString(ticket) + " " + sym + " now @ " + DoubleToString(price, dig)
|
| + " SL " + DoubleToString(sl, dig) + " TP " + DoubleToString(tp, dig) + ".";
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Execute a delete_order action |
|
| //+------------------------------------------------------------------+
|
| string AiActionDeleteOrder(JsonValue &args)
|
| {
|
| //--- Require and select the target pending order
|
| const long ticket = AiJsonInt(args, "ticket", 0);
|
| if(ticket <= 0)
|
| return "delete_order needs a ticket.";
|
| if(!OrderSelect((ulong)ticket))
|
| return "Pending order #" + IntegerToString(ticket) + " not found.";
|
| //--- Run the safety gate for the order symbol
|
| const string sym = OrderGetString(ORDER_SYMBOL);
|
| string err = AiValidateAction(sym, 0.0, false);
|
| if(StringLen(err) > 0)
|
| return "Blocked: " + err;
|
| //--- Push the deletion to the trade server
|
| if(!g_ai_trade.OrderDelete((ulong)ticket))
|
| return "Trade server refused: " + g_ai_trade.ResultRetcodeDescription();
|
| return "Executed: deleted pending order #" + IntegerToString(ticket) + " on " + sym + ".";
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Tool: draw a level or label on the chart |
|
| //+------------------------------------------------------------------+
|
| string AiToolDrawOnChart(const string argsJson)
|
| {
|
| //--- Parse the argument object
|
| JsonValue args;
|
| string aj = argsJson;
|
| if(StringLen(aj) == 0)
|
| aj = "{}";
|
| int pIdx = 0;
|
| char pBuf[];
|
| int pLen = StringToCharArray(aj, pBuf, 0, WHOLE_ARRAY, CP_UTF8);
|
| args.DeserializeFromArray(pBuf, pLen, pIdx);
|
| //--- Read the drawing arguments
|
| string kind = AiJsonText(args, "kind", "hline");
|
| StringToLower(kind);
|
| const double price = AiJsonNum(args, "price", 0.0);
|
| const string text = AiJsonText(args, "text", "AI level");
|
| if(price <= 0.0)
|
| return "draw_on_chart needs a positive price.";
|
| //--- Build a unique object name
|
| static int drawId = 0;
|
| drawId++;
|
| const string name = "AI_Draw_" + IntegerToString(drawId) + "_" + IntegerToString((long)TimeCurrent());
|
| //--- Create the requested object with the existing drawing helpers
|
| if(kind == "label")
|
| AiCreateTextLabel(name, TimeCurrent(), price, text, g_ai_accent, ANCHOR_LEFT);
|
| else
|
| AiCreateHLine(name, price, g_ai_accent, STYLE_DASH, text);
|
| ChartRedraw();
|
| return "Drawn " + kind + " at " + AiFormatPrice(Symbol(), price) + ".";
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Execute an approved action by tool name |
|
| //+------------------------------------------------------------------+
|
| string AiExecuteAction(const string toolName, const string argsJson)
|
| {
|
| //--- Parse the argument object
|
| JsonValue args;
|
| string a = argsJson;
|
| if(StringLen(a) == 0)
|
| a = "{}";
|
| int idx = 0;
|
| char buf[];
|
| int len = StringToCharArray(a, buf, 0, WHOLE_ARRAY, CP_UTF8);
|
| args.DeserializeFromArray(buf, len, idx);
|
| //--- Route to the matching executor
|
| if(toolName == "place_order")
|
| return AiActionPlaceOrder(args);
|
| if(toolName == "modify_position")
|
| return AiActionModifyPosition(args);
|
| if(toolName == "close_position")
|
| return AiActionClosePosition(args);
|
| if(toolName == "close_all")
|
| return AiActionCloseAll(args);
|
| if(toolName == "modify_order")
|
| return AiActionModifyOrder(args);
|
| if(toolName == "delete_order")
|
| return AiActionDeleteOrder(args);
|
| return "Unknown action tool: " + toolName;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Describe a stop level as a price and its distance in points |
|
| //+------------------------------------------------------------------+
|
| string AiLevelWithDistance(const string sym, const double level, const double refPrice)
|
| {
|
| //--- Show the bare price when there is nothing to measure against
|
| if(level <= 0.0)
|
| return "none";
|
| if(refPrice <= 0.0)
|
| return AiFormatPrice(sym, level);
|
| //--- Spell the gap in points so a mis-scaled level is obvious on sight
|
| const double pt = SymbolInfoDouble(sym, SYMBOL_POINT);
|
| const long pts = (pt > 0.0) ? (long)MathRound(MathAbs(level - refPrice) / pt) : 0;
|
| return AiFormatPrice(sym, level) + " (" + IntegerToString(pts) + " points from "
|
| + AiFormatPrice(sym, refPrice) + ")";
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Build the owner line for a ticket named by an action |
|
| //+------------------------------------------------------------------+
|
| string AiTicketOwnerLine(const long ticket, const bool isPendingOrder)
|
| {
|
| //--- Say nothing when no ticket was supplied
|
| if(ticket <= 0)
|
| return "";
|
| //--- Read the magic from the order book or the position list
|
| long magic = 0;
|
| if(isPendingOrder)
|
| {
|
| if(!OrderSelect((ulong)ticket))
|
| return "\nOwner: ticket not found";
|
| magic = OrderGetInteger(ORDER_MAGIC);
|
| }
|
| else
|
| {
|
| if(!PositionSelectByTicket((ulong)ticket))
|
| return "\nOwner: ticket not found";
|
| magic = PositionGetInteger(POSITION_MAGIC);
|
| }
|
| //--- Name the owner group in words so approval is informed
|
| const string grp = AiOwnerGroup(magic);
|
| if(grp == "this_ea")
|
| return "\nOwner: opened by this EA";
|
| if(grp == "manual")
|
| return "\nOwner: a hand-placed trade";
|
| return "\nOwner: ANOTHER ROBOT (magic " + IntegerToString(magic) + ")";
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Describe an action in card-ready lines |
|
| //+------------------------------------------------------------------+
|
| string AiDescribeAction(const string toolName, const string argsJson)
|
| {
|
| //--- Parse the argument object
|
| JsonValue args;
|
| string a = argsJson;
|
| if(StringLen(a) == 0)
|
| a = "{}";
|
| int idx = 0;
|
| char buf[];
|
| int len = StringToCharArray(a, buf, 0, WHOLE_ARRAY, CP_UTF8);
|
| args.DeserializeFromArray(buf, len, idx);
|
| string d = "";
|
| //--- Describe a place_order request
|
| if(toolName == "place_order")
|
| {
|
| //--- Compose the order summary lines
|
| string dir = AiJsonText(args, "direction", "?");
|
| string kind = AiJsonText(args, "order_kind", "market");
|
| StringToUpper(dir);
|
| StringToUpper(kind);
|
| const string osym = AiActionResolveSymbol(args);
|
| d = "Order: " + dir + " " + kind + " " + AiFormatLots(osym, AiJsonNum(args, "lots", LotSize))
|
| + " " + osym;
|
| if(AiJsonHas(args, "price"))
|
| d += "\nPrice: " + AiFormatPrice(osym, AiJsonNum(args, "price", 0.0));
|
| //--- Resolve the reference price the point distances are measured from
|
| const bool oBuy = (StringFind(dir, "BUY") >= 0);
|
| const bool oMkt = (StringFind(kind, "MARKET") >= 0);
|
| const double oRef = oMkt
|
| ? (oBuy ? SymbolInfoDouble(osym, SYMBOL_ASK) : SymbolInfoDouble(osym, SYMBOL_BID))
|
| : AiJsonNum(args, "price", 0.0);
|
| const long oSlPts = AiJsonInt(args, "sl_points", 0);
|
| const long oTpPts = AiJsonInt(args, "tp_points", 0);
|
| //--- Resolve each stop the same way the executor will, then show its true distance
|
| double oSl = AiJsonNum(args, "sl", 0.0);
|
| double oTp = AiJsonNum(args, "tp", 0.0);
|
| if(oSl <= 0.0 && oSlPts > 0)
|
| oSl = AiLevelFromPoints(osym, oRef, oSlPts, oBuy);
|
| if(oTp <= 0.0 && oTpPts > 0)
|
| oTp = AiLevelFromPoints(osym, oRef, oTpPts, !oBuy);
|
| if(oSl > 0.0)
|
| d += "\nSL: " + AiLevelWithDistance(osym, oSl, oRef);
|
| if(oTp > 0.0)
|
| d += "\nTP: " + AiLevelWithDistance(osym, oTp, oRef);
|
| }
|
| //--- Describe a modify_position request
|
| else
|
| if(toolName == "modify_position")
|
| {
|
| //--- Compose the modification summary lines
|
| const long mticket = AiJsonInt(args, "ticket", 0);
|
| d = "Position: #" + IntegerToString(mticket) + AiTicketOwnerLine(mticket, false);
|
| //--- Read the position symbol so every level prints at its own precision
|
| string msym = Symbol();
|
| if(mticket > 0 && PositionSelectByTicket((ulong)mticket))
|
| msym = PositionGetString(POSITION_SYMBOL);
|
| if(AiJsonFlag(args, "breakeven", false))
|
| d += "\nMove SL to breakeven";
|
| //--- Spell the trailing distance in points and pips so the size is unambiguous
|
| const long mtrail = AiJsonInt(args, "trail_points", 0);
|
| if(mtrail > 0)
|
| {
|
| //--- Quote pips only where a pip differs from a point, so crypto stays in points
|
| const int ppp = AiPointsPerPip(msym);
|
| d += "\nTrail SL by " + IntegerToString(mtrail) + " points";
|
| if(ppp > 1)
|
| d += " (" + DoubleToString((double)mtrail / (double)ppp, 1) + " pips)";
|
| }
|
| //--- Measure any new level against the entry so a mis-scaled stop stands out
|
| const long mSlPts = AiJsonInt(args, "sl_points", 0);
|
| const long mTpPts = AiJsonInt(args, "tp_points", 0);
|
| const double mEntry = (mticket > 0 && PositionSelectByTicket((ulong)mticket))
|
| ? PositionGetDouble(POSITION_PRICE_OPEN) : 0.0;
|
| double mSl = AiJsonNum(args, "sl", 0.0);
|
| double mTp = AiJsonNum(args, "tp", 0.0);
|
| if(mSl <= 0.0 && mSlPts > 0 && mEntry > 0.0)
|
| mSl = AiLevelFromPoints(msym, mEntry, mSlPts, (PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY));
|
| if(mTp <= 0.0 && mTpPts > 0 && mEntry > 0.0)
|
| mTp = AiLevelFromPoints(msym, mEntry, mTpPts, (PositionGetInteger(POSITION_TYPE) != POSITION_TYPE_BUY));
|
| if(mEntry > 0.0)
|
| d += "\nEntry: " + AiFormatPrice(msym, mEntry);
|
| if(mSl > 0.0)
|
| d += "\nNew SL: " + AiLevelWithDistance(msym, mSl, mEntry);
|
| if(mTp > 0.0)
|
| d += "\nNew TP: " + AiLevelWithDistance(msym, mTp, mEntry);
|
| }
|
| //--- Describe a close_position request
|
| else
|
| if(toolName == "close_position")
|
| {
|
| //--- Compose the close summary lines
|
| const long t = AiJsonInt(args, "ticket", 0);
|
| d = (t > 0) ? ("Position: #" + IntegerToString(t) + AiTicketOwnerLine(t, false))
|
| : ("Positions: sweep on " + AiActionResolveSymbol(args));
|
| if(t <= 0)
|
| {
|
| //--- Name the owner group, because a sweep can reach other robots
|
| string cs = AiJsonText(args, "scope", "");
|
| StringToLower(cs);
|
| if(cs == "this_ea")
|
| d += "\nOwner: trades opened by this EA only";
|
| else
|
| if(cs == "manual")
|
| d += "\nOwner: hand-placed trades only";
|
| else
|
| if(cs == "other_eas")
|
| d += "\nOwner: trades belonging to other robots";
|
| else
|
| if(cs == "everything")
|
| d += "\nOwner: EVERY trade on that symbol";
|
| else
|
| d += "\nOwner: not specified";
|
| }
|
| //--- Resolve the exact slice for a known ticket so approval shows real numbers
|
| const double cfr = AiJsonNum(args, "fraction", 0.0);
|
| const double clt = AiJsonNum(args, "lots", 0.0);
|
| if(t > 0 && PositionSelectByTicket((ulong)t))
|
| {
|
| //--- Compute the same slice the executor will cut
|
| const string csym = PositionGetString(POSITION_SYMBOL);
|
| const double cvol = PositionGetDouble(POSITION_VOLUME);
|
| const double slice = AiResolveCloseSlice(csym, cvol, cfr, clt);
|
| if(slice >= cvol - 0.0000001)
|
| d += "\nClose: full " + AiFormatLots(csym, cvol) + " lots";
|
| else
|
| d += "\nClose: " + AiFormatLots(csym, slice) + " of " + AiFormatLots(csym, cvol)
|
| + " lots (" + AiFormatLots(csym, cvol - slice) + " remains)";
|
| }
|
| else
|
| if(clt > 0.0)
|
| d += "\nClose: " + AiFormatLots(AiActionResolveSymbol(args), clt) + " lots";
|
| else
|
| if(cfr > 0.0)
|
| d += "\nClose: " + DoubleToString(cfr * 100.0, 0) + "% of each position";
|
| else
|
| d += "\nClose: full size";
|
| }
|
| //--- Describe a close_all request
|
| else
|
| if(toolName == "close_all")
|
| {
|
| //--- Compose the sweep summary line
|
| string f = AiJsonText(args, "filter", "all");
|
| StringToLower(f);
|
| if(f == "profit_only")
|
| d = "Results: positions in profit";
|
| else
|
| if(f == "loss_only")
|
| d = "Results: positions in loss";
|
| else
|
| if(f == "chart_symbol")
|
| d = "Results: positions on " + Symbol();
|
| else
|
| d = "Results: every open position";
|
| //--- Name the owner group in words, since this decides whose trades close
|
| string sc = AiJsonText(args, "scope", "");
|
| StringToLower(sc);
|
| if(sc == "this_ea")
|
| d += "\nOwner: trades opened by this EA only";
|
| else
|
| if(sc == "manual")
|
| d += "\nOwner: hand-placed trades only";
|
| else
|
| if(sc == "other_eas")
|
| d += "\nOwner: trades belonging to other robots";
|
| else
|
| if(sc == "everything")
|
| d += "\nOwner: EVERY trade on the account, including other robots";
|
| else
|
| d += "\nOwner: not specified";
|
| //--- Count exactly what the sweep will hit before it runs
|
| int hit = 0;
|
| double hitPl = 0.0;
|
| for(int ci = PositionsTotal() - 1; ci >= 0; ci--)
|
| {
|
| //--- Apply the same tests the executor will apply
|
| const ulong ct = PositionGetTicket(ci);
|
| if(ct == 0 || !PositionSelectByTicket(ct))
|
| continue;
|
| const string csym = PositionGetString(POSITION_SYMBOL);
|
| if(Action_ChartSymbolOnly && csym != Symbol())
|
| continue;
|
| if(f == "chart_symbol" && csym != Symbol())
|
| continue;
|
| const double cpl = PositionGetDouble(POSITION_PROFIT);
|
| if(f == "profit_only" && cpl <= 0.0)
|
| continue;
|
| if(f == "loss_only" && cpl >= 0.0)
|
| continue;
|
| if(!AiScopeMatches(sc, PositionGetInteger(POSITION_MAGIC)))
|
| continue;
|
| hit++;
|
| hitPl += cpl;
|
| }
|
| d += "\nWill close: " + IntegerToString(hit) + " position(s), floating P/L "
|
| + DoubleToString(hitPl, 2);
|
| //--- Name every position that will actually close so nothing is a surprise
|
| int listed = 0;
|
| for(int li = PositionsTotal() - 1; li >= 0 && listed < 8; li--)
|
| {
|
| //--- Repeat the executor tests on this candidate
|
| const ulong lt = PositionGetTicket(li);
|
| if(lt == 0 || !PositionSelectByTicket(lt))
|
| continue;
|
| const string lsym = PositionGetString(POSITION_SYMBOL);
|
| if(Action_ChartSymbolOnly && lsym != Symbol())
|
| continue;
|
| if(f == "chart_symbol" && lsym != Symbol())
|
| continue;
|
| const double lpl = PositionGetDouble(POSITION_PROFIT);
|
| if(f == "profit_only" && lpl <= 0.0)
|
| continue;
|
| if(f == "loss_only" && lpl >= 0.0)
|
| continue;
|
| if(!AiScopeMatches(sc, PositionGetInteger(POSITION_MAGIC)))
|
| continue;
|
| d += "\n #" + IntegerToString((long)lt) + " " + lsym + " "
|
| + ((PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY) ? "BUY" : "SELL") + " "
|
| + AiFormatLots(lsym, PositionGetDouble(POSITION_VOLUME)) + " P/L "
|
| + DoubleToString(lpl, 2) + " ("
|
| + AiOwnerGroup(PositionGetInteger(POSITION_MAGIC)) + ")";
|
| listed++;
|
| }
|
| if(hit > listed)
|
| d += "\n ... and " + IntegerToString(hit - listed) + " more";
|
| }
|
| //--- Describe a modify_order request
|
| else
|
| if(toolName == "modify_order")
|
| {
|
| //--- Compose the pending-order summary lines
|
| const long dticket = AiJsonInt(args, "ticket", 0);
|
| d = "Pending order: #" + IntegerToString(dticket) + AiTicketOwnerLine(dticket, true);
|
| //--- Read the order symbol so every level prints at its own precision
|
| string dsym = Symbol();
|
| if(dticket > 0 && OrderSelect((ulong)dticket))
|
| dsym = OrderGetString(ORDER_SYMBOL);
|
| if(AiJsonHas(args, "price"))
|
| d += "\nNew price: " + AiFormatPrice(dsym, AiJsonNum(args, "price", 0.0));
|
| if(AiJsonHas(args, "sl"))
|
| d += "\nNew SL: " + AiFormatPrice(dsym, AiJsonNum(args, "sl", 0.0));
|
| if(AiJsonHas(args, "tp"))
|
| d += "\nNew TP: " + AiFormatPrice(dsym, AiJsonNum(args, "tp", 0.0));
|
| }
|
| //--- Describe a delete_order request
|
| else
|
| if(toolName == "delete_order")
|
| {
|
| //--- Compose the deletion summary line
|
| const long xticket = AiJsonInt(args, "ticket", 0);
|
| d = "Delete pending order: #" + IntegerToString(xticket) + AiTicketOwnerLine(xticket, true);
|
| }
|
| //--- Fall back to the raw arguments for unknown tools
|
| else
|
| d = "Arguments: " + argsJson;
|
| return d;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Pending Action Queue |
|
| //+------------------------------------------------------------------+
|
| string g_ai_actQueueIds[]; // Tool-call ids of the parked round
|
| string g_ai_actQueueNames[]; // Tool names of the parked round
|
| string g_ai_actQueueArgs[]; // Raw argument JSON of the parked round
|
| string g_ai_actQueueResults[]; // Tool results filled as calls resolve
|
| bool g_ai_actQueueResolved[]; // Per-call resolution flags
|
| int g_ai_actionIndex = -1; // Queue index of the card on screen
|
| string g_ai_actionMessages = ""; // Parked messages JSON without the closing bracket
|
| string g_ai_actionToolsJson = "";// Parked tools schema for the resumed loop
|
| int g_ai_actionRound = 0; // Parked loop round of the interruption
|
| double g_ai_actionTemp = 1.0; // Parked sampling temperature
|
| ulong g_ai_actionShownMs = 0; // Tick count when the current card appeared
|
|
|
| //+------------------------------------------------------------------+
|
| //| Reset the pending action state |
|
| //+------------------------------------------------------------------+
|
| void AiActionReset()
|
| {
|
| //--- Clear the active flag and card text
|
| g_ai_actionActive = false;
|
| g_ai_actionCardText = "";
|
| g_ai_actionChatId = -1;
|
| g_ai_actionIndex = -1;
|
| //--- Drop the parked queue and loop state
|
| ArrayResize(g_ai_actQueueIds, 0);
|
| ArrayResize(g_ai_actQueueNames, 0);
|
| ArrayResize(g_ai_actQueueArgs, 0);
|
| ArrayResize(g_ai_actQueueResults, 0);
|
| ArrayResize(g_ai_actQueueResolved, 0);
|
| g_ai_actionMessages = "";
|
| g_ai_actionToolsJson = "";
|
| g_ai_actionRound = 0;
|
| g_ai_actionTemp = 1.0;
|
| g_ai_actionShownMs = 0;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Find the next unresolved queue index |
|
| //+------------------------------------------------------------------+
|
| int AiActionNextPending()
|
| {
|
| //--- Scan the queue for the first unresolved call
|
| for(int i = 0; i < ArraySize(g_ai_actQueueResolved); i++)
|
| if(!g_ai_actQueueResolved[i])
|
| return i;
|
| return -1;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Build the approval card text for a queue index |
|
| //+------------------------------------------------------------------+
|
| void AiBuildActionCard(const int idx)
|
| {
|
| //--- Guard against invalid indices
|
| if(idx < 0 || idx >= ArraySize(g_ai_actQueueNames))
|
| {
|
| g_ai_actionCardText = "";
|
| return;
|
| }
|
| //--- Compose the header, tool line, description and footer
|
| g_ai_actionCardText = AI_ACTION_CARD_HEADER + "\n"
|
| + "Tool: " + g_ai_actQueueNames[idx] + "\n"
|
| + AiDescribeAction(g_ai_actQueueNames[idx], g_ai_actQueueArgs[idx]) + "\n"
|
| + "Approve to execute or Reject to cancel.";
|
| }
|
|
|
| //--- Set by the send path after capturing the screenshot so AiSendRequest reuses it once
|
| string g_ai_pendingVisionB64 = "";
|
|
|
| //+------------------------------------------------------------------+
|
| //| Run the request loop with context, vision, tools and actions |
|
| //+------------------------------------------------------------------+
|
| string AiRunToolLoop(string messages, string toolsJson, int startRound, int maxRounds, double temperature)
|
| {
|
| //--- Keep the last tool outcome so a silent model turn still reports something
|
| string lastToolSummary = "";
|
| //--- Iterate request rounds until a final text answer arrives
|
| for(int round = startRound; round <= maxRounds; round++)
|
| {
|
| //--- Build the request payload for this round
|
| string req = "{\"model\":\"" + OpenAI_Model + "\",\"messages\":" + messages
|
| + ",\"max_tokens\":" + IntegerToString(MaxResponseLength)
|
| + ",\"temperature\":" + DoubleToString(temperature, 2);
|
| if(StringLen(toolsJson) > 0)
|
| req += ",\"tools\":" + toolsJson + ",\"tool_choice\":\"auto\"";
|
| req += "}";
|
| //--- Encode the payload and headers
|
| char postData[];
|
| int dataLen = StringToCharArray(req, postData, 0, WHOLE_ARRAY, CP_UTF8);
|
| ArrayResize(postData, dataLen - 1);
|
| string headers = "Authorization: Bearer " + OpenAI_API_Key + "\r\n"
|
| + "Content-Type: application/json; charset=UTF-8\r\n"
|
| + "Content-Length: " + IntegerToString(dataLen - 1) + "\r\n\r\n";
|
| //--- Issue the web request
|
| char result[];
|
| string resultHeaders;
|
| int res = WebRequest("POST", OpenAI_Endpoint, headers, 60000, postData, result, resultHeaders);
|
| if(res != 200)
|
| {
|
| //--- Surface the HTTP failure with its body
|
| string body = CharArrayToString(result, 0, WHOLE_ARRAY, CP_UTF8);
|
| return "API request failed: HTTP " + IntegerToString(res) + ", err "
|
| + IntegerToString(GetLastError()) + ", " + body;
|
| }
|
| //--- Parse the JSON response
|
| string response = CharArrayToString(result, 0, WHOLE_ARRAY, CP_UTF8);
|
| JsonValue root;
|
| int index = 0;
|
| char ca[];
|
| int cl = StringToCharArray(response, ca, 0, WHOLE_ARRAY, CP_UTF8);
|
| if(!root.DeserializeFromArray(ca, cl, index))
|
| return "Error: failed to parse API response: " + response;
|
| //--- Bubble API-level errors up to the chat
|
| JsonValue *errNode = root.FindChildByKey("error");
|
| if(errNode != NULL)
|
| return "API Error: " + AiJsonStr(errNode, "message");
|
| //--- Extract the first choice message
|
| JsonValue *choices = root.FindChildByKey("choices");
|
| if(choices == NULL || ArraySize(choices.m_children) == 0)
|
| return "Error: no choices in response: " + response;
|
| JsonValue *msg = choices.m_children[0].FindChildByKey("message");
|
| if(msg == NULL)
|
| return "Error: no message in response: " + response;
|
| //--- Count any tool calls the model made
|
| JsonValue *tcalls = msg.FindChildByKey("tool_calls");
|
| int ncalls = (tcalls == NULL) ? 0 : ArraySize(tcalls.m_children);
|
| //--- Handle the tool-call round
|
| if(ncalls > 0 && round < maxRounds)
|
| {
|
| //--- Rebuild the assistant tool-call message and collect calls
|
| string asstCalls = "[";
|
| string callIds[];
|
| string callNames[];
|
| string callArgs[];
|
| string callResults[];
|
| bool callResolved[];
|
| ArrayResize(callIds, ncalls);
|
| ArrayResize(callNames, ncalls);
|
| ArrayResize(callArgs, ncalls);
|
| ArrayResize(callResults, ncalls);
|
| ArrayResize(callResolved, ncalls);
|
| bool anyPending = false;
|
| for(int i = 0; i < ncalls; i++)
|
| {
|
| //--- Pull the id, name and arguments of this call
|
| JsonValue *tc = GetPointer(tcalls.m_children[i]);
|
| string id = AiJsonStr(tc, "id");
|
| JsonValue *fn = (tc == NULL) ? NULL : tc.FindChildByKey("function");
|
| string fname = AiJsonStr(fn, "name");
|
| string fargs = AiJsonStr(fn, "arguments");
|
| if(StringLen(fargs) == 0)
|
| fargs = "{}";
|
| callIds[i] = id;
|
| callNames[i] = fname;
|
| callArgs[i] = fargs;
|
| callResults[i] = "";
|
| callResolved[i] = false;
|
| if(i > 0)
|
| asstCalls += ",";
|
| asstCalls += "{\"id\":\"" + id + "\",\"type\":\"function\",\"function\":{\"name\":\""
|
| + fname + "\",\"arguments\":\"" + AiJsonEscape(fargs) + "\"}}";
|
| //--- Resolve the call now unless it needs a human decision
|
| if(AiIsActionTool(fname))
|
| {
|
| //--- Auto-approved actions execute inline through the safety gate
|
| if(Action_AutoApprove)
|
| {
|
| callResults[i] = AiExecuteAction(fname, fargs);
|
| callResolved[i] = true;
|
| }
|
| //--- Approval-gated actions stay parked for the card
|
| else
|
| anyPending = true;
|
| }
|
| else
|
| {
|
| //--- Data tools and drawing execute immediately
|
| callResults[i] = AiDispatchTool(fname, fargs);
|
| callResolved[i] = true;
|
| }
|
| }
|
| asstCalls += "]";
|
| string asstMsg = "{\"role\":\"assistant\",\"content\":null,\"tool_calls\":" + asstCalls + "}";
|
| string bodyMsgs = StringSubstr(messages, 0, StringLen(messages) - 1);
|
| //--- Park the round when any action awaits approval
|
| if(anyPending)
|
| {
|
| //--- Snapshot the queue arrays for the resume path
|
| ArrayResize(g_ai_actQueueIds, ncalls);
|
| ArrayResize(g_ai_actQueueNames, ncalls);
|
| ArrayResize(g_ai_actQueueArgs, ncalls);
|
| ArrayResize(g_ai_actQueueResults, ncalls);
|
| ArrayResize(g_ai_actQueueResolved, ncalls);
|
| for(int q = 0; q < ncalls; q++)
|
| {
|
| g_ai_actQueueIds[q] = callIds[q];
|
| g_ai_actQueueNames[q] = callNames[q];
|
| g_ai_actQueueArgs[q] = callArgs[q];
|
| g_ai_actQueueResults[q] = callResults[q];
|
| g_ai_actQueueResolved[q] = callResolved[q];
|
| }
|
| //--- Park the loop position and surface the first card
|
| g_ai_actionMessages = bodyMsgs + "," + asstMsg;
|
| g_ai_actionToolsJson = toolsJson;
|
| g_ai_actionRound = round;
|
| g_ai_actionTemp = temperature;
|
| g_ai_actionIndex = AiActionNextPending();
|
| g_ai_actionChatId = g_ai_currentChatId;
|
| AiBuildActionCard(g_ai_actionIndex);
|
| g_ai_actionShownMs = GetTickCount();
|
| g_ai_actionActive = true;
|
| return AI_ACTION_PENDING_SENTINEL;
|
| }
|
| //--- Keep a readable digest of this round in case the model then stays silent
|
| lastToolSummary = "";
|
| for(int sIdx = 0; sIdx < ncalls; sIdx++)
|
| if(StringLen(callResults[sIdx]) > 0)
|
| lastToolSummary += (StringLen(lastToolSummary) > 0 ? "\n" : "") + callResults[sIdx];
|
| //--- Append every tool result and continue the loop
|
| string toolResults = "";
|
| for(int r = 0; r < ncalls; r++)
|
| toolResults += ",{\"role\":\"tool\",\"tool_call_id\":\"" + callIds[r] + "\",\"content\":\""
|
| + AiJsonEscape(callResults[r]) + "\"}";
|
| messages = bodyMsgs + "," + asstMsg + toolResults + "]";
|
| continue;
|
| }
|
| //--- Extract the final text content and normalize it before judging it
|
| JsonValue *content = msg.FindChildByKey("content");
|
| string outText = (content == NULL) ? "" : content.ToString();
|
| if(outText == "null")
|
| outText = "";
|
| StringReplace(outText, "\\n", "\n");
|
| StringTrimLeft(outText);
|
| StringTrimRight(outText);
|
| if(StringLen(outText) > 0)
|
| return outText;
|
| //--- Summarize the tools instead of returning a blank turn
|
| if(StringLen(lastToolSummary) > 0)
|
| return lastToolSummary;
|
| return "The model returned no text for this turn.";
|
| }
|
| return "Error: tool-call loop exceeded " + IntegerToString(maxRounds) + " rounds.";
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Send a unified request with context, vision, tools and actions |
|
| //+------------------------------------------------------------------+
|
| string AiSendRequest(string prompt, double temperature = 1.0)
|
| {
|
| //--- Build the live market context system message
|
| string ctx = AiBuildMarketContext();
|
| //--- Include vision content only when the user attached a chart for this message
|
| string visionContent = "";
|
| if(StringLen(g_ai_pendingVisionB64) > 0)
|
| {
|
| visionContent = AiBuildVisionContent(AiJsonEscape(prompt), g_ai_pendingVisionB64);
|
| g_ai_pendingVisionB64 = "";
|
| }
|
| //--- Assemble the messages array and the advertised tools
|
| string messages = AiBuildMessagesFromHistory(prompt, ctx, visionContent);
|
| string toolsJson = AiToolsDefinitionJson();
|
| //--- Clamp the tool-round budget
|
| int maxRounds = AI_Tool_Rounds;
|
| if(maxRounds < 1)
|
| maxRounds = 1;
|
| if(maxRounds > 8)
|
| maxRounds = 8;
|
| //--- Run the shared request loop from the first round
|
| return AiRunToolLoop(messages, toolsJson, 0, maxRounds, temperature);
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Resume the parked request loop after approvals |
|
| //+------------------------------------------------------------------+
|
| string AiResumeToolLoop()
|
| {
|
| //--- Reassemble the parked messages with every tool result
|
| string messages = g_ai_actionMessages;
|
| for(int i = 0; i < ArraySize(g_ai_actQueueIds); i++)
|
| messages += ",{\"role\":\"tool\",\"tool_call_id\":\"" + g_ai_actQueueIds[i] + "\",\"content\":\""
|
| + AiJsonEscape(g_ai_actQueueResults[i]) + "\"}";
|
| messages += "]";
|
| //--- Snapshot the parked loop position before clearing state
|
| const string toolsJson = g_ai_actionToolsJson;
|
| const int nextRound = g_ai_actionRound + 1;
|
| const double temp = g_ai_actionTemp;
|
| //--- Clear the pending state so the resumed loop can park again cleanly
|
| AiActionReset();
|
| //--- Clamp the tool-round budget
|
| int maxRounds = AI_Tool_Rounds;
|
| if(maxRounds < 1)
|
| maxRounds = 1;
|
| if(maxRounds > 8)
|
| maxRounds = 8;
|
| //--- Continue the shared request loop where it stopped
|
| return AiRunToolLoop(messages, toolsJson, nextRound, maxRounds, temp);
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Replace the loading placeholder with final text |
|
| //+------------------------------------------------------------------+
|
| void AiReplaceLoadingPlaceholder(const string finalText, const string timestamp)
|
| {
|
| //--- Locate the thinking placeholder block
|
| int placeholderPos = StringFind(g_ai_conversationHistory, AI_LOADING_PLACEHOLDER, 0);
|
| if(placeholderPos >= 0)
|
| {
|
| //--- Splice the final text into the placeholder slot
|
| int endPos = StringFind(g_ai_conversationHistory, "\n\n", placeholderPos) + 2;
|
| if(endPos < 2)
|
| endPos = StringLen(g_ai_conversationHistory);
|
| string before = StringSubstr(g_ai_conversationHistory, 0, placeholderPos);
|
| string after = StringSubstr(g_ai_conversationHistory, endPos);
|
| g_ai_conversationHistory = before + finalText + "\n" + timestamp + "\n\n" + after;
|
| }
|
| else
|
| {
|
| //--- Append when no placeholder exists
|
| g_ai_conversationHistory += finalText + "\n" + timestamp + "\n\n";
|
| }
|
| //--- Keep the chat pinned to the newest message
|
| g_ai_chatScrollPin = true;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Annotate the on-screen approval card with a status |
|
| //+------------------------------------------------------------------+
|
| void AiAnnotateActionCard(const string status)
|
| {
|
| //--- Find the last occurrence of the current card text
|
| if(StringLen(g_ai_actionCardText) == 0)
|
| return;
|
| int pos = -1;
|
| int probe = StringFind(g_ai_conversationHistory, g_ai_actionCardText, 0);
|
| while(probe >= 0)
|
| {
|
| pos = probe;
|
| probe = StringFind(g_ai_conversationHistory, g_ai_actionCardText, probe + 1);
|
| }
|
| if(pos < 0)
|
| return;
|
| //--- Insert the status line right after the card text
|
| const int endPos = pos + StringLen(g_ai_actionCardText);
|
| string before = StringSubstr(g_ai_conversationHistory, 0, endPos);
|
| string after = StringSubstr(g_ai_conversationHistory, endPos);
|
| g_ai_conversationHistory = before + "\n" + status + after;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Resolve the on-screen approval card |
|
| //+------------------------------------------------------------------+
|
| void AiResolvePendingAction(const bool approved)
|
| {
|
| //--- Guard against stale or foreign-chat clicks
|
| if(!g_ai_actionActive)
|
| return;
|
| if(g_ai_actionChatId != g_ai_currentChatId)
|
| return;
|
| const int idx = g_ai_actionIndex;
|
| if(idx < 0 || idx >= ArraySize(g_ai_actQueueNames))
|
| return;
|
| //--- Ignore an already-decided call so a queued click cannot resolve it twice
|
| if(g_ai_actQueueResolved[idx])
|
| return;
|
| //--- Ignore clicks queued before this card appeared so none is approved unseen
|
| if(GetTickCount() - g_ai_actionShownMs < AI_ACTION_ARM_DELAY_MS)
|
| return;
|
| //--- Execute or refuse the parked action
|
| string result = "";
|
| if(approved)
|
| result = AiExecuteAction(g_ai_actQueueNames[idx], g_ai_actQueueArgs[idx]);
|
| else
|
| result = "Rejected by the user. The action was not executed.";
|
| g_ai_actQueueResults[idx] = result;
|
| g_ai_actQueueResolved[idx] = true;
|
| //--- Log the decision alongside its outcome
|
| Print("AI Action ", g_ai_actQueueNames[idx], (approved ? " approved: " : " rejected: "), result);
|
| if(g_ai_logFileHandle != INVALID_HANDLE)
|
| FileWrite(g_ai_logFileHandle, "AI Action " + g_ai_actQueueNames[idx]
|
| + (approved ? " approved: " : " rejected: ") + result);
|
| //--- Annotate the card in the transcript and notify via toast
|
| AiAnnotateActionCard(approved ? ("-> Approved. " + result) : "-> Rejected by the user.");
|
| Ai_ShowToast(approved ? "Action approved" : "Action rejected", false);
|
| //--- Build a timestamp for the follow-up messages
|
| string timestamp = TimeToString(TimeCurrent(), TIME_MINUTES);
|
| //--- Surface the next parked action as a fresh card
|
| const int nxt = AiActionNextPending();
|
| if(nxt >= 0)
|
| {
|
| //--- Point the card at the next call and append it to the chat
|
| g_ai_actionIndex = nxt;
|
| AiBuildActionCard(nxt);
|
| g_ai_actionShownMs = GetTickCount();
|
| g_ai_conversationHistory += "AI: " + g_ai_actionCardText + "\n" + timestamp + "\n\n";
|
| g_ai_chatScrollPin = true;
|
| AiUpdateCurrentHistory();
|
| Ai_RenderAll();
|
| ChartRedraw();
|
| return;
|
| }
|
| //--- Show the thinking placeholder while the loop resumes
|
| g_ai_conversationHistory += AI_LOADING_PLACEHOLDER + "\n" + timestamp + "\n\n";
|
| g_ai_chatScrollPin = true;
|
| Ai_RenderAll();
|
| ChartRedraw();
|
| //--- Resume the request loop and time it
|
| g_ai_startTimeMs = GetTickCount();
|
| string response = AiResumeToolLoop();
|
| ulong elapsedMs = GetTickCount() - g_ai_startTimeMs;
|
| string timeNote = "\n(Response in " + IntegerToString((int)(elapsedMs / 1000)) + "s)";
|
| //--- Swap in the next approval card when the model chained another action
|
| if(response == AI_ACTION_PENDING_SENTINEL)
|
| {
|
| response = g_ai_actionCardText;
|
| timeNote = "";
|
| }
|
| //--- Fall back to the action outcome when the model adds no words of its own
|
| StringTrimLeft(response);
|
| StringTrimRight(response);
|
| if(StringLen(response) == 0)
|
| response = result;
|
| //--- Log the follow-up response
|
| Print("AI: ", response);
|
| if(g_ai_logFileHandle != INVALID_HANDLE)
|
| FileWrite(g_ai_logFileHandle, "AI: " + response);
|
| //--- Publish the response, persist and repaint
|
| AiReplaceLoadingPlaceholder("AI: " + response + timeNote, timestamp);
|
| AiUpdateCurrentHistory();
|
| Ai_RenderAll();
|
| ChartRedraw();
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Save chats to encrypted compressed file |
|
| //+------------------------------------------------------------------+
|
| bool AiSaveChats()
|
| {
|
| //--- Build JSON array from chats
|
| JsonValue jsonArr;
|
| jsonArr.m_type = JsonArray;
|
| for(int i = 0; i < ArraySize(g_ai_chats); i++)
|
| {
|
| JsonValue obj;
|
| obj.m_type = JsonObject;
|
| obj["id"] = g_ai_chats[i].id;
|
| obj["title"] = g_ai_chats[i].title;
|
| obj["history"] = g_ai_chats[i].history;
|
| jsonArr.AddChild(obj);
|
| }
|
| //--- Serialize to string
|
| string jsonStr = jsonArr.SerializeToString();
|
| uchar data[];
|
| StringToCharArray(jsonStr, data);
|
| //--- Compress with ZIP
|
| uchar empty_key[];
|
| uchar zipped[];
|
| int res_zip = CryptEncode(CRYPT_ARCH_ZIP, data, empty_key, zipped);
|
| if(res_zip <= 0)
|
| {
|
| Print("Failed to compress chats: ", GetLastError());
|
| return false;
|
| }
|
| //--- Derive AES key from API key SHA-256 hash
|
| uchar key[32];
|
| uchar api_bytes[];
|
| StringToCharArray(OpenAI_API_Key, api_bytes);
|
| uchar hash[];
|
| CryptEncode(CRYPT_HASH_SHA256, api_bytes, empty_key, hash);
|
| ArrayCopy(key, hash, 0, 0, 32);
|
| //--- Encrypt with AES256
|
| uchar encoded[];
|
| int res_enc = CryptEncode(CRYPT_AES256, zipped, key, encoded);
|
| if(res_enc <= 0)
|
| {
|
| Print("Failed to encrypt chats: ", GetLastError());
|
| return false;
|
| }
|
| //--- Write to disk
|
| int handle = FileOpen(g_ai_chatsFileName, FILE_WRITE | FILE_BIN);
|
| if(handle == INVALID_HANDLE)
|
| {
|
| Print("Failed to save chats: ", GetLastError());
|
| return false;
|
| }
|
| FileWriteArray(handle, encoded, 0, res_enc);
|
| FileClose(handle);
|
| return true;
|
| }
|
|
|
| //--- Forward declarations for inter-dependent functions
|
| void AiCreateNewChat();
|
| bool AiUpdateCurrentHistory();
|
|
|
| //+------------------------------------------------------------------+
|
| //| Load chats from encrypted compressed file |
|
| //+------------------------------------------------------------------+
|
| void AiLoadChats()
|
| {
|
| //--- Create new chat if file missing
|
| if(!FileIsExist(g_ai_chatsFileName))
|
| {
|
| AiCreateNewChat();
|
| return;
|
| }
|
| //--- Open file for binary read
|
| int handle = FileOpen(g_ai_chatsFileName, FILE_READ | FILE_BIN);
|
| if(handle == INVALID_HANDLE)
|
| {
|
| Print("Failed to load chats: ", GetLastError());
|
| AiCreateNewChat();
|
| return;
|
| }
|
| //--- Read encrypted bytes
|
| int file_size = (int)FileSize(handle);
|
| uchar encoded_file[];
|
| ArrayResize(encoded_file, file_size);
|
| FileReadArray(handle, encoded_file, 0, file_size);
|
| FileClose(handle);
|
| //--- Derive AES key from API key
|
| uchar empty_key[];
|
| uchar key[32];
|
| uchar api_bytes[];
|
| StringToCharArray(OpenAI_API_Key, api_bytes);
|
| uchar hash[];
|
| CryptEncode(CRYPT_HASH_SHA256, api_bytes, empty_key, hash);
|
| ArrayCopy(key, hash, 0, 0, 32);
|
| //--- Decrypt AES
|
| uchar decoded_aes[];
|
| int res_dec = CryptDecode(CRYPT_AES256, encoded_file, key, decoded_aes);
|
| if(res_dec <= 0)
|
| {
|
| Print("Failed to decrypt chats: ", GetLastError());
|
| AiCreateNewChat();
|
| return;
|
| }
|
| //--- Decompress ZIP
|
| uchar decoded_zip[];
|
| int res_zip = CryptDecode(CRYPT_ARCH_ZIP, decoded_aes, empty_key, decoded_zip);
|
| if(res_zip <= 0)
|
| {
|
| Print("Failed to decompress chats: ", GetLastError());
|
| AiCreateNewChat();
|
| return;
|
| }
|
| //--- Parse JSON
|
| string jsonStr = CharArrayToString(decoded_zip);
|
| char charArray[];
|
| int len = StringToCharArray(jsonStr, charArray, 0, WHOLE_ARRAY, CP_UTF8);
|
| JsonValue json;
|
| int index = 0;
|
| if(!json.DeserializeFromArray(charArray, len, index))
|
| {
|
| Print("Failed to parse chats JSON");
|
| AiCreateNewChat();
|
| return;
|
| }
|
| if(json.m_type != JsonArray)
|
| {
|
| Print("Chats JSON not an array");
|
| AiCreateNewChat();
|
| return;
|
| }
|
| //--- Populate chats array
|
| int size = ArraySize(json.m_children);
|
| ArrayResize(g_ai_chats, size);
|
| for(int i = 0; i < size; i++)
|
| {
|
| JsonValue obj = json.m_children[i];
|
| g_ai_chats[i].id = (int)obj["id"].ToInteger();
|
| g_ai_chats[i].title = obj["title"].ToString();
|
| g_ai_chats[i].history = obj["history"].ToString();
|
| }
|
| //--- Activate last chat or create fresh
|
| if(size > 0)
|
| {
|
| g_ai_currentChatId = g_ai_chats[size - 1].id;
|
| g_ai_currentTitle = g_ai_chats[size - 1].title;
|
| g_ai_conversationHistory = g_ai_chats[size - 1].history;
|
| }
|
| else
|
| {
|
| AiCreateNewChat();
|
| }
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Create new empty chat |
|
| //+------------------------------------------------------------------+
|
| void AiCreateNewChat()
|
| {
|
| //--- Compute next chat id
|
| int max_id = 0;
|
| for(int i = 0; i < ArraySize(g_ai_chats); i++)
|
| max_id = MathMax(max_id, g_ai_chats[i].id);
|
| int new_id = max_id + 1;
|
| //--- Append new chat record
|
| int size = ArraySize(g_ai_chats);
|
| ArrayResize(g_ai_chats, size + 1);
|
| g_ai_chats[size].id = new_id;
|
| g_ai_chats[size].title = "Chat " + IntegerToString(new_id);
|
| g_ai_chats[size].history = "";
|
| //--- Set as active
|
| g_ai_currentChatId = new_id;
|
| g_ai_currentTitle = g_ai_chats[size].title;
|
| g_ai_conversationHistory = "";
|
| g_ai_currentPrompt = "";
|
| //--- Drop any approval card parked in the previous chat
|
| AiActionReset();
|
| //--- Persist and refresh
|
| AiSaveChats();
|
| g_ai_editor.SetText("");
|
| Ai_RenderAll();
|
| ChartRedraw();
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Update current chat record in chats array |
|
| //+------------------------------------------------------------------+
|
| bool AiUpdateCurrentHistory()
|
| {
|
| //--- Find current chat index
|
| int idx = AiGetChatIndex(g_ai_currentChatId);
|
| bool savedOk = true;
|
| //--- Update record and save
|
| if(idx >= 0)
|
| {
|
| g_ai_chats[idx].history = g_ai_conversationHistory;
|
| g_ai_chats[idx].title = g_ai_currentTitle;
|
| savedOk = AiSaveChats();
|
| }
|
| //--- Pin chat scroll to bottom for new content
|
| g_ai_chatScrollPin = true;
|
| return savedOk;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Reopen log file handle in append mode |
|
| //+------------------------------------------------------------------+
|
| void AiReopenLogHandle()
|
| {
|
| //--- Open log file for read/write
|
| g_ai_logFileHandle = FileOpen(AI_LOG_DIR + LogFileName, FILE_READ | FILE_WRITE | FILE_TXT);
|
| if(g_ai_logFileHandle != INVALID_HANDLE)
|
| FileSeek(g_ai_logFileHandle, 0, SEEK_END);
|
| else
|
| Print("Failed to reopen log: ", GetLastError());
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Clear log entries belonging to a deleted chat |
|
| //+------------------------------------------------------------------+
|
| void AiClearLogsForChat(int del_id)
|
| {
|
| //--- Close current handle before rewriting
|
| if(g_ai_logFileHandle != INVALID_HANDLE)
|
| FileClose(g_ai_logFileHandle);
|
| //--- Open temp output file
|
| string tempFile = AI_LOG_DIR + "Temp_Log.txt";
|
| int readHandle = FileOpen(AI_LOG_DIR + LogFileName, FILE_READ | FILE_TXT);
|
| if(readHandle == INVALID_HANDLE)
|
| {
|
| Print("Failed to open log for reading: ", GetLastError());
|
| AiReopenLogHandle();
|
| return;
|
| }
|
| int writeHandle = FileOpen(tempFile, FILE_WRITE | FILE_TXT);
|
| if(writeHandle == INVALID_HANDLE)
|
| {
|
| Print("Failed to open temp log: ", GetLastError());
|
| FileClose(readHandle);
|
| AiReopenLogHandle();
|
| return;
|
| }
|
| //--- Stream lines, skipping the deleted chat's block
|
| bool skipBlock = false;
|
| while(!FileIsEnding(readHandle))
|
| {
|
| string line = FileReadString(readHandle);
|
| if(StringFind(line, "Chat ID: ") == 0)
|
| {
|
| skipBlock = false;
|
| int commaPos = StringFind(line, ", Title: ");
|
| if(commaPos > 0)
|
| {
|
| string idStr = StringSubstr(line, 9, commaPos - 9);
|
| int chatId = (int)StringToInteger(idStr);
|
| if(chatId == del_id)
|
| skipBlock = true;
|
| }
|
| }
|
| if(!skipBlock)
|
| FileWrite(writeHandle, line);
|
| }
|
| //--- Close, swap files, reopen
|
| FileClose(readHandle);
|
| FileClose(writeHandle);
|
| FileDelete(AI_LOG_DIR + LogFileName);
|
| FileMove(tempFile, 0, AI_LOG_DIR + LogFileName, 0);
|
| AiReopenLogHandle();
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Delete screenshot files referenced in a transcript |
|
| //+------------------------------------------------------------------+
|
| void Ai_DeleteChatImages(const string history)
|
| {
|
| int pos = 0;
|
| while(true)
|
| {
|
| int ip = StringFind(history, "[img:", pos);
|
| if(ip < 0)
|
| break;
|
| int ep = StringFind(history, "]", ip);
|
| if(ep < 0)
|
| break;
|
| string inner = StringSubstr(history, ip + 5, ep - (ip + 5)); // base:w:h
|
| string parts[];
|
| if(StringSplit(inner, ':', parts) >= 1 && StringLen(parts[0]) > 0)
|
| {
|
| FileDelete(AI_IMG_DIR + parts[0] + ".png");
|
| FileDelete(AI_IMG_DIR + parts[0] + ".bmp");
|
| }
|
| pos = ep + 1;
|
| }
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Delete a chat by id |
|
| //+------------------------------------------------------------------+
|
| bool AiDeleteChat(int id)
|
| {
|
| //--- Find chat index
|
| int idx = AiGetChatIndex(id);
|
| if(idx < 0)
|
| return false;
|
| //--- Drop any approval card parked in the deleted chat
|
| if(g_ai_actionChatId == id)
|
| AiActionReset();
|
| //--- Remove this chat's screenshots from disk before dropping the record
|
| Ai_DeleteChatImages(g_ai_chats[idx].history);
|
| //--- If deleting active chat, switch to neighbor or fresh
|
| if(g_ai_currentChatId == id)
|
| {
|
| if(ArraySize(g_ai_chats) > 1)
|
| {
|
| int new_idx = (idx == ArraySize(g_ai_chats) - 1) ? idx - 1 : ArraySize(g_ai_chats) - 1;
|
| g_ai_currentChatId = g_ai_chats[new_idx].id;
|
| g_ai_currentTitle = g_ai_chats[new_idx].title;
|
| g_ai_conversationHistory = g_ai_chats[new_idx].history;
|
| }
|
| else
|
| {
|
| //--- Last chat - remove it first, then replace with a fresh empty one
|
| ArrayRemove(g_ai_chats, idx, 1);
|
| AiCreateNewChat();
|
| return true;
|
| }
|
| }
|
| //--- Remove record
|
| ArrayRemove(g_ai_chats, idx, 1);
|
| //--- Optionally clear log entries
|
| if(DeleteLogsOnChatDelete)
|
| AiClearLogsForChat(id);
|
| //--- Persist and re-render
|
| const bool savedOk = AiSaveChats();
|
| Ai_RenderAll();
|
| ChartRedraw();
|
| return savedOk;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Strip trailing whitespace-only lines from a string |
|
| //+------------------------------------------------------------------+
|
| string AiStripTrailingBlankLines(string s)
|
| {
|
| //--- Walk back from end while whitespace
|
| int len = StringLen(s);
|
| while(len > 0)
|
| {
|
| ushort ch = StringGetCharacter(s, len - 1);
|
| if(ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r')
|
| len--;
|
| else
|
| break;
|
| }
|
| if(len <= 0)
|
| return "";
|
| return StringSubstr(s, 0, len);
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Strip leading whitespace-only lines from a string |
|
| //+------------------------------------------------------------------+
|
| string AiStripLeadingBlankLines(string s)
|
| {
|
| //--- Walk forward from start while whitespace
|
| const int len = StringLen(s);
|
| int start = 0;
|
| while(start < len)
|
| {
|
| const ushort ch = StringGetCharacter(s, start);
|
| if(ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r')
|
| start++;
|
| else
|
| break;
|
| }
|
| if(start >= len)
|
| return "";
|
| return StringSubstr(s, start);
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Get the most recent user prompt from conversation history |
|
| //+------------------------------------------------------------------+
|
| string AiGetLastUserPrompt()
|
| {
|
| //--- Split history into lines
|
| string lines[];
|
| const int num_lines = StringSplit(g_ai_conversationHistory, '\n', lines);
|
| if(num_lines == 0)
|
| return "";
|
| //--- Find the most recent "You: " line
|
| int userStart = -1;
|
| for(int i = num_lines - 1; i >= 0; i--)
|
| {
|
| string t = lines[i];
|
| StringTrimLeft(t);
|
| StringTrimRight(t);
|
| if(StringFind(t, "You: ") == 0)
|
| {
|
| userStart = i;
|
| break;
|
| }
|
| }
|
| if(userStart < 0)
|
| return "";
|
| //--- Extract first content line
|
| string firstLine = lines[userStart];
|
| const int prefixPos = StringFind(firstLine, "You: ");
|
| string prompt = (prefixPos >= 0) ? StringSubstr(firstLine, prefixPos + 5) : firstLine;
|
| //--- Append continuation lines until block boundary
|
| for(int j = userStart + 1; j < num_lines; j++)
|
| {
|
| string raw = lines[j];
|
| string t = raw;
|
| StringTrimLeft(t);
|
| StringTrimRight(t);
|
| if(AiIsTimestamp(t))
|
| break;
|
| if(StringFind(t, "You: ") == 0)
|
| break;
|
| if(StringFind(t, "AI: ") == 0)
|
| break;
|
| prompt += "\n" + raw;
|
| }
|
| //--- Strip surrounding blank lines
|
| prompt = AiStripLeadingBlankLines(prompt);
|
| return AiStripTrailingBlankLines(prompt);
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Get the most recent AI response from conversation history |
|
| //+------------------------------------------------------------------+
|
| string AiGetLastAIResponse()
|
| {
|
| //--- Split history into lines
|
| string lines[];
|
| int num_lines = StringSplit(g_ai_conversationHistory, '\n', lines);
|
| if(num_lines == 0)
|
| return "";
|
| //--- Find the most recent "AI: " line
|
| int ai_start = -1;
|
| for(int i = num_lines - 1; i >= 0; i--)
|
| {
|
| string trimmed = lines[i];
|
| StringTrimLeft(trimmed);
|
| StringTrimRight(trimmed);
|
| if(StringFind(trimmed, "AI: ") == 0)
|
| {
|
| ai_start = i;
|
| break;
|
| }
|
| }
|
| if(ai_start == -1)
|
| return "";
|
| //--- Extract first content line
|
| string response_build = "";
|
| string first_line = lines[ai_start];
|
| int prefix_pos = StringFind(first_line, "AI: ");
|
| if(prefix_pos >= 0)
|
| {
|
| first_line = StringSubstr(first_line, prefix_pos + 4);
|
| StringTrimLeft(first_line);
|
| StringTrimRight(first_line);
|
| if(StringLen(first_line) > 0
|
| && StringFind(first_line, "(Response in ") != 0
|
| && StringFind(first_line, "(Regenerated in ") != 0
|
| && !AiIsTimestamp(first_line))
|
| {
|
| response_build = first_line;
|
| }
|
| }
|
| //--- Append continuation lines until block boundary
|
| for(int j = ai_start + 1; j < num_lines; j++)
|
| {
|
| string orig_line = lines[j];
|
| string trimmed = orig_line;
|
| StringTrimLeft(trimmed);
|
| StringTrimRight(trimmed);
|
| if(StringFind(trimmed, "You: ") == 0 || StringFind(trimmed, "AI: ") == 0)
|
| break;
|
| if(StringFind(trimmed, "(Response in ") == 0
|
| || StringFind(trimmed, "(Regenerated in ") == 0
|
| || AiIsTimestamp(trimmed))
|
| continue;
|
| if(response_build != "")
|
| response_build += "\n";
|
| response_build += orig_line;
|
| }
|
| return response_build;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Remove the last AI response block |
|
| //+------------------------------------------------------------------+
|
| void AiRemoveLastAIResponse()
|
| {
|
| //--- Split into double-newline blocks
|
| string blocks[];
|
| int num_blocks = AiSplitOnString(g_ai_conversationHistory, "\n\n", blocks);
|
| if(num_blocks == 0)
|
| return;
|
| //--- Drop trailing AI block if present
|
| if(StringFind(blocks[num_blocks - 1], "AI: ") == 0)
|
| {
|
| string new_history = "";
|
| for(int i = 0; i < num_blocks - 1; i++)
|
| {
|
| if(new_history != "")
|
| new_history += "\n\n";
|
| new_history += blocks[i];
|
| }
|
| g_ai_conversationHistory = new_history;
|
| }
|
| //--- Persist updated history
|
| AiUpdateCurrentHistory();
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Remove the most recent user+AI turn pair |
|
| //+------------------------------------------------------------------+
|
| void AiRemoveLastConversationTurn()
|
| {
|
| //--- Split history into lines
|
| string lines[];
|
| const int num_lines = StringSplit(g_ai_conversationHistory, '\n', lines);
|
| if(num_lines == 0)
|
| return;
|
| //--- Find last "AI: " line
|
| int aiStart = -1;
|
| for(int i = num_lines - 1; i >= 0; i--)
|
| {
|
| string t = lines[i];
|
| StringTrimLeft(t);
|
| StringTrimRight(t);
|
| if(StringFind(t, "AI: ") == 0)
|
| {
|
| aiStart = i;
|
| break;
|
| }
|
| }
|
| //--- Find matching "You: " line before it
|
| int youStart = -1;
|
| const int searchUpTo = (aiStart < 0) ? num_lines - 1 : aiStart - 1;
|
| for(int j = searchUpTo; j >= 0; j--)
|
| {
|
| string t = lines[j];
|
| StringTrimLeft(t);
|
| StringTrimRight(t);
|
| if(StringFind(t, "You: ") == 0)
|
| {
|
| youStart = j;
|
| break;
|
| }
|
| }
|
| if(youStart < 0)
|
| return;
|
| //--- Strip trailing blank lines from kept range
|
| int keepEnd = youStart;
|
| while(keepEnd > 0)
|
| {
|
| string t = lines[keepEnd - 1];
|
| StringTrimLeft(t);
|
| StringTrimRight(t);
|
| if(StringLen(t) == 0)
|
| keepEnd--;
|
| else
|
| break;
|
| }
|
| //--- Rebuild history from kept lines
|
| string new_history = "";
|
| for(int k = 0; k < keepEnd; k++)
|
| {
|
| if(k > 0)
|
| new_history += "\n";
|
| new_history += lines[k];
|
| }
|
| //--- Append separator for next message
|
| if(StringLen(new_history) > 0)
|
| new_history += "\n\n";
|
| g_ai_conversationHistory = new_history;
|
| AiUpdateCurrentHistory();
|
| }
|
|
|
| //--- Object name prefix for all AI signal drawings
|
| #define AI_SIGNAL_OBJ_PREFIX "AI_Signal_"
|
|
|
| //+------------------------------------------------------------------+
|
| //| Compute volatility-based price buffer |
|
| //+------------------------------------------------------------------+
|
| double Ai_DynamicBuffer(int bars = 14, double fraction = 0.2)
|
| {
|
| //--- Guard against invalid input
|
| if(bars <= 0)
|
| return 10.0 * _Point;
|
| //--- Pull recent bars
|
| MqlRates r[];
|
| int copied = CopyRates(Symbol(), Period(), 1, bars, r);
|
| if(copied <= 0)
|
| return 10.0 * _Point;
|
| //--- Compute average bar range
|
| double sum = 0.0;
|
| for(int i = 0; i < copied; i++)
|
| sum += (r[i].high - r[i].low);
|
| double avgRange = sum / (double)copied;
|
| double buf = avgRange * fraction;
|
| //--- Floor at one tick
|
| const double minBuf = SymbolInfoDouble(Symbol(), SYMBOL_TRADE_TICK_SIZE);
|
| if(buf < minBuf)
|
| buf = minBuf;
|
| return buf;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Place market or pending order with broker validation |
|
| //+------------------------------------------------------------------+
|
| void Ai_PlaceOrder(ENUM_ORDER_TYPE orderType, double triggerPrice,
|
| double sl, double tp, string comment,
|
| bool &success, ulong &ticket, string &errorMsg)
|
| {
|
| //--- Initialize outputs
|
| success = false;
|
| ticket = 0;
|
| errorMsg = "";
|
| //--- Read symbol info
|
| const string sym = Symbol();
|
| const double bid = SymbolInfoDouble(sym, SYMBOL_BID);
|
| const double ask = SymbolInfoDouble(sym, SYMBOL_ASK);
|
| const double pt = SymbolInfoDouble(sym, SYMBOL_POINT);
|
| const long stopsLvl = SymbolInfoInteger(sym, SYMBOL_TRADE_STOPS_LEVEL);
|
| const double minDist = (double)stopsLvl * pt;
|
| //--- Validate pending order trigger direction
|
| if(orderType == ORDER_TYPE_BUY_LIMIT)
|
| {
|
| if(triggerPrice >= ask - minDist)
|
| {
|
| errorMsg = "BUY LIMIT must be below current ask by at least stops level";
|
| return;
|
| }
|
| }
|
| else
|
| if(orderType == ORDER_TYPE_SELL_LIMIT)
|
| {
|
| if(triggerPrice <= bid + minDist)
|
| {
|
| errorMsg = "SELL LIMIT must be above current bid by at least stops level";
|
| return;
|
| }
|
| }
|
| else
|
| if(orderType == ORDER_TYPE_BUY_STOP)
|
| {
|
| if(triggerPrice <= ask + minDist)
|
| {
|
| errorMsg = "BUY STOP must be above current ask by at least stops level";
|
| return;
|
| }
|
| }
|
| else
|
| if(orderType == ORDER_TYPE_SELL_STOP)
|
| {
|
| if(triggerPrice >= bid - minDist)
|
| {
|
| errorMsg = "SELL STOP must be below current bid by at least stops level";
|
| return;
|
| }
|
| }
|
| //--- Determine direction and reference price
|
| const bool isLong = (orderType == ORDER_TYPE_BUY ||
|
| orderType == ORDER_TYPE_BUY_LIMIT ||
|
| orderType == ORDER_TYPE_BUY_STOP);
|
| const double refPx = (orderType == ORDER_TYPE_BUY) ? ask
|
| : (orderType == ORDER_TYPE_SELL) ? bid
|
| : triggerPrice;
|
| //--- Clamp SL/TP against minimum stops distance
|
| if(isLong)
|
| {
|
| if(sl > 0.0 && (refPx - sl) < minDist)
|
| sl = refPx - minDist;
|
| if(tp > 0.0 && (tp - refPx) < minDist)
|
| tp = refPx + minDist;
|
| }
|
| else
|
| {
|
| if(sl > 0.0 && (sl - refPx) < minDist)
|
| sl = refPx + minDist;
|
| if(tp > 0.0 && (refPx - tp) < minDist)
|
| tp = refPx - minDist;
|
| }
|
| //--- Normalize prices to symbol digits
|
| const int digits = (int)SymbolInfoInteger(sym, SYMBOL_DIGITS);
|
| sl = NormalizeDouble(sl, digits);
|
| tp = NormalizeDouble(tp, digits);
|
| triggerPrice = NormalizeDouble(triggerPrice, digits);
|
| //--- Dispatch to market or pending open
|
| bool ok = false;
|
| if(orderType == ORDER_TYPE_BUY || orderType == ORDER_TYPE_SELL)
|
| {
|
| ok = g_ai_trade.PositionOpen(sym, orderType, LotSize, 0.0, sl, tp, comment);
|
| }
|
| else
|
| {
|
| ok = g_ai_trade.OrderOpen(sym, orderType, LotSize, 0.0, triggerPrice,
|
| sl, tp, ORDER_TIME_GTC, 0, comment);
|
| }
|
| //--- Capture result
|
| if(!ok)
|
| {
|
| errorMsg = g_ai_trade.ResultRetcodeDescription();
|
| return;
|
| }
|
| success = true;
|
| ticket = g_ai_trade.ResultOrder();
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Open trade based on signal text |
|
| //+------------------------------------------------------------------+
|
| void AiOpenTrade(string signal, double entry, double sl, double tp)
|
| {
|
| //--- Map signal to order type
|
| ENUM_ORDER_TYPE type = (StringFind(signal, "BUY") >= 0) ? ORDER_TYPE_BUY
|
| : (StringFind(signal, "SELL") >= 0) ? ORDER_TYPE_SELL
|
| : (ENUM_ORDER_TYPE)-1;
|
| if(type == (ENUM_ORDER_TYPE)-1)
|
| return;
|
| //--- Submit market order
|
| double price = 0;
|
| string comment = "AI Signal: " + signal;
|
| int result = g_ai_trade.PositionOpen(Symbol(), type, LotSize, price, sl, tp, comment);
|
| //--- Report outcome
|
| if(result == -1)
|
| {
|
| Print("Trade open failed: ", g_ai_trade.ResultRetcodeDescription());
|
| Alert("Failed to open trade.");
|
| }
|
| else
|
| {
|
| Print("Trade opened: Ticket #", g_ai_trade.ResultOrder());
|
| }
|
| }
|
| //+------------------------------------------------------------------+
|
| //| Visualize trading signal with pattern-specific drawings |
|
| //+------------------------------------------------------------------+
|
| void AiVisualizeSignal(string signal, string reason, int triggerBar,
|
| double entry, double sl, double tp)
|
| {
|
| //--- Increment unique signal id
|
| static int sigId = 0;
|
| sigId++;
|
| string prefix = AI_SIGNAL_OBJ_PREFIX + IntegerToString(sigId) + "_";
|
| //--- Pull bars around trigger
|
| MqlRates rates[];
|
| int barsToCopy = 5;
|
| if(CopyRates(Symbol(), Period(), triggerBar, barsToCopy, rates) < barsToCopy)
|
| {
|
| Print("Failed to get rates for complete bar ", triggerBar);
|
| return;
|
| }
|
| ArraySetAsSeries(rates, true);
|
| //--- Cache bar data
|
| datetime triggerTime = rates[0].time;
|
| datetime prevTime = rates[1].time;
|
| double triggerHigh = rates[0].high, triggerLow = rates[0].low;
|
| double prevHigh = rates[1].high, prevLow = rates[1].low;
|
| double triggerOpen = rates[0].open, triggerClose = rates[0].close;
|
| //--- Determine arrow style
|
| bool isBuy = (StringFind(signal, "BUY") >= 0);
|
| int arrowCode = isBuy ? 233 : 234;
|
| color arrowColor = isBuy ? clrGreen : clrRed;
|
| double arrowPrice = isBuy ? triggerLow : triggerHigh;
|
| int arrowAnchor = isBuy ? ANCHOR_TOP : ANCHOR_BOTTOM;
|
| //--- Draw signal arrow
|
| string arrowName = prefix + "Arrow_" + TimeToString(triggerTime);
|
| ObjectCreate(0, arrowName, OBJ_ARROW, 0, triggerTime, arrowPrice);
|
| ObjectSetInteger(0, arrowName, OBJPROP_ARROWCODE, arrowCode);
|
| ObjectSetInteger(0, arrowName, OBJPROP_COLOR, arrowColor);
|
| ObjectSetInteger(0, arrowName, OBJPROP_ANCHOR, arrowAnchor);
|
| ObjectSetInteger(0, arrowName, OBJPROP_BACK, false);
|
| //--- Lower-case reason for pattern matching
|
| string lowerReason = reason;
|
| StringToLower(lowerReason);
|
| string patternLabel = reason + " (Bar " + IntegerToString(triggerBar) + ")";
|
| color labelColor = clrBlack;
|
| //--- Engulfing pattern visualization
|
| if(StringFind(lowerReason, "engulfing") >= 0)
|
| {
|
| bool isBullish = (StringFind(lowerReason, "bullish") >= 0) || (StringFind(signal, "BUY") >= 0);
|
| color engulfColor = isBullish ? clrDarkBlue : clrDarkRed;
|
| ObjectCreate(0, prefix + "EngulfRect", OBJ_RECTANGLE, 0,
|
| prevTime, prevLow, triggerTime + (PeriodSeconds() / 2), triggerHigh);
|
| ObjectSetInteger(0, prefix + "EngulfRect", OBJPROP_COLOR, engulfColor);
|
| ObjectSetInteger(0, prefix + "EngulfRect", OBJPROP_FILL, true);
|
| ObjectSetInteger(0, prefix + "EngulfRect", OBJPROP_BACK, true);
|
| ObjectSetInteger(0, prefix + "EngulfRect", OBJPROP_WIDTH, 1);
|
| patternLabel = (isBullish ? "Bullish " : "Bearish ") + "Engulfing (Bar " + IntegerToString(triggerBar) + ")";
|
| labelColor = clrBlue;
|
| }
|
| //--- Pin bar / hammer / shooting star visualization
|
| else
|
| if(StringFind(lowerReason, "pin bar") >= 0
|
| || StringFind(lowerReason, "hammer") >= 0
|
| || StringFind(lowerReason, "shooting star") >= 0)
|
| {
|
| color pinColor = isBuy ? clrGreen : clrRed;
|
| ObjectCreate(0, prefix + "PinLine", OBJ_TREND, 0,
|
| triggerTime, triggerOpen, triggerTime + PeriodSeconds(), triggerClose);
|
| ObjectSetInteger(0, prefix + "PinLine", OBJPROP_COLOR, pinColor);
|
| ObjectSetInteger(0, prefix + "PinLine", OBJPROP_WIDTH, 2);
|
| ObjectSetInteger(0, prefix + "PinLine", OBJPROP_RAY_RIGHT, false);
|
| string patName = StringFind(lowerReason, "hammer") >= 0 ? "Hammer"
|
| : StringFind(lowerReason, "shooting star") >= 0 ? "Shooting Star" : "Pin Bar";
|
| patternLabel = patName + " (Bar " + IntegerToString(triggerBar) + ")";
|
| labelColor = pinColor;
|
| }
|
| //--- Inside bar visualization
|
| else
|
| if(StringFind(lowerReason, "inside bar") >= 0)
|
| {
|
| color insideColor = clrYellow;
|
| ObjectCreate(0, prefix + "InsideRect", OBJ_RECTANGLE, 0,
|
| triggerTime, triggerLow, triggerTime + PeriodSeconds(), triggerHigh);
|
| ObjectSetInteger(0, prefix + "InsideRect", OBJPROP_COLOR, insideColor);
|
| ObjectSetInteger(0, prefix + "InsideRect", OBJPROP_FILL, false);
|
| ObjectSetInteger(0, prefix + "InsideRect", OBJPROP_BACK, true);
|
| ObjectSetInteger(0, prefix + "InsideRect", OBJPROP_STYLE, STYLE_DASH);
|
| patternLabel = "Inside Bar (Bar " + IntegerToString(triggerBar) + ")";
|
| labelColor = clrOrange;
|
| }
|
| //--- Doji visualization
|
| else
|
| if(StringFind(lowerReason, "doji") >= 0)
|
| {
|
| color dojiColor = clrMagenta;
|
| ObjectCreate(0, prefix + "DojiMark", OBJ_TEXT, 0, triggerTime, (triggerHigh + triggerLow) / 2);
|
| ObjectSetString(0, prefix + "DojiMark", OBJPROP_FONT, "Wingdings");
|
| ObjectSetInteger(0, prefix + "DojiMark", OBJPROP_FONTSIZE, 12);
|
| ObjectSetString(0, prefix + "DojiMark", OBJPROP_TEXT, CharToString((uchar)108));
|
| ObjectSetInteger(0, prefix + "DojiMark", OBJPROP_COLOR, dojiColor);
|
| patternLabel = "Doji (Bar " + IntegerToString(triggerBar) + ")";
|
| labelColor = dojiColor;
|
| }
|
| //--- Three soldiers / three crows visualization
|
| else
|
| if(StringFind(lowerReason, "three white soldiers") >= 0
|
| || StringFind(lowerReason, "three black crows") >= 0)
|
| {
|
| bool isBullish = StringFind(lowerReason, "white soldiers") >= 0;
|
| color soldiersColor = isBullish ? clrLimeGreen : clrCrimson;
|
| datetime startTime = rates[2].time;
|
| ObjectCreate(0, prefix + "SoldiersRect", OBJ_RECTANGLE, 0,
|
| startTime, rates[2].low, triggerTime + PeriodSeconds(), rates[0].high);
|
| ObjectSetInteger(0, prefix + "SoldiersRect", OBJPROP_COLOR, soldiersColor);
|
| ObjectSetInteger(0, prefix + "SoldiersRect", OBJPROP_FILL, true);
|
| ObjectSetInteger(0, prefix + "SoldiersRect", OBJPROP_BACK, true);
|
| ObjectSetInteger(0, prefix + "SoldiersRect", OBJPROP_WIDTH, 1);
|
| patternLabel = (isBullish ? "Three White Soldiers" : "Three Black Crows")
|
| + " (Bars " + IntegerToString(triggerBar - 2) + "-" + IntegerToString(triggerBar) + ")";
|
| labelColor = soldiersColor;
|
| }
|
| //--- Double top / double bottom visualization
|
| else
|
| if(StringFind(lowerReason, "double bottom") >= 0
|
| || StringFind(lowerReason, "double top") >= 0)
|
| {
|
| bool isBottom = StringFind(lowerReason, "bottom") >= 0;
|
| double level = isBottom ? MathMin(rates[1].low, triggerLow) : MathMax(rates[1].high, triggerHigh);
|
| color doubleColor = isBottom ? clrAqua : clrFuchsia;
|
| ObjectCreate(0, prefix + "DoubleLine", OBJ_TREND, 0,
|
| rates[1].time, level, triggerTime + PeriodSeconds() * 3, level);
|
| ObjectSetInteger(0, prefix + "DoubleLine", OBJPROP_COLOR, doubleColor);
|
| ObjectSetInteger(0, prefix + "DoubleLine", OBJPROP_STYLE, STYLE_DOT);
|
| ObjectSetInteger(0, prefix + "DoubleLine", OBJPROP_WIDTH, 2);
|
| ObjectSetInteger(0, prefix + "DoubleLine", OBJPROP_RAY_RIGHT, true);
|
| patternLabel = (isBottom ? "Double Bottom" : "Double Top")
|
| + " (Bar " + IntegerToString(triggerBar) + ") at " + DoubleToString(level, _Digits);
|
| labelColor = doubleColor;
|
| }
|
| //--- Breakout visualization
|
| else
|
| if(StringFind(lowerReason, "breakout") >= 0)
|
| {
|
| double breakLevel = isBuy ? MathMax(prevHigh, triggerHigh) : MathMin(prevLow, triggerLow);
|
| ObjectCreate(0, prefix + "BreakLine", OBJ_TREND, 0,
|
| prevTime, breakLevel, triggerTime + PeriodSeconds() * 5, breakLevel);
|
| ObjectSetInteger(0, prefix + "BreakLine", OBJPROP_COLOR, clrGray);
|
| ObjectSetInteger(0, prefix + "BreakLine", OBJPROP_STYLE, STYLE_DOT);
|
| ObjectSetInteger(0, prefix + "BreakLine", OBJPROP_WIDTH, 2);
|
| ObjectSetInteger(0, prefix + "BreakLine", OBJPROP_RAY_RIGHT, true);
|
| patternLabel = "Breakout (Bar " + IntegerToString(triggerBar) + ") at " + DoubleToString(breakLevel, _Digits);
|
| labelColor = clrBlue;
|
| }
|
| //--- RSI / divergence visualization
|
| else
|
| if(StringFind(lowerReason, "divergence") >= 0
|
| || StringFind(lowerReason, "rsi") >= 0)
|
| {
|
| int rsiHandle = iRSI(Symbol(), Period(), 14, PRICE_CLOSE);
|
| double rsiVal[1];
|
| if(CopyBuffer(rsiHandle, 0, triggerBar, 1, rsiVal) == 1)
|
| {
|
| patternLabel = "RSI Divergence (Bar " + IntegerToString(triggerBar)
|
| + ", RSI=" + IntegerToString((int)rsiVal[0]) + ")";
|
| }
|
| labelColor = clrDarkBlue;
|
| IndicatorRelease(rsiHandle);
|
| }
|
| //--- Generic highlight fallback
|
| else
|
| {
|
| ObjectCreate(0, prefix + "Highlight", OBJ_RECTANGLE, 0,
|
| triggerTime, triggerLow, triggerTime + PeriodSeconds(), triggerHigh);
|
| ObjectSetInteger(0, prefix + "Highlight", OBJPROP_COLOR, clrLightGray);
|
| ObjectSetInteger(0, prefix + "Highlight", OBJPROP_FILL, true);
|
| ObjectSetInteger(0, prefix + "Highlight", OBJPROP_BACK, true);
|
| patternLabel = reason + " (Bar " + IntegerToString(triggerBar) + ")";
|
| labelColor = clrBlack;
|
| }
|
| //--- Draw rotated pattern label
|
| datetime labelTime = triggerTime;
|
| double labelPrice = arrowPrice;
|
| int direction = isBuy ? 1 : -1;
|
| ENUM_ANCHOR_POINT labelAnchor = ANCHOR_LEFT;
|
| double labelAngle = (direction > 0) ? -90 : 90;
|
| string labelText = " " + patternLabel;
|
| AiCreateTextLabel(prefix + "PatternLabel", labelTime, labelPrice, labelText, labelColor, labelAnchor, labelAngle);
|
| Print("Anchored viz drawn: Arrow at ", (isBuy ? "low" : "high"), ", Label: ", patternLabel);
|
| ChartRedraw();
|
| }
|
|
|
| //--- Forward declaration for regenerate path
|
| void AiSubmitMessage(string prompt);
|
|
|
| //+------------------------------------------------------------------+
|
| //| Push signal action turn into chat history |
|
| //+------------------------------------------------------------------+
|
| void Ai_PushSignalActionToChat(string userLabel, string aiBubbleText, ulong elapsedMs)
|
| {
|
| //--- Build timestamp and elapsed note
|
| string ts = TimeToString(TimeCurrent(), TIME_MINUTES);
|
| int elapsedSec = (int)(elapsedMs / 1000);
|
| string timeNote = "\n(Response in " + IntegerToString(elapsedSec) + "s)";
|
| //--- Append user and AI turn to history
|
| g_ai_conversationHistory += "You: " + userLabel + "\n" + ts + "\n\n"
|
| + "AI: " + aiBubbleText + timeNote + "\n" + ts + "\n\n";
|
| g_ai_chatScrollPin = true;
|
| //--- Auto-rename placeholder title to action label
|
| if(StringFind(g_ai_currentTitle, "Chat ") == 0)
|
| {
|
| g_ai_currentTitle = userLabel;
|
| }
|
| //--- Persist update
|
| AiUpdateCurrentHistory();
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Auto-generate a concise chat title via secondary AI call |
|
| //+------------------------------------------------------------------+
|
| void Ai_AutoTitleChat(string userPrompt, string aiResponse)
|
| {
|
| //--- Build title-generation prompt
|
| string prompt =
|
| "Generate a concise title for this conversation. Rules:\n"
|
| " - 3 to 5 words ONLY.\n"
|
| " - MAXIMUM 100 characters total.\n"
|
| " - Plain text - no quotes, no markdown, no trailing punctuation.\n"
|
| " - Reflect the topic / intent, not just echo the prompt verbatim.\n"
|
| " - Output ONLY the title itself. No prefix like 'Title:' and no commentary.\n"
|
| "\n"
|
| "USER PROMPT: " + userPrompt + "\n"
|
| "AI RESPONSE: " + aiResponse + "\n"
|
| "\n"
|
| "Title:";
|
| //--- Send request with low temperature
|
| string raw = AiGetChatGPTResponse(prompt, 0.3);
|
| if(StringLen(raw) == 0)
|
| return;
|
| //--- Cleanup whitespace and newlines
|
| string title = raw;
|
| StringReplace(title, "\r", " ");
|
| StringReplace(title, "\n", " ");
|
| StringReplace(title, " ", " ");
|
| StringTrimLeft(title);
|
| StringTrimRight(title);
|
| //--- Strip wrapping quotes
|
| while(StringLen(title) >= 2)
|
| {
|
| ushort first = StringGetCharacter(title, 0);
|
| ushort last = StringGetCharacter(title, StringLen(title) - 1);
|
| if((first == '"' && last == '"') || (first == '\'' && last == '\''))
|
| {
|
| title = StringSubstr(title, 1, StringLen(title) - 2);
|
| StringTrimLeft(title);
|
| StringTrimRight(title);
|
| }
|
| else
|
| break;
|
| }
|
| //--- Strip leading "Title:" prefix
|
| if(StringFind(title, "Title:") == 0)
|
| {
|
| title = StringSubstr(title, 6);
|
| StringTrimLeft(title);
|
| }
|
| else
|
| if(StringFind(title, "title:") == 0)
|
| {
|
| title = StringSubstr(title, 6);
|
| StringTrimLeft(title);
|
| }
|
| //--- Hard cap at 100 characters
|
| if(StringLen(title) > 100)
|
| {
|
| title = StringSubstr(title, 0, 97) + "...";
|
| }
|
| //--- Reject empty result
|
| StringTrimLeft(title);
|
| StringTrimRight(title);
|
| if(StringLen(title) == 0)
|
| return;
|
| //--- Apply and persist title
|
| g_ai_currentTitle = title;
|
| AiUpdateCurrentHistory();
|
| Ai_RenderAll();
|
| ChartRedraw();
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Parse KEY: VALUE line from AI response |
|
| //+------------------------------------------------------------------+
|
| string Ai_ParseKVResponse(string raw, string key)
|
| {
|
| //--- Split into lines and uppercase target key
|
| string lines[];
|
| int n = StringSplit(raw, '\n', lines);
|
| string keyUpper = key;
|
| StringToUpper(keyUpper);
|
| //--- Search for matching key
|
| for(int i = 0; i < n; i++)
|
| {
|
| string line = lines[i];
|
| StringTrimLeft(line);
|
| StringTrimRight(line);
|
| int colonPos = StringFind(line, ":");
|
| if(colonPos <= 0)
|
| continue;
|
| string lineKey = StringSubstr(line, 0, colonPos);
|
| StringTrimLeft(lineKey);
|
| StringTrimRight(lineKey);
|
| StringToUpper(lineKey);
|
| if(lineKey != keyUpper)
|
| continue;
|
| string val = StringSubstr(line, colonPos + 1);
|
| StringTrimLeft(val);
|
| StringTrimRight(val);
|
| return val;
|
| }
|
| return "";
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Build standard bar-definition preamble for AI prompts |
|
| //+------------------------------------------------------------------+
|
| string Ai_BuildBarPreamble()
|
| {
|
| //--- Return shared bar rules text
|
| return
|
| "BAR DEFINITIONS (read carefully before analyzing):\n"
|
| " - A bar is BULLISH if its close > open.\n"
|
| " - A bar is BEARISH if its close < open.\n"
|
| " - A bar is DOJI if close equals open.\n"
|
| "\n"
|
| "BAR ORDERING IN THE DATA BELOW (CRITICAL):\n"
|
| " - Bars are listed in DESCENDING TIME ORDER.\n"
|
| " - Bar 1 = MOST RECENT closed bar (highest timestamp, just finished).\n"
|
| " - Bar 2 = the bar that closed BEFORE Bar 1.\n"
|
| " - Bar 3 closed before Bar 2, and so on backwards in time.\n"
|
| " - Bar 1 is the RIGHTMOST candle on the chart.\n"
|
| " - The data block includes each bar's timestamp so you can verify ordering.\n"
|
| " - DO NOT reverse this. Bar 1 is NEWEST, Bar N is OLDEST.\n"
|
| "\n";
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Format N bars in descending time order for AI prompt |
|
| //+------------------------------------------------------------------+
|
| string Ai_FormatBarsDesc(ENUM_TIMEFRAMES tf, int count)
|
| {
|
| //--- Pull rates and order newest first
|
| MqlRates r[];
|
| if(CopyRates(Symbol(), tf, 1, count, r) < count)
|
| return "";
|
| ArraySetAsSeries(r, true);
|
| //--- Build header
|
| string out = "BAR DATA (Bar 1 = newest, Bar " + IntegerToString(count) + " = oldest):\n";
|
| //--- Append each bar with direction tag
|
| for(int i = 0; i < count; i++)
|
| {
|
| string dir = (r[i].close > r[i].open) ? "BULLISH"
|
| : (r[i].close < r[i].open) ? "BEARISH" : "DOJI";
|
| out += "Bar " + IntegerToString(i + 1)
|
| + " | Time=" + TimeToString(r[i].time, TIME_DATE | TIME_MINUTES)
|
| + " | O=" + DoubleToString(r[i].open, _Digits)
|
| + " | H=" + DoubleToString(r[i].high, _Digits)
|
| + " | L=" + DoubleToString(r[i].low, _Digits)
|
| + " | C=" + DoubleToString(r[i].close, _Digits)
|
| + " | (this bar is " + dir + ")\n";
|
| }
|
| return out;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Draw signal arrow on current chart with optional label |
|
| //+------------------------------------------------------------------+
|
| void Ai_DrawSignalArrow(string actionTag, string signal, double entryPrice, string labelText = "")
|
| {
|
| //--- Bail on non-directional signal
|
| if(signal != "BUY" && signal != "SELL")
|
| return;
|
| //--- Increment unique sequence id
|
| static int s_arrowSeq = 0;
|
| s_arrowSeq++;
|
| bool isBuy = (signal == "BUY");
|
| //--- Anchor to most recent closed bar on current chart
|
| datetime t = iTime(Symbol(), Period(), 1);
|
| double bH = iHigh(Symbol(), Period(), 1);
|
| double bL = iLow(Symbol(), Period(), 1);
|
| double arrowPrice = isBuy ? bL : bH;
|
| //--- Create arrow object
|
| string name = AI_SIGNAL_OBJ_PREFIX + actionTag + "_Arrow_" + IntegerToString(s_arrowSeq);
|
| if(ObjectFind(0, name) >= 0)
|
| ObjectDelete(0, name);
|
| ObjectCreate(0, name, OBJ_ARROW, 0, t, arrowPrice);
|
| ObjectSetInteger(0, name, OBJPROP_ARROWCODE, isBuy ? 233 : 234);
|
| ObjectSetInteger(0, name, OBJPROP_COLOR, isBuy ? clrGreen : clrRed);
|
| ObjectSetInteger(0, name, OBJPROP_ANCHOR, isBuy ? ANCHOR_TOP : ANCHOR_BOTTOM);
|
| ObjectSetInteger(0, name, OBJPROP_BACK, false);
|
| ObjectSetInteger(0, name, OBJPROP_SELECTABLE, false);
|
| ObjectSetInteger(0, name, OBJPROP_HIDDEN, true);
|
| //--- Draw rotated label if provided
|
| if(StringLen(labelText) > 0)
|
| {
|
| string lblName = AI_SIGNAL_OBJ_PREFIX + actionTag + "_Label_" + IntegerToString(s_arrowSeq);
|
| if(ObjectFind(0, lblName) >= 0)
|
| ObjectDelete(0, lblName);
|
| const color labelColor = isBuy ? clrGreen : clrRed;
|
| const double labelAngle = isBuy ? -90.0 : 90.0;
|
| AiCreateTextLabel(lblName, t, arrowPrice, " " + labelText, labelColor, ANCHOR_LEFT, labelAngle);
|
| }
|
| ChartRedraw();
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Twin Bars action - signal from last 2 bars on current timeframe |
|
| //+------------------------------------------------------------------+
|
| void AiTwinBars()
|
| {
|
| //--- Throttle concurrent requests
|
| if(g_ai_signalRequestInFlight)
|
| {
|
| Ai_ShowToast("Signal in progress - please wait", true);
|
| return;
|
| }
|
| g_ai_signalRequestInFlight = true;
|
| //--- Pull Bar 1 close as fallback entry
|
| MqlRates rfb[];
|
| if(CopyRates(Symbol(), Period(), 1, 1, rfb) < 1)
|
| {
|
| g_ai_signalRequestInFlight = false;
|
| Ai_ShowToast("Twin Bars: failed to load Bar 1", true);
|
| return;
|
| }
|
| ArraySetAsSeries(rfb, true);
|
| double bar1Close = rfb[0].close;
|
| //--- Build bars block
|
| string barsBlock = Ai_FormatBarsDesc(Period(), 2);
|
| if(StringLen(barsBlock) == 0)
|
| {
|
| g_ai_signalRequestInFlight = false;
|
| Ai_ShowToast("Twin Bars: failed to load bars", true);
|
| return;
|
| }
|
| //--- Build prompt with strict twin-bars rule
|
| string prompt =
|
| "You are a precise trading-rule executor. Apply the rule below to the 2 bars in the DATA section.\n"
|
| "\n"
|
| + Ai_BuildBarPreamble()
|
| + "STRICT RULE FOR TWIN BARS:\n"
|
| " - If BOTH Bar 1 AND Bar 2 are BULLISH (close > open) -> SIGNAL is BUY.\n"
|
| " - If BOTH Bar 1 AND Bar 2 are BEARISH (close < open) -> SIGNAL is SELL.\n"
|
| " - Otherwise (any mix, any DOJI) -> SIGNAL is NONE.\n"
|
| "\n"
|
| "OUTPUT FORMAT (LINE-BASED KEY: VALUE - NO JSON, NO MARKDOWN, NO PROSE):\n"
|
| "Output EXACTLY 3 lines, in this order:\n"
|
| "SIGNAL: <BUY or SELL or NONE>\n"
|
| "REASON: Bar 1 O=<bar1_open> C=<bar1_close> -> <BULLISH/BEARISH/DOJI>; Bar 2 O=<bar2_open> C=<bar2_close> -> <BULLISH/BEARISH/DOJI>; Decision: <both BULLISH=>BUY | both BEARISH=>SELL | mixed=>NONE>\n"
|
| "ENTRY: <decimal price equal to Bar 1 close, with 5 decimals; or 0 if SIGNAL is NONE>\n"
|
| "\n"
|
| "EXAMPLE (do not copy values, use the actual ones from DATA):\n"
|
| "SIGNAL: BUY\n"
|
| "REASON: Bar 1 O=1.10100 C=1.10250 -> BULLISH; Bar 2 O=1.10000 C=1.10100 -> BULLISH; Decision: both BULLISH=>BUY\n"
|
| "ENTRY: 1.10250\n"
|
| "\n"
|
| "DATA:\n"
|
| + barsBlock;
|
| //--- Time the call
|
| ulong t0 = GetTickCount();
|
| string resp = AiGetChatGPTResponse(prompt, 0.0);
|
| ulong elapsed = GetTickCount() - t0;
|
| //--- Parse response
|
| string signal = Ai_ParseKVResponse(resp, "SIGNAL");
|
| string reason = Ai_ParseKVResponse(resp, "REASON");
|
| string entryStr = Ai_ParseKVResponse(resp, "ENTRY");
|
| double entry = StringToDouble(entryStr);
|
| StringToUpper(signal);
|
| //--- Apply local fallback if entry missing
|
| if((signal == "BUY" || signal == "SELL") && entry <= 0.0)
|
| {
|
| entry = bar1Close;
|
| }
|
| if(signal != "BUY" && signal != "SELL")
|
| signal = "NONE";
|
| //--- Build chat bubble
|
| string bubble;
|
| if(signal == "BUY")
|
| bubble = "Twin Bars -> BUY at " + DoubleToString(entry, _Digits) + "\n" + reason;
|
| else
|
| if(signal == "SELL")
|
| bubble = "Twin Bars -> SELL at " + DoubleToString(entry, _Digits) + "\n" + reason;
|
| else
|
| bubble = "Twin Bars -> NONE\n" + reason;
|
| //--- Auto-trade if enabled
|
| if(AutoTrade && (signal == "BUY" || signal == "SELL"))
|
| {
|
| const double bar1High = rfb[0].high;
|
| const double bar1Low = rfb[0].low;
|
| const double buf = Ai_DynamicBuffer();
|
| double sl = 0.0, tp = 0.0;
|
| if(signal == "BUY")
|
| {
|
| sl = bar1Low - buf;
|
| tp = entry + 2.0 * (entry - sl);
|
| }
|
| else
|
| {
|
| sl = bar1High + buf;
|
| tp = entry - 2.0 * (sl - entry);
|
| }
|
| const ENUM_ORDER_TYPE ot = (signal == "BUY") ? ORDER_TYPE_BUY : ORDER_TYPE_SELL;
|
| bool ok = false;
|
| ulong tk = 0;
|
| string err = "";
|
| Ai_PlaceOrder(ot, 0.0, sl, tp, "Twin Bars", ok, tk, err);
|
| if(ok)
|
| {
|
| bubble += "\nTrade: " + Ai_OrderTypeLabel(ot)
|
| + " @ " + DoubleToString(entry, _Digits)
|
| + " | SL=" + DoubleToString(sl, _Digits)
|
| + " | TP=" + DoubleToString(tp, _Digits)
|
| + " | Ticket #" + IntegerToString((long)tk);
|
| }
|
| else
|
| {
|
| bubble += "\nTrade FAILED: " + err;
|
| Ai_ShowToast("Twin Bars trade failed: " + err, true);
|
| }
|
| }
|
| //--- Push to chat and draw arrow
|
| Ai_PushSignalActionToChat("Twin Bars", bubble, elapsed);
|
| Ai_DrawSignalArrow("TwinBars", signal, entry, "Twin Bars " + signal + " @ " + DoubleToString(entry, _Digits));
|
| g_ai_signalRequestInFlight = false;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Daily Signal action - signal from today's H1 bars |
|
| //+------------------------------------------------------------------+
|
| void AiDailySignal()
|
| {
|
| //--- Throttle concurrent requests
|
| if(g_ai_signalRequestInFlight)
|
| {
|
| Ai_ShowToast("Signal in progress - please wait", true);
|
| return;
|
| }
|
| g_ai_signalRequestInFlight = true;
|
| //--- Pull last closed H1 bar as fallback entry source
|
| MqlRates rfb[];
|
| if(CopyRates(Symbol(), PERIOD_H1, 1, 1, rfb) < 1)
|
| {
|
| g_ai_signalRequestInFlight = false;
|
| Ai_ShowToast("Daily Signal: failed to load H1 Bar 1", true);
|
| return;
|
| }
|
| ArraySetAsSeries(rfb, true);
|
| double h1Close = rfb[0].close;
|
| //--- Compute server midnight timestamp
|
| datetime now = TimeCurrent();
|
| MqlDateTime dt;
|
| TimeToStruct(now, dt);
|
| dt.hour = 0;
|
| dt.min = 0;
|
| dt.sec = 0;
|
| datetime midnight = StructToTime(dt);
|
| //--- Compute current H1 bar start
|
| MqlDateTime hourCap;
|
| TimeToStruct(now, hourCap);
|
| hourCap.min = 0;
|
| hourCap.sec = 0;
|
| datetime currentH1Start = StructToTime(hourCap);
|
| //--- Stop one second before in-progress bar
|
| datetime stopTime = currentH1Start - 1;
|
| //--- Bail if no closed H1 bars yet today
|
| if(stopTime < midnight)
|
| {
|
| g_ai_signalRequestInFlight = false;
|
| Ai_ShowToast("Daily Signal: no closed H1 bars for today yet", false);
|
| return;
|
| }
|
| //--- Compute expected closed bar count
|
| const int expectedBars = (int)((currentH1Start - midnight) / 3600);
|
| //--- Pull H1 bars with retry for missing history
|
| MqlRates rd[];
|
| int copied = 0;
|
| for(int retry = 0; retry < 5; retry++)
|
| {
|
| copied = CopyRates(Symbol(), PERIOD_H1, midnight, stopTime, rd);
|
| if(copied >= expectedBars)
|
| break;
|
| Sleep(200);
|
| }
|
| if(copied <= 0)
|
| {
|
| g_ai_signalRequestInFlight = false;
|
| Ai_ShowToast("Daily Signal: no H1 bars for today yet", true);
|
| return;
|
| }
|
| //--- Warn if incomplete
|
| if(copied < expectedBars)
|
| {
|
| Print("Daily Signal warning: expected ", expectedBars,
|
| " H1 bars from midnight, got ", copied, ". H1 history may be incomplete.");
|
| }
|
| if(copied > 24)
|
| copied = 24;
|
| //--- Order newest first
|
| ArraySetAsSeries(rd, true);
|
| //--- Build bars block
|
| string barsBlock = "BAR DATA - " + IntegerToString(copied)
|
| + " CLOSED H1 bar(s) from midnight today up to the most recently CLOSED H1 bar:\n"
|
| + "(Bar 1 = most recent closed H1, Bar " + IntegerToString(copied) + " = the H1 bar that opened at midnight)\n";
|
| for(int i = 0; i < copied; i++)
|
| {
|
| string dir = (rd[i].close > rd[i].open) ? "BULLISH"
|
| : (rd[i].close < rd[i].open) ? "BEARISH" : "DOJI";
|
| barsBlock += "Bar " + IntegerToString(i + 1)
|
| + " | Time=" + TimeToString(rd[i].time, TIME_DATE | TIME_MINUTES)
|
| + " | O=" + DoubleToString(rd[i].open, _Digits)
|
| + " | H=" + DoubleToString(rd[i].high, _Digits)
|
| + " | L=" + DoubleToString(rd[i].low, _Digits)
|
| + " | C=" + DoubleToString(rd[i].close, _Digits)
|
| + " | (" + dir + ")\n";
|
| }
|
| //--- Build daily signal prompt
|
| string prompt =
|
| "You are a precise trading analyst. Below are today's H1 bars from server midnight.\n"
|
| "\n"
|
| + Ai_BuildBarPreamble()
|
| + "DECISION RULES FOR DAILY SIGNAL:\n"
|
| " - You MUST analyze ALL of the H1 bars provided in the DATA section, not just a few.\n"
|
| " - SIGNAL is BUY only if today's H1 bars show CLEAR upward continuation/momentum (most bars BULLISH, higher closes, no strong rejection).\n"
|
| " - SIGNAL is SELL only if today's H1 bars show CLEAR downward continuation/momentum.\n"
|
| " - SIGNAL is NONE if mixed, choppy, or unclear.\n"
|
| "\n"
|
| "REASON FORMATTING - REQUIRED:\n"
|
| " Your REASON must include ALL of the following, in this order:\n"
|
| " (a) State how many bars you analyzed (e.g. 'Analyzed 14 H1 bars').\n"
|
| " (b) State the count of BULLISH vs BEARISH vs DOJI bars (e.g. 'Bullish: 9, Bearish: 4, Doji: 1').\n"
|
| " (c) State the open of the OLDEST bar (Bar N) and the close of the NEWEST bar (Bar 1) to show net day movement.\n"
|
| " (d) Cite the 2-3 most decisive bars (largest range, key reversals, breakouts) by number with brief reasoning.\n"
|
| " (e) State the conclusion connecting the evidence to the SIGNAL.\n"
|
| "\n"
|
| "OUTPUT FORMAT (LINE-BASED KEY: VALUE - NO JSON, NO MARKDOWN, NO PROSE):\n"
|
| "Output EXACTLY 3 lines, in this order:\n"
|
| "SIGNAL: <BUY or SELL or NONE>\n"
|
| "REASON: <single-line summary covering all 5 elements (a)-(e) above, separated by '; '>\n"
|
| "ENTRY: <decimal price equal to Bar 1 close; or 0 if SIGNAL is NONE>\n"
|
| "\n"
|
| "EXAMPLE (replace values with actual ones from DATA - note how it covers ALL bars, not just 2-3):\n"
|
| "SIGNAL: BUY\n"
|
| "REASON: Analyzed 14 H1 bars; Bullish: 10, Bearish: 3, Doji: 1; Day opened at 78198.28 (Bar 14), now at 78368.03 (Bar 1) = +169.75 net up; decisive bars: Bar 11 (large bullish breakout, range 95.40), Bar 5 (rejection of low, hammer), Bar 1 (continuation close); conclusion: dominant bullish day with strong momentum and no significant rejection - BUY\n"
|
| "ENTRY: 78368.03\n"
|
| "\n"
|
| "DATA:\n"
|
| + barsBlock;
|
| //--- Time the call
|
| ulong t0 = GetTickCount();
|
| string resp = AiGetChatGPTResponse(prompt, 0.2);
|
| ulong elapsed = GetTickCount() - t0;
|
| //--- Parse response
|
| string signal = Ai_ParseKVResponse(resp, "SIGNAL");
|
| string reason = Ai_ParseKVResponse(resp, "REASON");
|
| string entryStr = Ai_ParseKVResponse(resp, "ENTRY");
|
| double entry = StringToDouble(entryStr);
|
| StringToUpper(signal);
|
| //--- Apply local fallback if entry missing
|
| if((signal == "BUY" || signal == "SELL") && entry <= 0.0)
|
| entry = h1Close;
|
| if(signal != "BUY" && signal != "SELL")
|
| signal = "NONE";
|
| //--- Build chat bubble
|
| string bubble;
|
| if(signal == "BUY")
|
| bubble = "Daily Signal -> BUY at " + DoubleToString(entry, _Digits) + "\n" + reason;
|
| else
|
| if(signal == "SELL")
|
| bubble = "Daily Signal -> SELL at " + DoubleToString(entry, _Digits) + "\n" + reason;
|
| else
|
| bubble = "Daily Signal -> NONE\n" + reason;
|
| //--- Auto-trade if enabled
|
| if(AutoTrade && (signal == "BUY" || signal == "SELL"))
|
| {
|
| const double h1High = rfb[0].high;
|
| const double h1Low = rfb[0].low;
|
| const double buf = Ai_DynamicBuffer();
|
| double sl = 0.0, tp = 0.0;
|
| if(signal == "BUY")
|
| {
|
| sl = h1Low - buf;
|
| tp = entry + 2.0 * (entry - sl);
|
| }
|
| else
|
| {
|
| sl = h1High + buf;
|
| tp = entry - 2.0 * (sl - entry);
|
| }
|
| const ENUM_ORDER_TYPE ot = (signal == "BUY") ? ORDER_TYPE_BUY : ORDER_TYPE_SELL;
|
| bool ok = false;
|
| ulong tk = 0;
|
| string err = "";
|
| Ai_PlaceOrder(ot, 0.0, sl, tp, "Daily Signal", ok, tk, err);
|
| if(ok)
|
| {
|
| bubble += "\nTrade: " + Ai_OrderTypeLabel(ot)
|
| + " @ " + DoubleToString(entry, _Digits)
|
| + " | SL=" + DoubleToString(sl, _Digits)
|
| + " | TP=" + DoubleToString(tp, _Digits)
|
| + " | Ticket #" + IntegerToString((long)tk);
|
| }
|
| else
|
| {
|
| bubble += "\nTrade FAILED: " + err;
|
| Ai_ShowToast("Daily Signal trade failed: " + err, true);
|
| }
|
| }
|
| //--- Push to chat and draw arrow
|
| Ai_PushSignalActionToChat("Daily Signal", bubble, elapsed);
|
| Ai_DrawSignalArrow("DailySignal", signal, entry, "Daily Signal " + signal + " @ " + DoubleToString(entry, _Digits));
|
| g_ai_signalRequestInFlight = false;
|
| }
|
| //+------------------------------------------------------------------+
|
| //| Trend Read action - trend direction with anchor points |
|
| //+------------------------------------------------------------------+
|
| void AiTrendRead()
|
| {
|
| //--- Throttle concurrent requests
|
| if(g_ai_signalRequestInFlight)
|
| {
|
| Ai_ShowToast("Signal in progress - please wait", true);
|
| return;
|
| }
|
| g_ai_signalRequestInFlight = true;
|
| //--- Configure bar count and pull bars
|
| const int N = 30;
|
| MqlRates ra[];
|
| if(CopyRates(Symbol(), Period(), 1, N, ra) < N)
|
| {
|
| g_ai_signalRequestInFlight = false;
|
| Ai_ShowToast("Trend Read: failed to load bars", true);
|
| return;
|
| }
|
| ArraySetAsSeries(ra, true);
|
| //--- Build bars block
|
| string barsBlock = Ai_FormatBarsDesc(Period(), N);
|
| if(StringLen(barsBlock) == 0)
|
| {
|
| g_ai_signalRequestInFlight = false;
|
| Ai_ShowToast("Trend Read: failed to format bars", true);
|
| return;
|
| }
|
| //--- Build trend read prompt
|
| string prompt =
|
| "You are a precise trend analyst. Analyze the last " + IntegerToString(N) + " bars in the DATA section.\n"
|
| "\n"
|
| + Ai_BuildBarPreamble()
|
| + "DECISION RULES FOR TREND READ:\n"
|
| " - TREND is UP if you see HIGHER HIGHS and HIGHER LOWS in the recent bars (compared to older bars).\n"
|
| " - TREND is DOWN if you see LOWER HIGHS and LOWER LOWS in the recent bars.\n"
|
| " - TREND is RANGE if highs and lows are roughly horizontal / no clear direction.\n"
|
| " - You MUST scan ALL 30 bars to determine the trend, not just a few.\n"
|
| "\n"
|
| "ANCHOR POINTS FOR THE TRENDLINE:\n"
|
| " - ANCHOR1 is the OLDER end (a bar with a higher number, e.g. 18, 22, 30).\n"
|
| " - ANCHOR2 is the NEWER end (usually Bar 1).\n"
|
| " - For UP trend: anchor through recent HIGHER LOWS (use those bars' LOW prices).\n"
|
| " - For DOWN trend: anchor through recent LOWER HIGHS (use those bars' HIGH prices).\n"
|
| " - For RANGE: anchor at the range MIDPOINT - use the average of (recent high + recent low).\n"
|
| " - Anchor prices MUST come from the actual OHLC values in DATA. Do not invent prices.\n"
|
| "\n"
|
| "REASON FORMATTING - REQUIRED:\n"
|
| " Your REASON must include ALL of the following, in this order:\n"
|
| " (a) State how many bars you analyzed (always 30 for this action).\n"
|
| " (b) State the highest HIGH and lowest LOW across the set, with their bar numbers.\n"
|
| " (c) State net move from oldest bar's open (Bar 30) to newest bar's close (Bar 1).\n"
|
| " (d) Describe the swing structure briefly (e.g. 'higher lows and higher highs since Bar 22', or 'choppy oscillation between 1.0980 and 1.1050').\n"
|
| " (e) State why you chose those anchor bars and the conclusion.\n"
|
| "\n"
|
| "OUTPUT FORMAT (LINE-BASED KEY: VALUE - NO JSON, NO MARKDOWN, NO PROSE):\n"
|
| "Output EXACTLY 6 lines, in this order:\n"
|
| "TREND: <UP or DOWN or RANGE>\n"
|
| "ANCHOR1_BAR: <integer 1..30>\n"
|
| "ANCHOR1_PRICE: <decimal price from that bar>\n"
|
| "ANCHOR2_BAR: <integer 1..30, must be smaller than ANCHOR1_BAR for chronological order>\n"
|
| "ANCHOR2_PRICE: <decimal price from that bar>\n"
|
| "REASON: <single-line summary covering all 5 elements (a)-(e), separated by '; '>\n"
|
| "\n"
|
| "EXAMPLE (replace values with actual ones from DATA - note how it covers ALL 30 bars, not just the anchors):\n"
|
| "TREND: UP\n"
|
| "ANCHOR1_BAR: 22\n"
|
| "ANCHOR1_PRICE: 1.09800\n"
|
| "ANCHOR2_BAR: 1\n"
|
| "ANCHOR2_PRICE: 1.10250\n"
|
| "REASON: Analyzed 30 bars; range was 1.09650 (low at Bar 28) to 1.10310 (high at Bar 4); net move from Bar 30 open 1.09720 to Bar 1 close 1.10250 = +53 pips up; structure shows progressively higher lows from Bar 22 onward (Bar 22 L=1.09800, Bar 15 L=1.09950, Bar 8 L=1.10100, Bar 1 L=1.10220) with no break of prior swing low; anchored Bar 22 as the swing-low origin and Bar 1 as the most recent higher-low confirmation - clear uptrend\n"
|
| "\n"
|
| "DATA:\n"
|
| + barsBlock;
|
| //--- Time the call
|
| ulong t0 = GetTickCount();
|
| string resp = AiGetChatGPTResponse(prompt, 0.2);
|
| ulong elapsed = GetTickCount() - t0;
|
| //--- Parse response
|
| string trend = Ai_ParseKVResponse(resp, "TREND");
|
| string a1BarStr = Ai_ParseKVResponse(resp, "ANCHOR1_BAR");
|
| string a1PriceStr = Ai_ParseKVResponse(resp, "ANCHOR1_PRICE");
|
| string a2BarStr = Ai_ParseKVResponse(resp, "ANCHOR2_BAR");
|
| string a2PriceStr = Ai_ParseKVResponse(resp, "ANCHOR2_PRICE");
|
| string reason = Ai_ParseKVResponse(resp, "REASON");
|
| //--- Validate trend
|
| StringToUpper(trend);
|
| if(trend != "UP" && trend != "DOWN" && trend != "RANGE")
|
| trend = "RANGE";
|
| //--- Convert anchor inputs
|
| int a1Bar = (int)StringToInteger(a1BarStr);
|
| int a2Bar = (int)StringToInteger(a2BarStr);
|
| double a1Price = StringToDouble(a1PriceStr);
|
| double a2Price = StringToDouble(a2PriceStr);
|
| //--- Clamp anchor bar indices
|
| if(a1Bar < 1)
|
| a1Bar = N;
|
| if(a1Bar > N)
|
| a1Bar = N;
|
| if(a2Bar < 1)
|
| a2Bar = 1;
|
| if(a2Bar > N)
|
| a2Bar = N;
|
| //--- Apply price fallbacks based on trend
|
| if(a1Price <= 0.0)
|
| {
|
| if(trend == "UP")
|
| a1Price = ra[a1Bar - 1].low;
|
| else
|
| if(trend == "DOWN")
|
| a1Price = ra[a1Bar - 1].high;
|
| else
|
| a1Price = (ra[a1Bar - 1].high + ra[a1Bar - 1].low) / 2.0;
|
| }
|
| if(a2Price <= 0.0)
|
| {
|
| if(trend == "UP")
|
| a2Price = ra[a2Bar - 1].low;
|
| else
|
| if(trend == "DOWN")
|
| a2Price = ra[a2Bar - 1].high;
|
| else
|
| a2Price = (ra[a2Bar - 1].high + ra[a2Bar - 1].low) / 2.0;
|
| }
|
| //--- Build and push chat bubble
|
| string bubble = "Trend Read -> " + trend + "\n" + reason;
|
| Ai_PushSignalActionToChat("Trend Read", bubble, elapsed);
|
| //--- Draw trendline and label
|
| if(a1Price > 0 && a2Price > 0)
|
| {
|
| static int s_trendSeq = 0;
|
| s_trendSeq++;
|
| datetime t1 = iTime(Symbol(), Period(), a1Bar);
|
| datetime t2 = iTime(Symbol(), Period(), a2Bar);
|
| color trendColor = (trend == "UP") ? clrLimeGreen
|
| : (trend == "DOWN") ? clrCrimson
|
| : clrGray;
|
| //--- Create trendline object
|
| string lineName = AI_SIGNAL_OBJ_PREFIX + "TrendRead_Line_" + IntegerToString(s_trendSeq);
|
| if(ObjectFind(0, lineName) >= 0)
|
| ObjectDelete(0, lineName);
|
| ObjectCreate(0, lineName, OBJ_TREND, 0, t1, a1Price, t2, a2Price);
|
| ObjectSetInteger(0, lineName, OBJPROP_COLOR, trendColor);
|
| ObjectSetInteger(0, lineName, OBJPROP_WIDTH, 2);
|
| ObjectSetInteger(0, lineName, OBJPROP_RAY_RIGHT, false);
|
| ObjectSetInteger(0, lineName, OBJPROP_SELECTABLE, false);
|
| ObjectSetInteger(0, lineName, OBJPROP_HIDDEN, true);
|
| //--- Create label at newer end
|
| string lblName = AI_SIGNAL_OBJ_PREFIX + "TrendRead_Label_" + IntegerToString(s_trendSeq);
|
| if(ObjectFind(0, lblName) >= 0)
|
| ObjectDelete(0, lblName);
|
| ObjectCreate(0, lblName, OBJ_TEXT, 0, t2, a2Price);
|
| ObjectSetString(0, lblName, OBJPROP_TEXT, " " + trend);
|
| ObjectSetInteger(0, lblName, OBJPROP_COLOR, trendColor);
|
| ObjectSetInteger(0, lblName, OBJPROP_FONTSIZE, 10);
|
| ObjectSetString(0, lblName, OBJPROP_FONT, "Arial Bold");
|
| ObjectSetInteger(0, lblName, OBJPROP_ANCHOR, ANCHOR_LEFT);
|
| ObjectSetInteger(0, lblName, OBJPROP_SELECTABLE, false);
|
| ObjectSetInteger(0, lblName, OBJPROP_HIDDEN, true);
|
| ChartRedraw();
|
| }
|
| g_ai_signalRequestInFlight = false;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Key Level action - support or resistance level near price |
|
| //+------------------------------------------------------------------+
|
| void AiKeyLevel()
|
| {
|
| //--- Throttle concurrent requests
|
| if(g_ai_signalRequestInFlight)
|
| {
|
| Ai_ShowToast("Signal in progress - please wait", true);
|
| return;
|
| }
|
| g_ai_signalRequestInFlight = true;
|
| //--- Configure bar count and build bars block
|
| const int N = 50;
|
| string barsBlock = Ai_FormatBarsDesc(Period(), N);
|
| if(StringLen(barsBlock) == 0)
|
| {
|
| g_ai_signalRequestInFlight = false;
|
| Ai_ShowToast("Key Level: failed to load bars", true);
|
| return;
|
| }
|
| //--- Read current price
|
| double currentPrice = SymbolInfoDouble(Symbol(), SYMBOL_BID);
|
| //--- Build key level prompt
|
| string prompt =
|
| "You are a precise support/resistance analyst.\n"
|
| "\n"
|
| + Ai_BuildBarPreamble()
|
| + "CURRENT PRICE (bid): " + DoubleToString(currentPrice, _Digits) + "\n"
|
| "\n"
|
| "DECISION RULES FOR KEY LEVEL:\n"
|
| " - Identify ONE significant horizontal level near current price (within reasonable trading distance).\n"
|
| " - LEVEL_TYPE is SUPPORT if the level is BELOW current price (price has bounced UP from there).\n"
|
| " - LEVEL_TYPE is RESISTANCE if the level is ABOVE current price (price has bounced DOWN from there).\n"
|
| " - Choose the level with strongest evidence: multiple touches, prior swing high/low, recent rejection.\n"
|
| " - PRICE must come from actual high/low values in the DATA - do not invent prices.\n"
|
| " - You MUST scan ALL 50 bars to identify the MOST SIGNIFICANT level - do not pick the first level you notice.\n"
|
| "\n"
|
| "TRADE BIAS - your read on what price will do AT this level:\n"
|
| " - TRADE_BIAS is BOUNCE if you expect price to reject at this level and reverse (level holds).\n"
|
| " Use BOUNCE when the level has been repeatedly defended with strong rejections.\n"
|
| " - TRADE_BIAS is BREAK if you expect price to push through this level (level fails).\n"
|
| " Use BREAK when price is approaching the level with strong momentum, multiple recent failed defenses, or the level has weakened.\n"
|
| "\n"
|
| "REASON FORMATTING - REQUIRED:\n"
|
| " Your REASON must include ALL of the following, in this order:\n"
|
| " (a) State how many bars you analyzed (always 50 for this action).\n"
|
| " (b) State the OVERALL price range - highest HIGH and lowest LOW across the set, with their bar numbers.\n"
|
| " (c) Cite ALL the bars where price touched/tested this level (with their high or low values).\n"
|
| " (d) Explain why you chose THIS level over other candidates you saw.\n"
|
| " (e) Justify the TRADE_BIAS based on the most recent test(s) - what did price do the last 1-2 times it reached this level?\n"
|
| "\n"
|
| "OUTPUT FORMAT (LINE-BASED KEY: VALUE - NO JSON, NO MARKDOWN, NO PROSE):\n"
|
| "Output EXACTLY 4 lines, in this order:\n"
|
| "LEVEL_TYPE: <SUPPORT or RESISTANCE>\n"
|
| "PRICE: <decimal price, 5 decimals, an actual high or low from the bars>\n"
|
| "TRADE_BIAS: <BOUNCE or BREAK>\n"
|
| "REASON: <single-line summary covering all 5 elements (a)-(e), separated by '; '>\n"
|
| "\n"
|
| "EXAMPLE (replace values with actual ones from DATA - note how it covers ALL 50 bars and multiple touches):\n"
|
| "LEVEL_TYPE: RESISTANCE\n"
|
| "PRICE: 1.10500\n"
|
| "TRADE_BIAS: BOUNCE\n"
|
| "REASON: Analyzed 50 bars; range was 1.10080 (low at Bar 12) to 1.10545 (high at Bar 41); level 1.10500 was tested at Bars 41 (H=1.10545), 28 (H=1.10510), 18 (H=1.10495), 8 (H=1.10500) - four touches across the set; chose this over a weaker level near 1.10300 because 1.10500 has 4 clean rejections vs only 2 partial touches at 1.10300; bias BOUNCE because the most recent two tests at Bars 8 and 18 both produced strong upper wicks > 30 pips and immediate reversal - level remains well-defended\n"
|
| "\n"
|
| "DATA:\n"
|
| + barsBlock;
|
| //--- Time the call
|
| ulong t0 = GetTickCount();
|
| string resp = AiGetChatGPTResponse(prompt, 0.2);
|
| ulong elapsed = GetTickCount() - t0;
|
| //--- Parse response
|
| string levelType = Ai_ParseKVResponse(resp, "LEVEL_TYPE");
|
| string priceStr = Ai_ParseKVResponse(resp, "PRICE");
|
| string tradeBias = Ai_ParseKVResponse(resp, "TRADE_BIAS");
|
| string reason = Ai_ParseKVResponse(resp, "REASON");
|
| StringToUpper(levelType);
|
| StringToUpper(tradeBias);
|
| double price = StringToDouble(priceStr);
|
| //--- Validate level type and price relationship
|
| bool valid = (price > 0 && (levelType == "SUPPORT" || levelType == "RESISTANCE"));
|
| if(valid)
|
| {
|
| if(price < currentPrice && levelType != "SUPPORT")
|
| levelType = "SUPPORT";
|
| if(price > currentPrice && levelType != "RESISTANCE")
|
| levelType = "RESISTANCE";
|
| }
|
| //--- Default trade bias if invalid
|
| if(tradeBias != "BOUNCE" && tradeBias != "BREAK")
|
| tradeBias = "BOUNCE";
|
| //--- Build chat bubble
|
| string bubble;
|
| if(valid)
|
| bubble = "Key Level -> " + levelType + " at " + DoubleToString(price, _Digits)
|
| + " | Bias: " + tradeBias + "\n" + reason;
|
| else
|
| bubble = "Key Level -> no clear level found\n" + reason;
|
| //--- Auto-trade pending order based on level type and bias
|
| if(AutoTrade && valid)
|
| {
|
| ENUM_ORDER_TYPE ot = (ENUM_ORDER_TYPE)-1;
|
| double sl = 0.0, tp = 0.0;
|
| const double buf = Ai_DynamicBuffer();
|
| //--- SUPPORT + BOUNCE -> BUY LIMIT
|
| if(levelType == "SUPPORT" && tradeBias == "BOUNCE")
|
| {
|
| ot = ORDER_TYPE_BUY_LIMIT;
|
| sl = price - buf;
|
| tp = price + 2.0 * (price - sl);
|
| }
|
| //--- SUPPORT + BREAK -> SELL STOP
|
| else
|
| if(levelType == "SUPPORT" && tradeBias == "BREAK")
|
| {
|
| ot = ORDER_TYPE_SELL_STOP;
|
| sl = price + buf;
|
| tp = price - 2.0 * (sl - price);
|
| }
|
| //--- RESISTANCE + BOUNCE -> SELL LIMIT
|
| else
|
| if(levelType == "RESISTANCE" && tradeBias == "BOUNCE")
|
| {
|
| ot = ORDER_TYPE_SELL_LIMIT;
|
| sl = price + buf;
|
| tp = price - 2.0 * (sl - price);
|
| }
|
| //--- RESISTANCE + BREAK -> BUY STOP
|
| else
|
| if(levelType == "RESISTANCE" && tradeBias == "BREAK")
|
| {
|
| ot = ORDER_TYPE_BUY_STOP;
|
| sl = price - buf;
|
| tp = price + 2.0 * (price - sl);
|
| }
|
| //--- Submit pending order
|
| if(ot != (ENUM_ORDER_TYPE)-1)
|
| {
|
| bool ok = false;
|
| ulong tk = 0;
|
| string err = "";
|
| Ai_PlaceOrder(ot, price, sl, tp, "Key Level " + levelType + "/" + tradeBias,
|
| ok, tk, err);
|
| if(ok)
|
| {
|
| bubble += "\nTrade: " + Ai_OrderTypeLabel(ot)
|
| + " @ " + DoubleToString(price, _Digits)
|
| + " | SL=" + DoubleToString(sl, _Digits)
|
| + " | TP=" + DoubleToString(tp, _Digits)
|
| + " | Ticket #" + IntegerToString((long)tk);
|
| }
|
| else
|
| {
|
| bubble += "\nTrade FAILED: " + err;
|
| Ai_ShowToast("Key Level trade failed: " + err, true);
|
| }
|
| }
|
| }
|
| //--- Push to chat history
|
| Ai_PushSignalActionToChat("Key Level", bubble, elapsed);
|
| //--- Draw horizontal level and label
|
| if(valid)
|
| {
|
| static int s_levelSeq = 0;
|
| s_levelSeq++;
|
| color levelColor = (levelType == "SUPPORT") ? clrMediumSeaGreen : clrIndianRed;
|
| //--- Create horizontal line
|
| string lineName = AI_SIGNAL_OBJ_PREFIX + "KeyLevel_HLine_" + IntegerToString(s_levelSeq);
|
| if(ObjectFind(0, lineName) >= 0)
|
| ObjectDelete(0, lineName);
|
| ObjectCreate(0, lineName, OBJ_HLINE, 0, 0, price);
|
| ObjectSetInteger(0, lineName, OBJPROP_COLOR, levelColor);
|
| ObjectSetInteger(0, lineName, OBJPROP_WIDTH, 1);
|
| ObjectSetInteger(0, lineName, OBJPROP_STYLE, STYLE_DASH);
|
| ObjectSetInteger(0, lineName, OBJPROP_SELECTABLE, false);
|
| ObjectSetInteger(0, lineName, OBJPROP_HIDDEN, true);
|
| //--- Create label anchored to last closed bar
|
| datetime tNow = iTime(Symbol(), Period(), 1);
|
| string lblName = AI_SIGNAL_OBJ_PREFIX + "KeyLevel_Label_" + IntegerToString(s_levelSeq);
|
| if(ObjectFind(0, lblName) >= 0)
|
| ObjectDelete(0, lblName);
|
| ObjectCreate(0, lblName, OBJ_TEXT, 0, tNow, price);
|
| ObjectSetString(0, lblName, OBJPROP_TEXT, " " + levelType + " " + DoubleToString(price, _Digits));
|
| ObjectSetInteger(0, lblName, OBJPROP_COLOR, levelColor);
|
| ObjectSetInteger(0, lblName, OBJPROP_FONTSIZE, 9);
|
| ObjectSetString(0, lblName, OBJPROP_FONT, "Arial Bold");
|
| ObjectSetInteger(0, lblName, OBJPROP_ANCHOR, ANCHOR_LEFT_LOWER);
|
| ObjectSetInteger(0, lblName, OBJPROP_SELECTABLE, false);
|
| ObjectSetInteger(0, lblName, OBJPROP_HIDDEN, true);
|
| ChartRedraw();
|
| }
|
| g_ai_signalRequestInFlight = false;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Clear all AI signal drawings from chart |
|
| //+------------------------------------------------------------------+
|
| void AiClearSignalDrawings()
|
| {
|
| //--- Count objects before deletion
|
| const int before = ObjectsTotal(0, -1, -1);
|
| //--- Delete all objects matching prefix
|
| ObjectsDeleteAll(0, AI_SIGNAL_OBJ_PREFIX);
|
| //--- Count objects after and compute removed
|
| const int after = ObjectsTotal(0, -1, -1);
|
| const int removed = before - after;
|
| ChartRedraw();
|
| //--- Notify user via toast
|
| if(removed > 0)
|
| {
|
| Ai_ShowToast("Cleared " + IntegerToString(removed) + " signal drawing(s)", false);
|
| }
|
| else
|
| {
|
| Ai_ShowToast("No signal drawings to clear", false);
|
| }
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Quick Scalp action - candlestick pattern signal |
|
| //+------------------------------------------------------------------+
|
| void AiGetTradeSignal(bool isAuto = false)
|
| {
|
| //--- Throttle concurrent requests
|
| if(!isAuto && g_ai_signalRequestInFlight)
|
| {
|
| Ai_ShowToast("Signal in progress - please wait", true);
|
| return;
|
| }
|
| g_ai_signalRequestInFlight = true;
|
| //--- Build mode label and log start
|
| string modeStr = isAuto ? " (Auto-mode)" : " (Manual)";
|
| Print("Starting Quick Scalp signal analysis", modeStr);
|
| //--- Pull bars and order newest first
|
| const int N = 10;
|
| MqlRates ra[];
|
| if(CopyRates(Symbol(), Period(), 1, N, ra) < N)
|
| {
|
| g_ai_signalRequestInFlight = false;
|
| Alert("Quick Scalp: failed to load " + IntegerToString(N) + " bars.");
|
| return;
|
| }
|
| ArraySetAsSeries(ra, true);
|
| //--- Build bars block
|
| string barsBlock = Ai_FormatBarsDesc(Period(), N);
|
| if(StringLen(barsBlock) == 0)
|
| {
|
| g_ai_signalRequestInFlight = false;
|
| Alert("Quick Scalp: failed to format bars.");
|
| return;
|
| }
|
| //--- Build pattern-detection prompt
|
| string prompt =
|
| "You are a precise candlestick-pattern trading analyst. Analyze the last " + IntegerToString(N) + " bars in DATA.\n"
|
| "\n"
|
| + Ai_BuildBarPreamble()
|
| + "PATTERN DEFINITIONS (match exactly - if no exact match, output NONE):\n"
|
| " - BULLISH ENGULFING: a BULLISH bar whose body fully engulfs the prior bar's body, AND the prior bar is BEARISH.\n"
|
| " - BEARISH ENGULFING: a BEARISH bar whose body fully engulfs the prior bar's body, AND the prior bar is BULLISH.\n"
|
| " - HAMMER: a BULLISH bar with a small body near the top and a long lower wick (lower wick >= 2x body).\n"
|
| " - SHOOTING STAR: a BEARISH bar with a small body near the bottom and a long upper wick (upper wick >= 2x body).\n"
|
| " - PIN BAR (BULLISH): long lower wick rejecting lower prices; close in upper third of bar range.\n"
|
| " - PIN BAR (BEARISH): long upper wick rejecting higher prices; close in lower third of bar range.\n"
|
| " - INSIDE BAR: a bar whose high <= prior high AND low >= prior low (entirely inside prior bar's range).\n"
|
| "\n"
|
| "DECISION RULES:\n"
|
| " - If you find a clear BULLISH pattern (BULLISH ENGULFING, HAMMER, BULLISH PIN BAR) on Bar 1 or Bar 2 -> SIGNAL is BUY.\n"
|
| " - If you find a clear BEARISH pattern (BEARISH ENGULFING, SHOOTING STAR, BEARISH PIN BAR) on Bar 1 or Bar 2 -> SIGNAL is SELL.\n"
|
| " - INSIDE BAR can be either direction depending on context.\n"
|
| " - If no clear pattern matches the definitions above -> SIGNAL is NONE.\n"
|
| "\n"
|
| "PRICE GUIDANCE (these MUST come from actual OHLC values in the DATA):\n"
|
| " - ENTRY = the TRIGGER_BAR close price.\n"
|
| " - SL (stop loss): for BUY, just BELOW the trigger bar's LOW (e.g. trigger LOW minus a few pips). For SELL, just ABOVE the trigger bar's HIGH.\n"
|
| " - TP (take profit): roughly 2x the SL distance away from ENTRY in the trade direction (so target reward = 2x risk).\n"
|
| " - You MUST scan ALL 10 bars before settling on a trigger - pick the STRONGEST pattern, not the first one you spot.\n"
|
| "\n"
|
| "REASON FORMATTING - REQUIRED:\n"
|
| " Your REASON must include ALL of the following, in this order:\n"
|
| " (a) State how many bars you analyzed (always 10 for this action).\n"
|
| " (b) State the price range across the 10 bars (highest HIGH, lowest LOW with bar numbers).\n"
|
| " (c) Name the EXACT pattern at the TRIGGER_BAR with its OHLC values, and the prior bar's OHLC if relevant (e.g. for engulfing).\n"
|
| " (d) Briefly state why this pattern over alternatives - were there competing patterns at other bars? If yes, why this one wins.\n"
|
| "\n"
|
| "OUTPUT FORMAT (LINE-BASED KEY: VALUE - NO JSON, NO MARKDOWN, NO PROSE):\n"
|
| "Output EXACTLY 6 lines, in this order:\n"
|
| "SIGNAL: <BUY or SELL or NONE>\n"
|
| "REASON: <single-line summary covering all 4 elements (a)-(d), separated by '; '>\n"
|
| "TRIGGER_BAR: <integer 1..10, the bar where the pattern formed>\n"
|
| "ENTRY: <decimal price, 5 decimals; or 0 if SIGNAL is NONE>\n"
|
| "SL: <decimal price, 5 decimals; or 0 if SIGNAL is NONE>\n"
|
| "TP: <decimal price, 5 decimals; or 0 if SIGNAL is NONE>\n"
|
| "\n"
|
| "EXAMPLE (replace values with actual ones from DATA - note how REASON covers ALL 4 elements):\n"
|
| "SIGNAL: BUY\n"
|
| "REASON: Analyzed 10 bars; range 1.09980 (low at Bar 7) to 1.10310 (high at Bar 4); Bar 1 BULLISH ENGULFING - Bar 1 O=1.10100 C=1.10280 (BULLISH) engulfs Bar 2 O=1.10240 C=1.10110 (BEARISH), Bar 1 body fully covers Bar 2 body; chose this over a weaker hammer at Bar 5 because the engulfing has higher volume context (Bar 1 range 180 pips vs Bar 5 range 60 pips) and is at the most recent bar\n"
|
| "TRIGGER_BAR: 1\n"
|
| "ENTRY: 1.10280\n"
|
| "SL: 1.10080\n"
|
| "TP: 1.10680\n"
|
| "\n"
|
| "DATA:\n"
|
| + barsBlock;
|
| //--- Time the call
|
| ulong signalStartMs = GetTickCount();
|
| string response = AiGetChatGPTResponse(prompt, 0.0);
|
| ulong signalElapsedMs = GetTickCount() - signalStartMs;
|
| int signalElapsedSec = (int)(signalElapsedMs / 1000);
|
| string signalTimeNote = "\n(Response in " + IntegerToString(signalElapsedSec) + "s)";
|
| Print("Raw AI Response: ", response);
|
| //--- Parse response
|
| string signal = Ai_ParseKVResponse(response, "SIGNAL");
|
| string reason = Ai_ParseKVResponse(response, "REASON");
|
| string trigStr = Ai_ParseKVResponse(response, "TRIGGER_BAR");
|
| string entryStr = Ai_ParseKVResponse(response, "ENTRY");
|
| string slStr = Ai_ParseKVResponse(response, "SL");
|
| string tpStr = Ai_ParseKVResponse(response, "TP");
|
| StringToUpper(signal);
|
| //--- Validate signal
|
| if(signal != "BUY" && signal != "SELL" && signal != "NONE")
|
| {
|
| g_ai_signalRequestInFlight = false;
|
| Alert("Quick Scalp: invalid SIGNAL '" + signal + "'. Raw: " + response);
|
| return;
|
| }
|
| //--- Convert numeric fields
|
| int triggerBar = (int)StringToInteger(trigStr);
|
| double entry = StringToDouble(entryStr);
|
| double sl = StringToDouble(slStr);
|
| double tp = StringToDouble(tpStr);
|
| if(triggerBar < 1 || triggerBar > N)
|
| triggerBar = 1;
|
| //--- Apply local fallbacks for entry/SL/TP
|
| if(signal == "BUY" || signal == "SELL")
|
| {
|
| const MqlRates trig = ra[triggerBar - 1];
|
| const double bufferPts = Ai_DynamicBuffer();
|
| if(entry <= 0.0)
|
| entry = trig.close;
|
| if(signal == "BUY")
|
| {
|
| if(sl <= 0.0)
|
| sl = trig.low - bufferPts;
|
| if(tp <= 0.0)
|
| tp = entry + 2.0 * (entry - sl);
|
| }
|
| else
|
| {
|
| if(sl <= 0.0)
|
| sl = trig.high + bufferPts;
|
| if(tp <= 0.0)
|
| tp = entry - 2.0 * (sl - entry);
|
| }
|
| }
|
| //--- Build chat bubble
|
| string bubble;
|
| if(signal == "BUY" || signal == "SELL")
|
| {
|
| bubble = "Quick Scalp -> " + signal + " at " + DoubleToString(entry, _Digits)
|
| + " (SL=" + DoubleToString(sl, _Digits)
|
| + ", TP=" + DoubleToString(tp, _Digits)
|
| + ", Bar " + IntegerToString(triggerBar) + ")"
|
| + "\n" + reason;
|
| }
|
| else
|
| {
|
| bubble = "Quick Scalp -> NONE\n" + reason;
|
| }
|
| //--- Auto-trade if enabled
|
| if(AutoTrade && (signal == "BUY" || signal == "SELL"))
|
| {
|
| const ENUM_ORDER_TYPE ot = (signal == "BUY") ? ORDER_TYPE_BUY : ORDER_TYPE_SELL;
|
| bool ok = false;
|
| ulong tk = 0;
|
| string err = "";
|
| Ai_PlaceOrder(ot, 0.0, sl, tp, "Quick Scalp", ok, tk, err);
|
| if(ok)
|
| {
|
| bubble += "\nTrade: " + Ai_OrderTypeLabel(ot)
|
| + " @ " + DoubleToString(entry, _Digits)
|
| + " | SL=" + DoubleToString(sl, _Digits)
|
| + " | TP=" + DoubleToString(tp, _Digits)
|
| + " | Ticket #" + IntegerToString((long)tk);
|
| }
|
| else
|
| {
|
| bubble += "\nTrade FAILED: " + err;
|
| Ai_ShowToast("Quick Scalp trade failed: " + err, true);
|
| }
|
| }
|
| //--- Append to history
|
| string timestamp = TimeToString(TimeCurrent(), TIME_MINUTES);
|
| g_ai_conversationHistory += "You: Quick Scalp" + modeStr + "\n" + timestamp + "\n\n"
|
| + "AI: " + bubble + signalTimeNote + "\n" + timestamp + "\n\n";
|
| g_ai_chatScrollPin = true;
|
| //--- Auto-rename placeholder title
|
| if(StringFind(g_ai_currentTitle, "Chat ") == 0)
|
| {
|
| g_ai_currentTitle = "Quick Scalp";
|
| }
|
| //--- Persist and refresh
|
| AiUpdateCurrentHistory();
|
| Ai_RenderAll();
|
| ChartRedraw();
|
| //--- Visualize signal on chart
|
| string fullReason = reason + " (Bar " + IntegerToString(triggerBar) + ")";
|
| if(signal != "NONE")
|
| AiVisualizeSignal(signal, fullReason, triggerBar, entry, sl, tp);
|
| else
|
| Print("Quick Scalp: NONE - skipping viz/trade.");
|
| g_ai_signalRequestInFlight = false;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Append chart data to current prompt |
|
| //+------------------------------------------------------------------+
|
| void AiGetAndAppendChartData()
|
| {
|
| //--- Pull chart data string
|
| string data = AiGetChartDataString();
|
| if(StringLen(data) == 0)
|
| return;
|
| //--- Log data
|
| Print("Chart data appended to prompt: \n", data);
|
| if(g_ai_logFileHandle != INVALID_HANDLE)
|
| FileWrite(g_ai_logFileHandle, "Chart data appended to prompt: \n" + data);
|
| //--- Append to current prompt with newline separator
|
| if(StringLen(g_ai_currentPrompt) > 0)
|
| g_ai_currentPrompt += "\n";
|
| g_ai_currentPrompt += data;
|
| //--- Sync editor and refresh
|
| g_ai_editor.SetText(g_ai_currentPrompt);
|
| Ai_RenderAll();
|
| ChartRedraw();
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Submit user message to AI and update chat |
|
| //+------------------------------------------------------------------+
|
| void AiSubmitMessage(string prompt)
|
| {
|
| //--- Strip surrounding blank lines
|
| prompt = AiStripLeadingBlankLines(prompt);
|
| prompt = AiStripTrailingBlankLines(prompt);
|
| if(StringLen(prompt) == 0)
|
| return;
|
| //--- Build timestamp and init response state
|
| string timestamp = TimeToString(TimeCurrent(), TIME_MINUTES);
|
| string response = "";
|
| bool send_to_api = true;
|
| //--- Handle local "set title" command
|
| if(StringFind(prompt, "set title ") == 0)
|
| {
|
| string new_title = StringSubstr(prompt, 10);
|
| g_ai_currentTitle = new_title;
|
| response = "Title set to " + new_title;
|
| send_to_api = false;
|
| AiUpdateCurrentHistory();
|
| }
|
| //--- Append the user turn, embedding the attached screenshot marker and staging its base64
|
| string imgMarker = "";
|
| if(g_ai_attachPending && send_to_api && StringLen(g_ai_attachBase) > 0)
|
| {
|
| imgMarker = "\n[img:" + g_ai_attachBase + ":"
|
| + IntegerToString(g_ai_attachW) + ":" + IntegerToString(g_ai_attachH) + "]";
|
| g_ai_pendingVisionB64 = g_ai_attachB64;
|
| }
|
| //--- Clear the attachment slot once consumed (or if this turn didn't send)
|
| if(g_ai_attachPending && send_to_api)
|
| {
|
| g_ai_attachPending = false;
|
| g_ai_attachBase = "";
|
| g_ai_attachB64 = "";
|
| g_ai_attachW = 0;
|
| g_ai_attachH = 0;
|
| }
|
| g_ai_conversationHistory += "You: " + prompt + imgMarker + "\n" + timestamp + "\n\n";
|
| g_ai_chatScrollPin = true;
|
| //--- Process API path
|
| if(send_to_api)
|
| {
|
| //--- Append preparing placeholder
|
| g_ai_conversationHistory += AI_PREP_BASE + "\n" + timestamp + "\n\n";
|
| g_ai_chatScrollPin = true;
|
| Ai_RenderAll();
|
| ChartRedraw();
|
| //--- Run pre-animation cycles
|
| for(int i = 0; i < AI_PRE_ANIM_CYCLES; i++)
|
| {
|
| int subCycle = i % 3;
|
| string dots = "";
|
| for(int d = 0; d <= subCycle; d++)
|
| dots += ".";
|
| int prepPos = StringFind(g_ai_conversationHistory, AI_PREP_BASE, 0);
|
| if(prepPos >= 0)
|
| {
|
| int endPos = StringFind(g_ai_conversationHistory, "\n\n", prepPos) + 2;
|
| if(endPos < 2)
|
| endPos = StringLen(g_ai_conversationHistory);
|
| string before = StringSubstr(g_ai_conversationHistory, 0, prepPos);
|
| string after = StringSubstr(g_ai_conversationHistory, endPos);
|
| g_ai_conversationHistory = before + AI_PREP_BASE + dots + "\n" + timestamp + "\n\n" + after;
|
| }
|
| g_ai_chatScrollPin = true;
|
| Ai_RenderAll();
|
| ChartRedraw();
|
| Sleep(200);
|
| }
|
| //--- Swap preparing placeholder for thinking placeholder
|
| int prepPos = StringFind(g_ai_conversationHistory, AI_PREP_BASE, 0);
|
| if(prepPos >= 0)
|
| {
|
| int endPos = StringFind(g_ai_conversationHistory, "\n\n", prepPos) + 2;
|
| if(endPos < 2)
|
| endPos = StringLen(g_ai_conversationHistory);
|
| string before = StringSubstr(g_ai_conversationHistory, 0, prepPos);
|
| string after = StringSubstr(g_ai_conversationHistory, endPos);
|
| g_ai_conversationHistory = before + AI_LOADING_PLACEHOLDER + "\n" + timestamp + "\n\n" + after;
|
| }
|
| else
|
| {
|
| g_ai_conversationHistory += AI_LOADING_PLACEHOLDER + "\n" + timestamp + "\n\n";
|
| }
|
| g_ai_chatScrollPin = true;
|
| Ai_RenderAll();
|
| ChartRedraw();
|
| //--- Issue API request
|
| g_ai_startTimeMs = GetTickCount();
|
| Print("Chat ID: ", g_ai_currentChatId, ", Title: ", g_ai_currentTitle);
|
| if(g_ai_logFileHandle != INVALID_HANDLE)
|
| FileWrite(g_ai_logFileHandle, "Chat ID: " + IntegerToString(g_ai_currentChatId) + ", Title: " + g_ai_currentTitle);
|
| Print("User: ", prompt);
|
| if(g_ai_logFileHandle != INVALID_HANDLE)
|
| FileWrite(g_ai_logFileHandle, "User: " + prompt);
|
| response = AiSendRequest(prompt); // Unified send path with context, vision, tools and actions
|
| //--- Swap in the approval card text when the send parked on a pending action
|
| const bool actionParked = (response == AI_ACTION_PENDING_SENTINEL);
|
| if(actionParked)
|
| response = g_ai_actionCardText;
|
| Print("AI: ", response);
|
| if(g_ai_logFileHandle != INVALID_HANDLE)
|
| FileWrite(g_ai_logFileHandle, "AI: " + response);
|
| //--- Compute elapsed time note
|
| ulong elapsedMs = GetTickCount() - g_ai_startTimeMs;
|
| int elapsedSec = (int)(elapsedMs / 1000);
|
| string timeNote = "\n(Response in " + IntegerToString(elapsedSec) + "s)";
|
| if(actionParked)
|
| timeNote = "";
|
| //--- Replace thinking placeholder with real response
|
| int placeholderPos = StringFind(g_ai_conversationHistory, AI_LOADING_PLACEHOLDER, 0);
|
| if(placeholderPos >= 0)
|
| {
|
| int endPos = StringFind(g_ai_conversationHistory, "\n\n", placeholderPos) + 2;
|
| if(endPos < 2)
|
| endPos = StringLen(g_ai_conversationHistory);
|
| string before = StringSubstr(g_ai_conversationHistory, 0, placeholderPos);
|
| string after = StringSubstr(g_ai_conversationHistory, endPos);
|
| g_ai_conversationHistory = before + "AI: " + response + timeNote + "\n" + timestamp + "\n\n" + after;
|
| }
|
| else
|
| {
|
| g_ai_conversationHistory += "AI: " + response + timeNote + "\n" + timestamp + "\n\n";
|
| }
|
| //--- Auto-truncate placeholder title from prompt
|
| bool didAutoTruncate = false;
|
| if(StringFind(g_ai_currentTitle, "Chat ") == 0)
|
| {
|
| g_ai_currentTitle = StringSubstr(prompt, 0, 30);
|
| if(StringLen(prompt) > 30)
|
| g_ai_currentTitle += "...";
|
| AiUpdateCurrentHistory();
|
| didAutoTruncate = true;
|
| }
|
| //--- Auto-name via secondary AI call on first completed exchange only
|
| if(didAutoTruncate && !actionParked)
|
| {
|
| int aiTurnCount = 0;
|
| int searchPos = 0;
|
| while(true)
|
| {
|
| int found = StringFind(g_ai_conversationHistory, "AI: ", searchPos);
|
| if(found < 0)
|
| break;
|
| aiTurnCount++;
|
| searchPos = found + 4;
|
| }
|
| if(aiTurnCount == 1)
|
| {
|
| Ai_AutoTitleChat(prompt, response);
|
| }
|
| }
|
| }
|
| else
|
| {
|
| //--- Append local response without API call
|
| g_ai_conversationHistory += "AI: " + response + "\n" + timestamp + "\n\n";
|
| }
|
| //--- Persist and refresh
|
| AiUpdateCurrentHistory();
|
| Ai_RenderAll();
|
| ChartRedraw();
|
| }
|
|
|
| #endif // AI_LOGIC_MQH
|
| //+------------------------------------------------------------------+ |