AITelegramSignalCopier/AITelegramSignalCopier.mq5
chachaian 09793f8c55 Improve code documentation and readability
* Refactor the EA for improved code organization
* Add MetaQuotes-style comments throughout the source code
* Document the purpose of major logical blocks
* Improve inline documentation for complex execution paths
* Standardize log messages for clearer execution tracing
* Preserve existing functionality while improving maintainability
* Prepare the codebase for the accompanying MQL5 article series
2026-06-27 20:08:19 -07:00

733 lines
24 KiB
MQL5

//+------------------------------------------------------------------+
//| AITelegramSignalCopier.mq5 |
//| AI-powered Telegram Signal Copier for MT5 |
//+------------------------------------------------------------------+
#property copyright "Copyright 2026"
#property version "1.00"
#property strict
#include <Trade/Trade.mqh>
//--- external inputs
input string InpApiBaseUrl = "http://127.0.0.1:5000"; // Flask API base URL
input double InpLotSize = 0.01; // Fixed lot size
input int InpTimerSec = 5; // Polling interval in seconds
input bool InpVerboseLog = true; // Enable detailed log messages
//--- trading object
CTrade trade;
//--- structure used to store one signal received from the Flask API
struct SSignal
{
long id; // database signal ID
string symbol; // canonical symbol returned by the backend
string direction; // BUY or SELL
string intent; // AI-parsed trading intent
string orderType; // executable order type, if provided by backend
double entryPrice; // requested entry price for pending orders
double stopLoss; // stop-loss price
double takeProfit; // take-profit price
double confidence; // AI confidence score
};
//--- stores the last processed signal ID to prevent duplicate execution
long g_lastSignalId = 0;
//+------------------------------------------------------------------+
//| Expert initialization function |
//+------------------------------------------------------------------+
int OnInit()
{
//--- validate timer input before starting the EA
if(InpTimerSec <= 0)
{
Print("[INIT] Invalid timer interval. InpTimerSec must be greater than zero.");
return(INIT_PARAMETERS_INCORRECT);
}
//--- start periodic polling of the Flask backend
EventSetTimer(InpTimerSec);
//--- provide immediate visual feedback in the Experts tab
Print("[INIT] AI Telegram Signal Copier initialized.");
Print("[INIT] API Base URL: ", InpApiBaseUrl);
Print("[INIT] Polling interval: ", InpTimerSec, " seconds.");
return(INIT_SUCCEEDED);
}
//+------------------------------------------------------------------+
//| Expert deinitialization function |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
{
//--- stop timer events when the EA is removed or the terminal closes
EventKillTimer();
Print("[INIT] AI Telegram Signal Copier stopped.");
}
//+------------------------------------------------------------------+
//| Timer event |
//+------------------------------------------------------------------+
void OnTimer()
{
//--- keep the event handler short and delegate the workflow
ProcessPendingSignal();
}
//+------------------------------------------------------------------+
//| Process one pending signal |
//+------------------------------------------------------------------+
void ProcessPendingSignal()
{
string response = "";
//--- request the next pending signal from the Flask backend
if(!HttpGet(BuildUrl("/api/v1/signals/pending"), response))
return;
//--- the backend explicitly returns null when no signal is pending
if(StringFind(response, "\"signal\": null") >= 0)
{
if(InpVerboseLog)
Print("[API] No pending signals available.");
return;
}
SSignal signal;
//--- convert the JSON response into the local SSignal structure
if(!ParsePendingSignal(response, signal))
{
Print("[SIGNAL] Failed to parse pending signal response.");
Print(response);
return;
}
//--- prevent repeated execution of the same signal during timer cycles
if(signal.id == g_lastSignalId)
return;
g_lastSignalId = signal.id;
//--- show the parsed signal before attempting execution
PrintSignal(signal);
ulong ticket = 0;
string message = "";
//--- execute the signal and report the outcome back to the backend
bool executed = ExecuteSignal(signal, ticket, message);
if(executed)
SendExecutionStatus(signal.id, "EXECUTED", ticket, message);
else
SendExecutionStatus(signal.id, "FAILED", 0, message);
}
//+------------------------------------------------------------------+
//| Execute signal |
//+------------------------------------------------------------------+
bool ExecuteSignal(const SSignal &signal, ulong &ticket, string &message)
{
//--- initialize output ticket before execution starts
ticket = 0;
//--- a signal cannot be executed without a symbol
if(signal.symbol == "")
{
message = "Signal symbol is missing.";
Print(message);
return(false);
}
//--- map the canonical AI/backend symbol to the broker-specific symbol
string brokerSymbol = ResolveBrokerSymbol(signal.symbol, message);
if(brokerSymbol == "")
{
Print(message);
return(false);
}
Print("[SYMBOL] Canonical symbol: ", signal.symbol);
Print("[SYMBOL] Resolved broker symbol: ", brokerSymbol);
ENUM_ORDER_TYPE orderType;
//--- convert AI intent into an executable MetaTrader 5 order type
if(!ResolveOrderType(signal, brokerSymbol, orderType, message))
{
Print(message);
return(false);
}
Print("[TRADE] Resolved order type: ", OrderTypeToString(orderType));
//--- normalize prices according to the broker symbol digits
double entry = NormalizeSignalPrice(brokerSymbol, signal.entryPrice);
double sl = NormalizeSignalPrice(brokerSymbol, signal.stopLoss);
double tp = NormalizeSignalPrice(brokerSymbol, signal.takeProfit);
bool result = false;
//--- execute the appropriate market or pending order
if(orderType == ORDER_TYPE_BUY)
result = trade.Buy(InpLotSize, brokerSymbol, 0.0, sl, tp, "AI Telegram Signal");
else
if(orderType == ORDER_TYPE_SELL)
result = trade.Sell(InpLotSize, brokerSymbol, 0.0, sl, tp, "AI Telegram Signal");
else
if(orderType == ORDER_TYPE_BUY_LIMIT)
result = trade.BuyLimit(InpLotSize, entry, brokerSymbol, sl, tp, ORDER_TIME_GTC, 0, "AI Telegram Signal");
else
if(orderType == ORDER_TYPE_SELL_LIMIT)
result = trade.SellLimit(InpLotSize, entry, brokerSymbol, sl, tp, ORDER_TIME_GTC, 0, "AI Telegram Signal");
else
if(orderType == ORDER_TYPE_BUY_STOP)
result = trade.BuyStop(InpLotSize, entry, brokerSymbol, sl, tp, ORDER_TIME_GTC, 0, "AI Telegram Signal");
else
if(orderType == ORDER_TYPE_SELL_STOP)
result = trade.SellStop(InpLotSize, entry, brokerSymbol, sl, tp, ORDER_TIME_GTC, 0, "AI Telegram Signal");
else
{
message = "Unsupported resolved order type.";
Print(message);
return(false);
}
//--- handle broker-side rejection and preserve the retcode message
if(!result)
{
message = "Trade failed. Retcode: " + IntegerToString((int)trade.ResultRetcode()) +
". " + trade.ResultRetcodeDescription();
Print(message);
return(false);
}
//--- store broker order ticket and notify the user
ticket = trade.ResultOrder();
message = "Trade opened successfully.";
Print("[TRADE] ", message);
Print("[TRADE] Broker ticket/order: ", ticket);
NotifyExecutionSuccess(signal, brokerSymbol, orderType, ticket);
return(true);
}
//+------------------------------------------------------------------+
//| Resolve AI intent into executable MT5 order type |
//+------------------------------------------------------------------+
bool ResolveOrderType(const SSignal &signal, const string brokerSymbol, ENUM_ORDER_TYPE &orderType, string &message)
{
//--- read current prices for market and pending order decisions
double ask = SymbolInfoDouble(brokerSymbol, SYMBOL_ASK);
double bid = SymbolInfoDouble(brokerSymbol, SYMBOL_BID);
if(ask <= 0.0 || bid <= 0.0)
{
message = "Failed to read market prices for symbol: " + brokerSymbol;
return(false);
}
Print("[TRADE] Current Ask: ", DoubleToString(ask, (int)SymbolInfoInteger(brokerSymbol, SYMBOL_DIGITS)));
Print("[TRADE] Current Bid: ", DoubleToString(bid, (int)SymbolInfoInteger(brokerSymbol, SYMBOL_DIGITS)));
//--- direct market intents do not require an entry price
if(signal.intent == "BUY_MARKET")
{
orderType = ORDER_TYPE_BUY;
return(true);
}
if(signal.intent == "SELL_MARKET")
{
orderType = ORDER_TYPE_SELL;
return(true);
}
//--- pending order intents require a valid entry price
if(signal.entryPrice <= 0.0)
{
message = "Entry price is required for pending orders.";
return(false);
}
//--- buy-at-price becomes Buy Limit below Ask or Buy Stop above Ask
if(signal.intent == "BUY_AT_PRICE")
{
orderType = (signal.entryPrice < ask) ? ORDER_TYPE_BUY_LIMIT : ORDER_TYPE_BUY_STOP;
return(true);
}
//--- sell-at-price becomes Sell Limit above Bid or Sell Stop below Bid
if(signal.intent == "SELL_AT_PRICE")
{
orderType = (signal.entryPrice > bid) ? ORDER_TYPE_SELL_LIMIT : ORDER_TYPE_SELL_STOP;
return(true);
}
//--- breakout-style buy above current price
if(signal.intent == "BUY_ABOVE")
{
orderType = ORDER_TYPE_BUY_STOP;
return(true);
}
//--- breakout-style sell below current price
if(signal.intent == "SELL_BELOW")
{
orderType = ORDER_TYPE_SELL_STOP;
return(true);
}
message = "Unsupported signal intent: " + signal.intent;
return(false);
}
//+------------------------------------------------------------------+
//| Resolve canonical symbol to broker symbol |
//+------------------------------------------------------------------+
string ResolveBrokerSymbol(const string canonicalSymbol, string &message)
{
//--- reject empty canonical symbols before searching the terminal
if(canonicalSymbol == "")
{
message = "Canonical symbol is empty.";
return("");
}
//--- first try the canonical symbol exactly as received
if(SymbolSelect(canonicalSymbol, true))
return(canonicalSymbol);
//--- if the EA is attached to a matching chart symbol, prefer it
if(StringFind(_Symbol, canonicalSymbol) == 0)
{
if(SymbolSelect(_Symbol, true))
return(_Symbol);
}
//--- search visible Market Watch symbols first
int marketWatchTotal = SymbolsTotal(true);
for(int i = 0; i < marketWatchTotal; i++)
{
string symbolName = SymbolName(i, true);
if(StringFind(symbolName, canonicalSymbol) == 0)
{
if(SymbolSelect(symbolName, true))
return(symbolName);
}
}
//--- search all broker symbols if Market Watch search fails
int allSymbolsTotal = SymbolsTotal(false);
for(int j = 0; j < allSymbolsTotal; j++)
{
string symbolName = SymbolName(j, false);
if(StringFind(symbolName, canonicalSymbol) == 0)
{
if(SymbolSelect(symbolName, true))
return(symbolName);
}
}
message = "No broker symbol found for canonical symbol: " + canonicalSymbol;
return("");
}
//+------------------------------------------------------------------+
//| Convert MT5 order type to readable text |
//+------------------------------------------------------------------+
string OrderTypeToString(const ENUM_ORDER_TYPE orderType)
{
//--- convert internal MetaTrader 5 order type constants to log text
if(orderType == ORDER_TYPE_BUY)
return("BUY_MARKET");
if(orderType == ORDER_TYPE_SELL)
return("SELL_MARKET");
if(orderType == ORDER_TYPE_BUY_LIMIT)
return("BUY_LIMIT");
if(orderType == ORDER_TYPE_SELL_LIMIT)
return("SELL_LIMIT");
if(orderType == ORDER_TYPE_BUY_STOP)
return("BUY_STOP");
if(orderType == ORDER_TYPE_SELL_STOP)
return("SELL_STOP");
return("UNKNOWN");
}
//+------------------------------------------------------------------+
//| Build API URL |
//+------------------------------------------------------------------+
string BuildUrl(const string endpoint)
{
string baseUrl = InpApiBaseUrl;
//--- avoid double slashes when joining base URL and endpoint
if(StringLen(baseUrl) > 0 && StringSubstr(baseUrl, StringLen(baseUrl) - 1, 1) == "/")
baseUrl = StringSubstr(baseUrl, 0, StringLen(baseUrl) - 1);
return(baseUrl + endpoint);
}
//+------------------------------------------------------------------+
//| Send HTTP GET request |
//+------------------------------------------------------------------+
bool HttpGet(const string url, string &response)
{
char data[];
char result[];
string headers = "";
string resultHeaders = "";
int timeout = 5000;
//--- GET requests do not need a request body
ArrayResize(data, 0);
ResetLastError();
//--- send HTTP GET request to the Flask backend
int statusCode = WebRequest("GET", url, headers, timeout, data, result, resultHeaders);
if(statusCode == -1)
{
Print("WebRequest GET failed. Error: ", GetLastError());
Print("Allow this URL in MT5: ", InpApiBaseUrl);
return(false);
}
//--- convert byte response into readable UTF-8 string
response = CharArrayToString(result, 0, -1, CP_UTF8);
//--- non-2xx responses are treated as failed API requests
if(statusCode < 200 || statusCode >= 300)
{
Print("HTTP GET failed. Status: ", statusCode);
Print("Response: ", response);
return(false);
}
return(true);
}
//+------------------------------------------------------------------+
//| Send execution status to Flask |
//+------------------------------------------------------------------+
bool SendExecutionStatus(const long signalId, const string status, const ulong ticket, const string message)
{
//--- build endpoint URL for the specific signal being updated
string url = BuildUrl("/api/v1/signals/" + IntegerToString(signalId) + "/status");
//--- build compact JSON body expected by the Flask endpoint
string body = "{";
body += "\"status\":\"" + status + "\",";
body += "\"ticket\":" + IntegerToString((long)ticket) + ",";
body += "\"message\":\"" + JsonEscape(message) + "\"";
body += "}";
char data[];
char result[];
string headers = "Content-Type: application/json\r\n";
string resultHeaders = "";
int timeout = 5000;
//--- convert JSON string into UTF-8 char array for WebRequest
int copied = StringToCharArray(body, data, 0, WHOLE_ARRAY, CP_UTF8);
//--- remove the terminal null character added by StringToCharArray
if(copied > 0)
ArrayResize(data, copied - 1);
ResetLastError();
//--- send execution result to the backend
int statusCode = WebRequest("POST", url, headers, timeout, data, result, resultHeaders);
string response = CharArrayToString(result, 0, -1, CP_UTF8);
if(statusCode == -1)
{
Print("WebRequest POST failed. Error: ", GetLastError());
return(false);
}
//--- report failed status update attempts for debugging
if(statusCode < 200 || statusCode >= 300)
{
Print("Status update failed. HTTP: ", statusCode);
Print("Response: ", response);
return(false);
}
Print("[STATUS] Execution status sent to Flask: ", status);
return(true);
}
//+------------------------------------------------------------------+
//| Parse pending signal response |
//+------------------------------------------------------------------+
bool ParsePendingSignal(const string json, SSignal &signal)
{
//--- reset all fields before parsing the new response
signal.id = 0;
signal.symbol = "";
signal.direction = "";
signal.intent = "";
signal.orderType = "";
signal.entryPrice = 0.0;
signal.stopLoss = 0.0;
signal.takeProfit = 0.0;
signal.confidence = 0.0;
//--- signal ID is required because it is used for status updates
if(!JsonGetLong(json, "id", signal.id))
return(false);
//--- extract text fields returned by the Flask API
JsonGetString(json, "symbol", signal.symbol);
JsonGetString(json, "direction", signal.direction);
JsonGetString(json, "intent", signal.intent);
JsonGetString(json, "order_type", signal.orderType);
//--- extract numeric fields returned by the Flask API
JsonGetDouble(json, "entry_price", signal.entryPrice);
JsonGetDouble(json, "stop_loss", signal.stopLoss);
JsonGetDouble(json, "take_profit", signal.takeProfit);
JsonGetDouble(json, "confidence", signal.confidence);
return(true);
}
//+------------------------------------------------------------------+
//| Extract long value from simple JSON |
//+------------------------------------------------------------------+
bool JsonGetLong(const string json, const string key, long &value)
{
string raw = "";
//--- locate raw JSON value by key
if(!JsonGetRawValue(json, key, raw))
return(false);
//--- integer fields cannot be empty or null
if(raw == "" || raw == "null")
return(false);
value = StringToInteger(raw);
return(true);
}
//+------------------------------------------------------------------+
//| Extract double value from simple JSON |
//+------------------------------------------------------------------+
bool JsonGetDouble(const string json, const string key, double &value)
{
string raw = "";
//--- locate raw JSON value by key
if(!JsonGetRawValue(json, key, raw))
return(false);
//--- optional numeric fields are treated as zero when null
if(raw == "" || raw == "null")
{
value = 0.0;
return(true);
}
value = StringToDouble(raw);
return(true);
}
//+------------------------------------------------------------------+
//| Extract string value from simple JSON |
//+------------------------------------------------------------------+
bool JsonGetString(const string json, const string key, string &value)
{
string pattern = "\"" + key + "\":";
int pos = StringFind(json, pattern);
//--- key is not present in the JSON response
if(pos < 0)
return(false);
//--- move cursor to the start of the value
pos += StringLen(pattern);
while(pos < StringLen(json) && IsWhitespace(StringGetCharacter(json, pos)))
pos++;
if(pos >= StringLen(json))
return(false);
//--- null string fields are converted to empty strings
if(StringSubstr(json, pos, 4) == "null")
{
value = "";
return(true);
}
//--- string values must begin with a double quote
if(StringGetCharacter(json, pos) != '"')
return(false);
pos++;
int end = StringFind(json, "\"", pos);
if(end < 0)
return(false);
value = StringSubstr(json, pos, end - pos);
return(true);
}
//+------------------------------------------------------------------+
//| Extract raw JSON value |
//+------------------------------------------------------------------+
bool JsonGetRawValue(const string json, const string key, string &value)
{
string pattern = "\"" + key + "\":";
int pos = StringFind(json, pattern);
//--- key must exist before a value can be extracted
if(pos < 0)
return(false);
//--- move cursor from key name to value start
pos += StringLen(pattern);
while(pos < StringLen(json) && IsWhitespace(StringGetCharacter(json, pos)))
pos++;
int end = pos;
//--- raw value ends at comma, object close, or line break
while(end < StringLen(json))
{
ushort ch = StringGetCharacter(json, end);
if(ch == ',' || ch == '}' || ch == '\r' || ch == '\n')
break;
end++;
}
value = StringSubstr(json, pos, end - pos);
StringTrimLeft(value);
StringTrimRight(value);
return(true);
}
//+------------------------------------------------------------------+
//| Check whitespace character |
//+------------------------------------------------------------------+
bool IsWhitespace(const ushort ch)
{
//--- helper used by the lightweight JSON extraction functions
return(ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n');
}
//+------------------------------------------------------------------+
//| Normalize signal price |
//+------------------------------------------------------------------+
double NormalizeSignalPrice(const string symbol, const double price)
{
//--- zero means the signal did not provide this price level
if(price <= 0.0)
return(0.0);
//--- use broker symbol digits before sending prices to the server
int digits = (int)SymbolInfoInteger(symbol, SYMBOL_DIGITS);
return(NormalizeDouble(price, digits));
}
//+------------------------------------------------------------------+
//| Escape JSON string |
//+------------------------------------------------------------------+
string JsonEscape(string value)
{
//--- escape characters that can break the JSON status payload
StringReplace(value, "\\", "\\\\");
StringReplace(value, "\"", "\\\"");
StringReplace(value, "\r", "\\r");
StringReplace(value, "\n", "\\n");
return(value);
}
//+------------------------------------------------------------------+
//| Send successful execution notification |
//+------------------------------------------------------------------+
void NotifyExecutionSuccess(const SSignal &signal, const string brokerSymbol, const ENUM_ORDER_TYPE orderType, const ulong ticket)
{
string slText = "Not provided";
string tpText = "Not provided";
int digits = (int)SymbolInfoInteger(brokerSymbol, SYMBOL_DIGITS);
//--- show missing SL or TP clearly in the notification
if(signal.stopLoss > 0.0)
slText = DoubleToString(signal.stopLoss, digits);
if(signal.takeProfit > 0.0)
tpText = DoubleToString(signal.takeProfit, digits);
//--- prepare a concise execution message for logs, alerts, and mobile push
string notification = "";
notification += "Signal executed: " + OrderTypeToString(orderType) + " " + brokerSymbol + "\n";
notification += "Lot: " + DoubleToString(InpLotSize, 2) + "\n";
notification += "SL: " + slText + "\n";
notification += "TP: " + tpText + "\n";
notification += "Ticket: " + IntegerToString((long)ticket);
Print(notification);
Alert(notification);
SendNotification(notification);
}
//+------------------------------------------------------------------+
//| Print parsed signal |
//+------------------------------------------------------------------+
void PrintSignal(const SSignal &signal)
{
//--- print parsed signal fields for article-friendly visual feedback
Print("[SIGNAL] Pending signal received.");
Print("[SIGNAL] ID: ", signal.id);
Print("[SIGNAL] Symbol: ", signal.symbol);
Print("[SIGNAL] Direction: ", signal.direction);
Print("[SIGNAL] Intent: ", signal.intent);
Print("[SIGNAL] Order Type: ", signal.orderType);
Print("[SIGNAL] Entry: ", DoubleToString(signal.entryPrice, 5));
Print("[SIGNAL] SL: ", DoubleToString(signal.stopLoss, 5));
Print("[SIGNAL] TP: ", DoubleToString(signal.takeProfit, 5));
Print("[SIGNAL] Confidence: ", DoubleToString(signal.confidence, 2));
}
//+------------------------------------------------------------------+