//+------------------------------------------------------------------+ //| Tradeus.mq5 — the bridge between one MetaTrader 5 terminal and | //| Tradeus. | //| | //| WHAT IT DOES | //| Four times a second it tells Tradeus what this account holds and | //| asks what to do. That is the whole program. It opens no ports, | //| listens for nothing, and stores nothing on disk. | //| | //| WHAT IT CANNOT DO, AND WHY YOU SHOULD KNOW | //| It only runs while this terminal runs. Close MetaTrader, let the | //| computer sleep, lose the internet — and copying stops, silently, | //| because a program that has stopped asking cannot report that it | //| stopped. If that is not acceptable for your account, the cloud | //| connection exists for exactly this reason. | //| | //| HOW FAST IT IS, HONESTLY | //| WebRequest is synchronous: it freezes this program until the | //| server answers. So the poll cannot be driven much below a fifth | //| of a second without the EA spending its life waiting instead of | //| watching the market. Expect a copied trade to arrive 300 ms to | //| 1.5 s after the signal — half the poll interval on average, plus | //| the round trip, plus your broker. A hosted connection is faster | //| because the terminal sits in a datacentre, not because it uses | //| a better road: every cloud MT5 API on the market runs a Windows | //| terminal and a bridge exactly like this one. | //| | //| SETUP (three steps, once) | //| 1. Copy this file into MQL5\Experts and compile it (F7). | //| 2. Tools -> Options -> Expert Advisors -> tick "Allow WebRequest | //| for listed URL" and add the address Tradeus showed you. | //| 3. Drag it onto any chart. Paste the token into Token, the | //| address into ServerUrl, and allow algo trading. | //+------------------------------------------------------------------+ #property copyright "Tradeus" #property version "1.00" #property strict #include #include input string ServerUrl = ""; // Address Tradeus showed you, e.g. https://api.tradeus.app/ea/bc_xxx input string Token = ""; // The token Tradeus showed you, once input int MagicBase = 777100; CTrade trade; CPositionInfo pos; // Answers waiting to go up with the next poll. They are sent BEFORE the next // batch is taken, so an order that finished can never be reported after the // command that replaced it. string g_results = ""; int g_pollMs = 250; bool g_saidHello = false; // HOW LONG AN UNSENT ANSWER IS WORTH KEEPING. // // Tradeus waits COMMAND_TIMEOUT_MS for a command to be answered — ten seconds // (state.ts), which is forty of these polls. Answers therefore ride across // failed polls for that long and are dropped afterwards, because by then // nobody is listening for them any more. The first version of this file threw // them away BEFORE the request instead, with a comment claiming Tradeus had // already stopped waiting. It had not, and one dropped poll turned a filled // order into an OrderUnconfirmedError. const int RESULT_MAX_POLLS = 40; int g_resultPolls = 0; // A refused poll slows down. The reason is specific rather than hygienic and // is written out above Refused(). int g_backoffMs = 0; int g_lastComplaint = 0; //+------------------------------------------------------------------+ //| JSON is built here and never parsed here. | //| | //| Building it is four lines of concatenation. Parsing it would be a | //| parser somebody has to write, test and then trust inside a | //| trading terminal — so Tradeus answers in plain lines instead, and | //| this file never sees a brace it did not write itself. | //+------------------------------------------------------------------+ string JsonEscape(const string s) { string o = s; StringReplace(o, "\\", "\\\\"); StringReplace(o, "\"", "\\\""); return o; } string Num(const double v, const int digits = 8) { return DoubleToString(v, digits); } //+------------------------------------------------------------------+ //| Everything this account currently holds, per symbol — BOTH SIDES. | //| | //| WHAT WAS WRONG BEFORE, AND WHY IT HAD TO CHANGE HERE RATHER THAN | //| IN TRADEUS. This function used to emit `netQty` alone, with the | //| reasoning that MT5 is the only side that knows whether the account | //| hedges, so netting belonged here. The netting was right; throwing | //| the legs away was not. On a RETAIL_HEDGING account — MT5's default | //| for retail — one long lot and one short lot of EURUSD net to ZERO, | //| and a net of zero is indistinguishable from a closed position. So | //| Tradeus read a held account as flat: the kill switch reported | //| "already flat" and sent no close while both legs were open. | //| | //| No guard on the Tradeus side could recover that, because the | //| information was already destroyed by the time it left this | //| terminal. So the legs are now reported as well as the net: | //| `longQty` and `shortQty` per symbol, and `positionsTotal` beside | //| the list — a genuine count of held legs, which this venue has and | //| most do not. `netQty` stays exactly as it was, so an older Tradeus | //| reading this EA sees no change. | //+------------------------------------------------------------------+ string PositionsJson(int &positions_total) { string syms[]; double qty[]; double lng[]; double shr[]; double px[]; double pl[]; int lgs[]; // THE STOP AND THE TARGET RESTING ON THIS SYMBOL. MetaTrader keeps them per // TICKET, and a symbol can be held in several — so these are reduced to the // one that would be hit FIRST: the highest stop under a net long, the lowest // over a net short, and the mirror of that for the target. Reporting any other // one would tell a follower it is covered at a level the market reaches later // than the level that will actually close the leader. // // 0 IS MetaTrader's "none", not a price. It is kept as 0 here and dropped from // the JSON below, because a stop AT zero and NO stop are the same thing on // every instrument this will ever see, and sending 0 would read as a price. double sl[]; double tp[]; int n = 0; // THE SECOND WITNESS, and it only is one because it is read HERE — once, // before a single leg is touched. // // WHAT WAS WRONG BEFORE: this count was incremented inside the loop below, // after `SelectByIndex` had already succeeded, and the comment there argued // that counting "only legs actually read" was the careful choice. It is the // opposite of careful. A leg the `continue` drops is then missing from the // list AND missing from the count, so the two fall together and Tradeus's // cross-check — `positionsTotal` against the summed per-symbol `legs` — holds // by construction for every report this EA could ever send. The one failure // the count exists to catch, a short list that looks complete, was the one // failure it could not catch. // // `PositionsTotal()` is the terminal's own answer and a dropped leg cannot // shrink it, so a list that arrived short now disagrees with it and Tradeus // refuses instead of reading the missing symbol as a closed position. // // THE COST, named: if a position closes while this loop is walking, the count // is one higher than the legs that could be selected and Tradeus refuses that // report. That is correct — the snapshot genuinely straddles a change — and it // is transient: the next poll reads a settled book. A refusal costs one // detection pass; believing a short list costs a follower his position. positions_total = PositionsTotal(); for(int i = positions_total - 1; i >= 0; i--) { if(!pos.SelectByIndex(i)) continue; string sym = pos.Symbol(); bool is_long = (pos.PositionType() == POSITION_TYPE_BUY); double signed_qty = is_long ? pos.Volume() : -pos.Volume(); int at = -1; for(int k = 0; k < n; k++) if(syms[k] == sym) { at = k; break; } if(at < 0) { n++; ArrayResize(syms, n); ArrayResize(qty, n); ArrayResize(lng, n); ArrayResize(shr, n); ArrayResize(px, n); ArrayResize(pl, n); ArrayResize(lgs, n); ArrayResize(sl, n); ArrayResize(tp, n); at = n - 1; syms[at] = sym; qty[at] = 0; lng[at] = 0; shr[at] = 0; px[at] = 0; pl[at] = 0; lgs[at] = 0; sl[at] = 0; tp[at] = 0; } // Volume-weighted entry price. A mean over legs on OPPOSITE sides is not // an entry price of anything, so it is weighted by absolute size and // Tradeus is told to treat it as indicative only. double prev = MathAbs(qty[at]); double add = MathAbs(signed_qty); if(prev + add > 0) px[at] = (px[at] * prev + pos.PriceOpen() * add) / (prev + add); qty[at] += signed_qty; if(is_long) lng[at] += pos.Volume(); else shr[at] += pos.Volume(); lgs[at] += 1; pl[at] += pos.Profit() + pos.Swap(); // KEEP THE ONE THAT FIRES FIRST. Which that is depends on the side of the // leg, not on the symbol's net — a hedged symbol has both, and Tradeus // refuses a hedge outright one layer up, so there is nothing to be careful // about here beyond picking per leg and letting that refusal do its job. double leg_sl = pos.StopLoss(); double leg_tp = pos.TakeProfit(); if(leg_sl > 0) { if(sl[at] == 0) sl[at] = leg_sl; else sl[at] = is_long ? MathMax(sl[at], leg_sl) : MathMin(sl[at], leg_sl); } if(leg_tp > 0) { if(tp[at] == 0) tp[at] = leg_tp; else tp[at] = is_long ? MathMin(tp[at], leg_tp) : MathMax(tp[at], leg_tp); } } string out = "["; for(int k = 0; k < n; k++) { if(k > 0) out += ","; out += "{\"symbol\":\"" + JsonEscape(syms[k]) + "\""; out += ",\"netQty\":" + Num(qty[k]); // BOTH SIDES, ALWAYS — including a zero. "shortQty":0 is the statement // that this symbol has no short leg; leaving the field out would be the // statement that this EA cannot tell, and Tradeus has to distinguish the // two or it is back to guessing about a hedge. out += ",\"longQty\":" + Num(lng[k]); out += ",\"shortQty\":" + Num(shr[k]); // HOW MANY TICKETS this symbol is held in, which is what turns the count // beside the list into a cross-check that can actually disagree with it. // Volumes could never do that job: three tickets of 0.1 lots and one of // 0.3 report the same volume. Summed over the symbols this must equal // `positionsTotal` exactly, so a symbol lost on the way up takes its // tickets with it and the two stop matching. out += ",\"legs\":" + IntegerToString(lgs[k]); out += ",\"avgPrice\":" + Num(px[k]); out += ",\"unrealizedPl\":" + Num(pl[k]); // OMITTED WHEN THERE IS NONE, never sent as 0. Absent means "this symbol // has no stop resting on it"; a 0 would arrive as a price of zero, and the // copier's three-state contract reads a number as a level somebody chose. if(sl[k] > 0) out += ",\"stopPrice\":" + Num(sl[k]); if(tp[k] > 0) out += ",\"targetPrice\":" + Num(tp[k]); out += "}"; } return out + "]"; } //+------------------------------------------------------------------+ //| MARGIN MODE IS READ, NEVER ASSUMED. | //| | //| MT5 decides netting-versus-hedging per account and the broker sets | //| it. On a netting account an opposing order REDUCES the position | //| instead of opening a second one — so a copier that guesses wrong | //| reverses an account it meant to scale into. | //+------------------------------------------------------------------+ string MarginModeName() { long m = AccountInfoInteger(ACCOUNT_MARGIN_MODE); if(m == ACCOUNT_MARGIN_MODE_RETAIL_HEDGING) return "RETAIL_HEDGING"; if(m == ACCOUNT_MARGIN_MODE_RETAIL_NETTING) return "RETAIL_NETTING"; if(m == ACCOUNT_MARGIN_MODE_EXCHANGE) return "EXCHANGE"; return "UNKNOWN"; } //+------------------------------------------------------------------+ //| WHAT THIS SERVER WILL ACCEPT — asked, never assumed. | //| | //| MT5 lot rules belong to the BROKER, not to the instrument. The | //| same EURUSD is 0.01-step on one server and 0.1-step on another, | //| the minimum differs, the maximum differs, and the broker spells it | //| EURUSD, EURUSD.raw or EURUSD.m as it pleases. Tradeus had none of | //| it: it sent the size the alert asked for and the server answered | //| with a retcode. A product whose customers bring their own brokers | //| cannot hold a table of brokers — so the terminal states its own | //| rules and Tradeus sizes against them, or refuses and says why. | //| | //| MARKET WATCH, NOT THE WHOLE CATALOGUE (SymbolsTotal(true)). It is | //| the trader's own selection, it is the set his alerts name, and MT5 | //| adds every symbol he holds to it. A broker's full list runs to | //| thousands and would be sent on every poll for nothing. | //| | //| CAPPED, AND THE CAP IS DECLARED. A list that was cut short and one | //| that is complete look identical on the far side, and "not in the | //| list" then means either "this account cannot trade it" or "we did | //| not send enough" — different instructions for the trader. So | //| `symbolsTruncated` goes out beside it rather than being inferred. | //+------------------------------------------------------------------+ #define TRADEUS_MAX_SYMBOLS 300 string SymbolsJson(bool &truncated) { int total = SymbolsTotal(true); truncated = (total > TRADEUS_MAX_SYMBOLS); int take = truncated ? TRADEUS_MAX_SYMBOLS : total; string out = "["; // `written`, not `i`: a skipped name would otherwise put a leading comma in // the array and the whole report would fail to parse on the far side. int written = 0; for(int i = 0; i < take; i++) { string sym = SymbolName(i, true); if(StringLen(sym) == 0) continue; if(written > 0) out += ","; written++; out += "{\"name\":\"" + JsonEscape(sym) + "\""; out += ",\"digits\":" + IntegerToString((int)SymbolInfoInteger(sym, SYMBOL_DIGITS)); // THE GRID, THE FLOOR AND THE CEILING — the three numbers an order is // checked against. Sent with enough decimals to survive a 0.01 step. out += ",\"volumeMin\":" + Num(SymbolInfoDouble(sym, SYMBOL_VOLUME_MIN)); out += ",\"volumeStep\":" + Num(SymbolInfoDouble(sym, SYMBOL_VOLUME_STEP)); out += ",\"volumeMax\":" + Num(SymbolInfoDouble(sym, SYMBOL_VOLUME_MAX)); // Units per lot. Tradeus derives the value of a full price move from THIS // rather than from the tick pair below, because that division is where a // float turns 100000 into 99999.99999999999. The tick pair is sent anyway // and used to cross-examine it: two of this terminal's own answers about // one instrument have to agree, or no value is derived from either. out += ",\"contractSize\":" + Num(SymbolInfoDouble(sym, SYMBOL_TRADE_CONTRACT_SIZE)); out += ",\"tickSize\":" + Num(SymbolInfoDouble(sym, SYMBOL_TRADE_TICK_SIZE)); out += ",\"tickValue\":" + Num(SymbolInfoDouble(sym, SYMBOL_TRADE_TICK_VALUE)); // The currency profit is paid in. A value in another currency read as the // account's is a fabricated figure, so Tradeus withholds the point value // unless this matches the account. out += ",\"profitCurrency\":\"" + JsonEscape(SymbolInfoString(sym, SYMBOL_CURRENCY_PROFIT)) + "\""; // FULL means the broker takes orders in it right now. Close-only and // disabled are real states, and an order sent into one is a retcode. out += ",\"tradable\":" + ((SymbolInfoInteger(sym, SYMBOL_TRADE_MODE) == SYMBOL_TRADE_MODE_FULL) ? "true" : "false"); out += "}"; } return out + "]"; } string StateJson() { string j = "{"; j += "\"login\":\"" + IntegerToString(AccountInfoInteger(ACCOUNT_LOGIN)) + "\""; j += ",\"currency\":\"" + JsonEscape(AccountInfoString(ACCOUNT_CURRENCY)) + "\""; j += ",\"balance\":" + Num(AccountInfoDouble(ACCOUNT_BALANCE), 2); j += ",\"equity\":" + Num(AccountInfoDouble(ACCOUNT_EQUITY), 2); j += ",\"marginMode\":\"" + MarginModeName() + "\""; j += ",\"build\":" + IntegerToString(TerminalInfoInteger(TERMINAL_BUILD)); // WHICH SHAPE THIS EA SPEAKS. Not decoration and not for support: Tradeus's // strict position read has to be able to tell a terminal that reports each // symbol's long and short volume from one that reports only the net, because // on the second kind a hedging account's held position arrives as a zero and // reads as closed. Absent means protocol 1 — every EA in the field before // this build — and the strict read refuses such a terminal BY NAME rather // than believing it. The customer has to install this file again. // BUMPED TO 3 BY THE SYMBOL LIST BELOW, and the number is a promise about a // SHAPE rather than a build date. 3 adds `symbols` and changes nothing about // the position fields, which is why Tradeus still reads a 2 perfectly for // positions and refuses only to SIZE against one — a customer who has not // reinstalled yet keeps his positions, his balance and his exits, and is told // to download this file again the first time an order needs a lot rule. // 4 since 01.10.2026: positions now carry the stop and target resting on them // (stopPrice/targetPrice, omitted where there is none). A protocol-3 terminal // reports neither, and Tradeus must say it cannot see the stop rather than // report that there is none — which is why this number exists at all. // 5 adds the two commands that take ABSOLUTE levels: `setStops`, and an `order` // that carries a price instead of a distance. Declared rather than inferred, // because Tradeus refuses to send a command a terminal has not claimed to // understand — an old EA would answer "does not understand 'setStops'", which is // honest but arrives after the trader believed his stop had travelled. j += ",\"protocol\":5"; int total = 0; string positions = PositionsJson(total); // The count goes out BESIDE the list and is read INDEPENDENTLY of it — the // terminal's own PositionsTotal(), taken before the legs are walked. That // independence is the whole value of the field: a count tallied from the same // walk falls whenever the list falls and can never contradict it. Tradeus // refuses when the two disagree, because a missing leg is exactly what a // closed position looks like. j += ",\"positionsTotal\":" + IntegerToString(total); j += ",\"positions\":" + positions; // WHAT THIS SERVER WILL ACCEPT. See SymbolsJson: Market Watch, capped, and // the cap declared rather than inferred from a length. bool symbols_truncated = false; string symbols = SymbolsJson(symbols_truncated); j += ",\"symbols\":" + symbols; if(symbols_truncated) j += ",\"symbolsTruncated\":true"; if(StringLen(g_results) > 0) j += ",\"results\":[" + g_results + "]"; j += "}"; return j; } //+------------------------------------------------------------------+ //| One HTTPS round trip. Returns the body, or "" on any failure. | //| | //| WebRequest BLOCKS this thread for up to `timeout`. That is why the | //| timeout is short: a terminal stuck waiting on a slow network is a | //| terminal that is not watching the market. | //+------------------------------------------------------------------+ string Post(const string path, const string body, int &status) { char data[]; char result[]; string result_headers; int len = StringToCharArray(body, data, 0, WHOLE_ARRAY, CP_UTF8) - 1; if(len < 0) len = 0; ArrayResize(data, len); string headers = "Content-Type: application/json\r\n" "X-Tradeus-Agent-Token: " + Token + "\r\n"; ResetLastError(); status = WebRequest("POST", ServerUrl + path, headers, 4000, data, result, result_headers); if(status == -1) { int err = GetLastError(); // 4014 is MQL5's "this function is not allowed here". On an EA it // means the URL is not on the allow-list; from an indicator or the // Strategy Tester it means WebRequest cannot run at all. Both are worth // naming, because the second one is why this EA cannot be backtested. // Plain ASCII in this message on purpose: MQL5 has no \u escape, and the // compiler warns rather than errors on one - so an em dash here would ship // as a silent warning nobody reads. Caught by MetaEditor, warning 86. if(err == 4014) Print("Tradeus: WebRequest was refused. If this is a chart, add the address under " "Tools -> Options -> Expert Advisors -> 'Allow WebRequest for listed URL': ", ServerUrl, " (WebRequest never works in the Strategy Tester - this EA cannot be backtested.)"); else Print("Tradeus: could not reach the server (error ", err, ")"); return ""; } return CharArrayToString(result, 0, WHOLE_ARRAY, CP_UTF8); } //+------------------------------------------------------------------+ //| WHY A REFUSED POLL MUST SLOW DOWN, and it is not politeness. | //| | //| Tradeus counts a poll against an UNKNOWN connection id as an abuse | //| failure — ten in ten minutes (the "ea-unknown" budget). At four | //| polls a second an Expert Advisor pointed at a connection that was | //| deleted burns that in two and a half seconds. Worse, every further | //| poll records another failure, so the ten-minute window never | //| empties again: this machine's address stays locked for as long as | //| the terminal runs, and the customer sees a connection that will | //| not come back no matter what he does. | //| | //| Doubling from a second to a minute. A wrong address then costs one | //| request a minute instead of two hundred and forty. | //+------------------------------------------------------------------+ void Complain(const int status) { if(status == 409) Print("Tradeus: this account is already connected to Tradeus somewhere else. " "Disconnect it there first - retrying cannot fix this one."); else if(status == 401) Print("Tradeus: the token was refused. Check what you pasted into Token."); else if(status == 429) Print("Tradeus: Tradeus is refusing requests from this address for now. Check that " "ServerUrl is the address Tradeus showed you, then leave it alone for ten minutes."); else if(status == -1) Print("Tradeus: could not reach ", ServerUrl, " at all. Check the address, the " "WebRequest allow-list, and this machine's connection."); else Print("Tradeus: the server answered HTTP ", status, "."); } void Refused(const int status) { g_backoffMs = (g_backoffMs == 0) ? 1000 : g_backoffMs * 2; if(g_backoffMs > 60000) g_backoffMs = 60000; EventKillTimer(); EventSetMillisecondTimer(g_backoffMs); // Once per change of reason, never once per attempt: a line printed four // times a second is a line nobody reads. if(status != g_lastComplaint) { g_lastComplaint = status; Complain(status); } } void Recovered() { if(g_backoffMs == 0) return; g_backoffMs = 0; g_lastComplaint = 0; EventKillTimer(); EventSetMillisecondTimer(g_pollMs); Print("Tradeus: the server is answering again."); } //+------------------------------------------------------------------+ //| CTrade RETURNS TRUE FOR A REQUEST THAT WAS MERELY ACCEPTED. | //| | //| Read out of this terminal's own Trade.mqh rather than out of the | //| documentation: CTrade::OrderSend ends in `return(res)`, where res | //| is ::OrderSend's own boolean, and m_result.retcode is never looked | //| at. A requote, a rejection, and an order merely PLACED in the | //| trading system all come back true. The server's verdict is the | //| retcode, so that is what is read here — otherwise Tradeus is told | //| a fill happened that did not, and sizes its next decision on it. | //+------------------------------------------------------------------+ bool TradeFilled() { uint rc = trade.ResultRetcode(); if(rc == TRADE_RETCODE_DONE_PARTIAL) return true; // real volume moved if(rc != TRADE_RETCODE_DONE) return false; // PLACED is not a fill // DONE that moved nothing is not a fill either. return (trade.ResultDeal() != 0 || trade.ResultVolume() > 0); } /** The number as well as the words. "Order placed" as a FAILURE message reads * like a success in the Tradeus log; "10008 Order placed" does not. */ string TradeError() { return IntegerToString(trade.ResultRetcode()) + " " + trade.ResultRetcodeDescription(); } //+------------------------------------------------------------------+ void AddResult(const string id, const bool ok, const string ticket, const double price, const double qty, const string err, const string closedJson = "") { if(StringLen(g_results) > 0) g_results += ","; g_results += "{\"id\":\"" + JsonEscape(id) + "\",\"ok\":" + (ok ? "true" : "false"); if(StringLen(ticket) > 0) g_results += ",\"ticket\":\"" + JsonEscape(ticket) + "\""; if(price > 0) g_results += ",\"price\":" + Num(price); if(qty > 0) g_results += ",\"qty\":" + Num(qty); if(StringLen(err) > 0) g_results += ",\"error\":\"" + JsonEscape(err) + "\""; if(StringLen(closedJson) > 0) g_results += ",\"closed\":[" + closedJson + "]"; g_results += "}"; } /** How much of `symbol` this terminal still shows open. An empty symbol means * everything. Used to let THE BOOK decide whether a close worked, rather than * a return code that only says the request was accepted. */ double StillOpen(const string symbol) { double v = 0; for(int i = PositionsTotal() - 1; i >= 0; i--) { if(!pos.SelectByIndex(i)) continue; if(StringLen(symbol) > 0 && pos.Symbol() != symbol) continue; v += pos.Volume(); } return v; } //+------------------------------------------------------------------+ //| WHAT WAS CLOSED, so the ledger can book it. | //| | //| `side` is the CLOSING order's side — sell closes a long — which is | //| what types.ts states in so many words and what every other adapter | //| sends. Reversed, every realized amount in the ledger points the | //| wrong way, and it still looks like a number. | //| | //| The amount comes from the DEAL the close produced rather than from | //| the position's floating P&L, because only the deal carries | //| commission. Where the deal cannot be read the floating amount goes | //| instead and `price` is left out — which the server already reads as | //| "split this by size, not by price" rather than as a made-up fill. | //+------------------------------------------------------------------+ string ClosedLeg(const string symbol, const bool wasLong, const double volume, const double floatingPl) { double pnl = floatingPl; double px = 0; double vol = volume; ulong deal = trade.ResultDeal(); if(deal != 0 && HistorySelect(TimeCurrent() - 300, TimeCurrent() + 60) && HistoryDealSelect(deal)) { pnl = HistoryDealGetDouble(deal, DEAL_PROFIT) + HistoryDealGetDouble(deal, DEAL_SWAP) + HistoryDealGetDouble(deal, DEAL_COMMISSION); px = HistoryDealGetDouble(deal, DEAL_PRICE); vol = HistoryDealGetDouble(deal, DEAL_VOLUME); } string j = "{\"symbol\":\"" + JsonEscape(symbol) + "\""; j += ",\"side\":\"" + (wasLong ? "sell" : "buy") + "\""; j += ",\"qty\":" + Num(vol); j += ",\"realizedPnl\":" + Num(pnl, 2); if(px > 0) j += ",\"price\":" + Num(px); return j + "}"; } //+------------------------------------------------------------------+ //| SL and TP arrive as a PRICE DISTANCE, never as a level and never | //| in "points". | //| | //| An MT5 "point" is the last digit of the quote — a tenth of a pip on | //| a five-digit pair. Tradeus speaks in price. Confusing the two is | //| wrong by a factor of ten thousand and still looks like a number, so | //| the conversion happens here, once, from the price this order | //| actually filled at. | //+------------------------------------------------------------------+ void DoOrder(const string id, const string symbol, const string side, const double qty, const double slDist, const double tpDist, const double absSl, const double absTp) { if(!SymbolSelect(symbol, true)) { AddResult(id, false, "", 0, 0, "this terminal does not offer " + symbol); return; } bool isBuy = (side == "buy"); double price = isBuy ? SymbolInfoDouble(symbol, SYMBOL_ASK) : SymbolInfoDouble(symbol, SYMBOL_BID); int digits = (int)SymbolInfoInteger(symbol, SYMBOL_DIGITS); // THE LEVEL WINS WHERE TRADEUS KNEW ONE (protocol 5). // // A distance is computed off the price THIS terminal filled at, which is exact // for this account and therefore DIFFERENT on every account. Where the order came // from a copy group, the level is the leader's own, and both accounts then leave // the trade in the same move instead of a few points apart. // // The distance stays for the case where nobody ever named a price. double sl = 0, tp = 0; if(absSl > 0) sl = NormalizeDouble(absSl, digits); else if(slDist > 0) sl = NormalizeDouble(isBuy ? price - slDist : price + slDist, digits); if(absTp > 0) tp = NormalizeDouble(absTp, digits); else if(tpDist > 0) tp = NormalizeDouble(isBuy ? price + tpDist : price - tpDist, digits); trade.SetExpertMagicNumber(MagicBase); bool ok = isBuy ? trade.Buy(qty, symbol, 0, sl, tp) : trade.Sell(qty, symbol, 0, sl, tp); if(!ok || !TradeFilled()) { AddResult(id, false, "", 0, 0, TradeError()); return; } AddResult(id, true, IntegerToString(trade.ResultOrder()), trade.ResultPrice(), trade.ResultVolume(), ""); } void DoCloseSymbol(const string id, const string symbol) { string closed = ""; string err = ""; for(int i = PositionsTotal() - 1; i >= 0; i--) { if(!pos.SelectByIndex(i)) continue; if(pos.Symbol() != symbol) continue; bool wasLong = (pos.PositionType() == POSITION_TYPE_BUY); double vol = pos.Volume(); double floatPl = pos.Profit() + pos.Swap(); if(!trade.PositionClose(pos.Ticket()) || !TradeFilled()) { err = TradeError(); continue; } if(StringLen(closed) > 0) closed += ","; closed += ClosedLeg(symbol, wasLong, vol, floatPl); } // THE BOOK DECIDES, not the return codes. A close the server answered DONE // that left volume behind is still exposure, and calling that flat is the // one lie here that costs money. if(StringLen(err) == 0 && StillOpen(symbol) > 0) err = "the terminal still shows an open position in " + symbol + " after closing it"; // Nothing to close is not a failure. The account is in the state that was // asked for, which is the only thing the caller wanted to be true. AddResult(id, StringLen(err) == 0, "", 0, 0, err, closed); } //+------------------------------------------------------------------+ //| Move the stop and/or target on every leg of one symbol. | //| | //| THE ONE THING A DISTANCE CANNOT EXPRESS. There is no fresh fill to | //| measure from behind a position that is already open, so this takes | //| PRICES. MetaTrader's own PositionModify takes prices too, so | //| nothing is converted anywhere. | //| | //| THREE STATES PER LEG, and they are three different instructions: | //| < 0 (an empty field on the wire) leave this leg where it is | //| = 0 remove it; 0.0 is MetaTrader's | //| own "no stop" | //| > 0 the level | //| | //| EVERY LEG OF THE SYMBOL, because a hedging account can hold several | //| tickets in one instrument and protection that covered one of them | //| would be protection the trader does not have. | //+------------------------------------------------------------------+ void DoSetStops(const string id, const string symbol, const double absSl, const double absTp) { if(!SymbolSelect(symbol, true)) { AddResult(id, false, "", 0, 0, "this terminal does not offer " + symbol); return; } int digits = (int)SymbolInfoInteger(symbol, SYMBOL_DIGITS); int touched = 0; string err = ""; for(int i = PositionsTotal() - 1; i >= 0; i--) { if(!pos.SelectByIndex(i)) continue; if(pos.Symbol() != symbol) continue; // What is there now, so an untouched leg keeps exactly its own value rather // than inheriting the other ticket's. double sl = pos.StopLoss(); double tp = pos.TakeProfit(); if(absSl >= 0) sl = (absSl == 0) ? 0 : NormalizeDouble(absSl, digits); if(absTp >= 0) tp = (absTp == 0) ? 0 : NormalizeDouble(absTp, digits); if(sl == pos.StopLoss() && tp == pos.TakeProfit()) continue; if(!trade.PositionModify(pos.Ticket(), sl, tp)) { err = TradeError(); continue; } touched++; } if(StringLen(err) == 0 && touched == 0) err = "no open " + symbol + " position to put a stop behind"; AddResult(id, StringLen(err) == 0, "", 0, 0, err); } void DoFlatten(const string id) { string closed = ""; string err = ""; for(int i = PositionsTotal() - 1; i >= 0; i--) { if(!pos.SelectByIndex(i)) continue; string sym = pos.Symbol(); bool wasLong = (pos.PositionType() == POSITION_TYPE_BUY); double vol = pos.Volume(); double floatPl = pos.Profit() + pos.Swap(); if(!trade.PositionClose(pos.Ticket()) || !TradeFilled()) { err = TradeError(); continue; } if(StringLen(closed) > 0) closed += ","; closed += ClosedLeg(sym, wasLong, vol, floatPl); } if(StringLen(err) == 0 && StillOpen("") > 0) err = "the terminal still shows open positions after flattening"; AddResult(id, StringLen(err) == 0, "", 0, 0, err, closed); } //+------------------------------------------------------------------+ //| One command line: | //| id|kind|symbol|side|qty|slDistance|tpDistance|stopPrice|targetPrice //| | //| Empty fields keep their place, so every field is always at the | //| same index. That is what makes a positional format safe to read | //| without a schema. | //+------------------------------------------------------------------+ void RunLine(const string line) { string f[]; int n = StringSplit(line, '|', f); if(n < 2) return; string id = f[0]; string kind = f[1]; // PROTOCOL 5 APPENDED TWO FIELDS, so every index above keeps its meaning and a // line from an older Tradeus still reads exactly as it did. An absent field means // "leave this leg alone", which is why it decodes to -1 rather than to 0: zero is // MetaTrader's own "no stop" and therefore an instruction of its own. double absSl = (n >= 8 && StringLen(f[7]) > 0) ? StringToDouble(f[7]) : -1.0; double absTp = (n >= 9 && StringLen(f[8]) > 0) ? StringToDouble(f[8]) : -1.0; if(kind == "order" && n >= 7) DoOrder(id, f[2], f[3], StringToDouble(f[4]), StringToDouble(f[5]), StringToDouble(f[6]), absSl, absTp); else if(kind == "setStops" && n >= 3) DoSetStops(id, f[2], absSl, absTp); else if(kind == "closeSymbol" && n >= 3) DoCloseSymbol(id, f[2]); else if(kind == "flatten") DoFlatten(id); else AddResult(id, false, "", 0, 0, "this Expert Advisor does not understand '" + kind + "'"); } //+------------------------------------------------------------------+ void Poll() { string body = StateJson(); int status = 0; string res = Post("/poll", body, status); if(status != 200) { // THE ANSWERS ARE KEPT. They are only dropped once Tradeus has stopped // waiting for them — see RESULT_MAX_POLLS. Clearing them before the // request, as the first version did, turned a filled order into an // OrderUnconfirmedError every time a single poll was dropped. if(StringLen(g_results) > 0 && ++g_resultPolls >= RESULT_MAX_POLLS) { g_results = ""; g_resultPolls = 0; } Refused(status); return; } // Tradeus has them now, and WebRequest blocks, so nothing can have been // appended while the request was in flight. g_results = ""; g_resultPolls = 0; Recovered(); string lines[]; int n = StringSplit(res, '\n', lines); for(int i = 0; i < n; i++) { string line = lines[i]; StringTrimRight(line); StringTrimLeft(line); if(StringLen(line) == 0) continue; if(StringSubstr(line, 0, 3) == "OK ") { int ms = (int)StringToInteger(StringSubstr(line, 3)); if(ms >= 100 && ms != g_pollMs) { g_pollMs = ms; EventKillTimer(); EventSetMillisecondTimer(g_pollMs); } continue; } RunLine(line); } } //+------------------------------------------------------------------+ bool SayHello() { int status = 0; Post("/hello", StateJson(), status); if(status == 200) { g_saidHello = true; Recovered(); return true; } Refused(status); return false; } //+------------------------------------------------------------------+ int OnInit() { if(StringLen(ServerUrl) == 0 || StringLen(Token) == 0) { Print("Tradeus: paste the address and the token from the Tradeus connect screen into this EA's settings."); return INIT_PARAMETERS_INCORRECT; } // Build 5800 (17 April 2026) taught WebRequest to keep the connection // alive between calls. Before it, every single poll paid a fresh TLS // handshake — four times a second, all day. It still WORKS on an older // build, so this warns rather than refuses, but the difference is the // whole latency budget. if(TerminalInfoInteger(TERMINAL_BUILD) < 5800) Print("Tradeus: this terminal is build ", TerminalInfoInteger(TERMINAL_BUILD), ". Build 5800 or newer reuses the connection between requests and is noticeably faster — " "Help -> Check for Updates."); if(!MQLInfoInteger(MQL_TRADE_ALLOWED)) Print("Tradeus: algo trading is switched off — Tradeus will see this account but cannot trade it."); // The timer is started FIRST so that a refusal inside SayHello can slow it // down and have that stick. EventSetMillisecondTimer(g_pollMs); SayHello(); return INIT_SUCCEEDED; } void OnDeinit(const int reason) { EventKillTimer(); } void OnTimer() { // Say hello until it lands. A terminal that started before the connection // existed, or while the network was down, otherwise polls forever against a // connection that never learned which account it is. // // "Until it lands" does NOT mean at full speed. A 409 means this account is // already connected somewhere else and can never succeed on retry, so it // backs off like any other refusal instead of being re-sent four times a // second while the customer reads a message naming the wrong cause. if(!g_saidHello && !SayHello()) return; Poll(); } //+------------------------------------------------------------------+