// PropGuardBridge for cTrader // // Read-only bridge: reports account equity, open positions and closed trades // to the Prop Trader Guard server. It never opens, changes or closes a trade. // // It is the cTrader counterpart of bridge-ea/PropGuardBridge.mq5 and posts // the same JSON to the same endpoints, so the server cannot tell them apart // except by the "platform" it names when pairing. // // How to install: in cTrader open Automate, press New, name the cBot // PropGuardBridge, replace the generated code with this file, press Build, // then add an instance to any chart of the account, paste the pairing code // from the app into the "Pairing code" parameter and press Play. // // Written 10 October 2026 against the cTrader Algo API reference (Http, // HttpRequest, LocalStorage, Positions, History). It has not been run on a // live terminal here: cTrader Automate is the only thing that can build it. // The first thing to check if it will not pair is the Log tab of the cBot // instance, where every failure is printed with its reason. using System; using System.Collections.Generic; using System.Globalization; using System.Text; using cAlgo.API; namespace cAlgo.Robots { [Robot(AccessRights = AccessRights.None, AddIndicators = false)] public class PropGuardBridge : Robot { [Parameter("Server address", DefaultValue = "https://proptraderguard.fly.dev")] public string BaseUrl { get; set; } [Parameter("Pairing code (first run only)", DefaultValue = "")] public string PairingCode { get; set; } [Parameter("Snapshot interval with positions open (s)", DefaultValue = 1, MinValue = 1)] public int SnapshotSeconds { get; set; } [Parameter("Snapshot interval while flat (s)", DefaultValue = 10, MinValue = 1)] public int IdleSnapshotSeconds { get; set; } [Parameter("Hours of history replayed on start", DefaultValue = 48, MinValue = 1)] public int BackfillHours { get; set; } [Parameter("Days of history sent once, on first pairing", DefaultValue = 90, MinValue = 1)] public int FirstRunDays { get; set; } private string _token = ""; private DateTime _lastSnapshot = DateTime.MinValue; private DateTime _lastErrorPrint = DateTime.MinValue; /// Closing times already reported, so the history scan only sends new ones. private DateTime _historySentUpTo = DateTime.MinValue; private readonly HashSet _openReported = new HashSet(); private static readonly DateTime Epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); protected override void OnStart() { var justPaired = false; _token = LocalStorage.GetString(TokenKey(), LocalStorageScope.Device) ?? ""; if (_token != "") Print("PropGuardBridge: using the saved bridge token."); if (_token == "") { if (string.IsNullOrWhiteSpace(PairingCode)) { Print("PropGuardBridge: not paired. Open the app, add this account, and put its pairing code in the cBot parameters."); Stop(); return; } if (!Pair()) { Stop(); return; } justPaired = true; } // History: the whole first-run window once, then a short window on // every later start so a restart cannot lose a closed trade. var hours = justPaired ? FirstRunDays * 24 : BackfillHours; SendHistory(Server.TimeInUtc.AddHours(-hours), backfill: true); foreach (var p in Positions) _openReported.Add(p.Id); Positions.Opened += OnPositionOpened; Positions.Closed += OnPositionClosed; Timer.Start(TimeSpan.FromSeconds(1)); SendSnapshot(); } protected override void OnStop() { Positions.Opened -= OnPositionOpened; Positions.Closed -= OnPositionClosed; } protected override void OnTimer() { var interval = Positions.Count > 0 ? SnapshotSeconds : IdleSnapshotSeconds; if ((Server.TimeInUtc - _lastSnapshot).TotalSeconds >= interval) SendSnapshot(); } private void OnPositionOpened(PositionOpenedEventArgs args) { var p = args.Position; if (_openReported.Add(p.Id)) Post("/v1/bridge/deals", "[" + EntryDealJson(p.Id, p.SymbolName, p.TradeType, p.VolumeInUnits, p.EntryPrice, p.EntryTime, false) + "]"); SendSnapshot(); } private void OnPositionClosed(PositionClosedEventArgs args) { // The closing deal is in History a moment later; a scan from the // last point reported picks it up, partial closes included. SendHistory(_historySentUpTo, backfill: false); SendSnapshot(); } // MARK: Pairing private string TokenKey() { // One token per account and per server, as the EA does: a token only // means something to the server that issued it. return "PropGuardBridge.token." + Account.Number + "." + BaseUrl; } private bool Pair() { var json = "{" + "\"code\":\"" + JsonEscape(PairingCode.Trim()) + "\"," + "\"login\":" + Account.Number + "," + "\"server\":\"" + JsonEscape(Account.BrokerName) + "\"," + "\"platform\":\"ctrader\"" + "}"; string reply; if (!Post("/v1/bridge/pair", json, out reply)) { Print("PropGuardBridge: pairing failed. Check the code has not expired and that the account number matches the one in the app."); return false; } var token = JsonString(reply, "bridgeToken"); if (token == "") { Print("PropGuardBridge: the server did not return a bridge token."); return false; } _token = token; LocalStorage.SetString(TokenKey(), token, LocalStorageScope.Device); LocalStorage.Flush(LocalStorageScope.Device); Print("PropGuardBridge: paired. The pairing code can now be cleared from the parameters."); return true; } private void ForgetToken() { _token = ""; LocalStorage.SetString(TokenKey(), "", LocalStorageScope.Device); LocalStorage.Flush(LocalStorageScope.Device); } // MARK: Snapshots private void SendSnapshot() { var sb = new StringBuilder(); sb.Append("{"); sb.Append("\"login\":").Append(Account.Number).Append(","); sb.Append("\"server\":\"").Append(JsonEscape(Account.BrokerName)).Append("\","); sb.Append("\"currency\":\"").Append(JsonEscape(Account.Asset.Name)).Append("\","); sb.Append("\"balance\":").Append(Num(Account.Balance, 2)).Append(","); sb.Append("\"equity\":").Append(Num(Account.Equity, 2)).Append(","); sb.Append("\"positions\":").Append(Positions.Count).Append(","); // cTrader keeps time in UTC, so the server's offset from GMT is 0. sb.Append("\"serverGmtOffset\":0,"); sb.Append("\"ts\":").Append(UnixSeconds(Server.TimeInUtc)).Append(","); sb.Append("\"openPositions\":").Append(PositionsJson()).Append(","); // Contract details for the chart the bridge is attached to, per lot, // which is what the lot-size maths expects. sb.Append("\"symbol\":{"); sb.Append("\"name\":\"").Append(JsonEscape(Symbol.Name)).Append("\","); sb.Append("\"tickValue\":").Append(Num(Symbol.TickValue * Symbol.LotSize, 8)).Append(","); sb.Append("\"tickSize\":").Append(Num(Symbol.TickSize, 8)).Append(","); sb.Append("\"volumeStep\":").Append(Num(Symbol.VolumeInUnitsStep / Symbol.LotSize, 8)).Append(","); sb.Append("\"volumeMin\":").Append(Num(Symbol.VolumeInUnitsMin / Symbol.LotSize, 8)).Append(","); sb.Append("\"volumeMax\":").Append(Num(Symbol.VolumeInUnitsMax / Symbol.LotSize, 8)); sb.Append("}}"); Post("/v1/bridge/snapshot", sb.ToString()); _lastSnapshot = Server.TimeInUtc; } private string PositionsJson() { var sb = new StringBuilder("["); var first = true; foreach (var p in Positions) { var symbol = Symbols.GetSymbol(p.SymbolName); var digits = symbol != null && symbol.Digits > 0 && symbol.Digits <= 8 ? symbol.Digits : 5; var lotSize = symbol != null && symbol.LotSize > 0 ? symbol.LotSize : 1; if (!first) sb.Append(","); first = false; sb.Append("{"); sb.Append("\"ticket\":").Append(p.Id).Append(","); sb.Append("\"symbol\":\"").Append(JsonEscape(p.SymbolName)).Append("\","); sb.Append("\"side\":\"").Append(p.TradeType == TradeType.Sell ? "sell" : "buy").Append("\","); sb.Append("\"volume\":").Append(Num(p.VolumeInUnits / lotSize, 2)).Append(","); sb.Append("\"openPrice\":").Append(Num(p.EntryPrice, digits)).Append(","); sb.Append("\"currentPrice\":").Append(Num(p.CurrentPrice, digits)).Append(","); sb.Append("\"sl\":").Append(Num(p.StopLoss ?? 0, digits)).Append(","); sb.Append("\"tp\":").Append(Num(p.TakeProfit ?? 0, digits)).Append(","); sb.Append("\"profit\":").Append(Num(p.NetProfit, 2)).Append(","); sb.Append("\"tickValue\":").Append(Num(symbol != null ? symbol.TickValue * lotSize : 0, 8)).Append(","); sb.Append("\"tickSize\":").Append(Num(symbol != null ? symbol.TickSize : 0, 8)); sb.Append("}"); } sb.Append("]"); return sb.ToString(); } // MARK: History // // cTrader's history is a list of closed trades, one row per (partial) // close, where MetaTrader's is a list of deals. Each row becomes an // "out" deal at its close, plus one "in" deal per position at its entry, // so the server groups them by position exactly as it does the EA's. // Tickets must be unique and must not collide across the two kinds, so // an entry is the position id times ten and a close is the closing deal // id times ten plus one. Resending the entry for a position with several // closes is harmless: the server keeps the first copy of a ticket. private void SendHistory(DateTime fromUtc, bool backfill) { var sb = new StringBuilder("["); var first = true; var newest = _historySentUpTo; var entriesSent = new HashSet(); foreach (var t in History) { if (t.ClosingTime <= fromUtc) continue; var symbol = Symbols.GetSymbol(t.SymbolName); var lotSize = symbol != null && symbol.LotSize > 0 ? symbol.LotSize : 1; if (entriesSent.Add(t.PositionId)) { if (!first) sb.Append(","); first = false; sb.Append(EntryDealJson(t.PositionId, t.SymbolName, t.TradeType, PositionVolume(t.PositionId), t.EntryPrice, t.EntryTime, backfill)); } if (!first) sb.Append(","); first = false; sb.Append("{"); sb.Append("\"login\":").Append(Account.Number).Append(","); sb.Append("\"ticket\":").Append((long)t.ClosingDealId * 10 + 1).Append(","); sb.Append("\"positionId\":").Append(t.PositionId).Append(","); sb.Append("\"symbol\":\"").Append(JsonEscape(t.SymbolName)).Append("\","); // The closing deal is the opposite side of the position. sb.Append("\"type\":\"").Append(t.TradeType == TradeType.Buy ? "sell" : "buy").Append("\","); sb.Append("\"entry\":\"out\","); sb.Append("\"volume\":").Append(Num(t.VolumeInUnits / lotSize, 2)).Append(","); sb.Append("\"price\":").Append(Num(t.ClosingPrice, 5)).Append(","); sb.Append("\"net\":").Append(Num(t.NetProfit, 2)).Append(","); sb.Append("\"ts\":").Append(UnixSeconds(t.ClosingTime)).Append(","); sb.Append("\"backfill\":").Append(backfill ? "true" : "false"); sb.Append("}"); if (t.ClosingTime > newest) newest = t.ClosingTime; } sb.Append("]"); if (first) return; if (Post("/v1/bridge/deals", sb.ToString())) _historySentUpTo = newest; } /// The volume a position was opened with: the sum of its closes, since /// every unit that went in must have come out by the time it is history. private double PositionVolume(long positionId) { double units = 0; foreach (var t in History) if (t.PositionId == positionId) units += t.VolumeInUnits; foreach (var p in Positions) if (p.Id == positionId) units += p.VolumeInUnits; return units; } private string EntryDealJson(long positionId, string symbolName, TradeType side, double volumeInUnits, double price, DateTime entryTimeUtc, bool backfill) { var symbol = Symbols.GetSymbol(symbolName); var lotSize = symbol != null && symbol.LotSize > 0 ? symbol.LotSize : 1; var sb = new StringBuilder(); sb.Append("{"); sb.Append("\"login\":").Append(Account.Number).Append(","); sb.Append("\"ticket\":").Append(positionId * 10).Append(","); sb.Append("\"positionId\":").Append(positionId).Append(","); sb.Append("\"symbol\":\"").Append(JsonEscape(symbolName)).Append("\","); sb.Append("\"type\":\"").Append(side == TradeType.Sell ? "sell" : "buy").Append("\","); sb.Append("\"entry\":\"in\","); sb.Append("\"volume\":").Append(Num(volumeInUnits / lotSize, 2)).Append(","); sb.Append("\"price\":").Append(Num(price, 5)).Append(","); sb.Append("\"net\":0,"); sb.Append("\"ts\":").Append(UnixSeconds(entryTimeUtc)).Append(","); sb.Append("\"backfill\":").Append(backfill ? "true" : "false"); sb.Append("}"); return sb.ToString(); } // MARK: HTTP private bool Post(string path, string body) { string ignored; return Post(path, body, out ignored); } private bool Post(string path, string body, out string replyText) { replyText = ""; try { var request = new HttpRequest(new Uri(BaseUrl.TrimEnd('/') + path)); request.Method = HttpMethod.Post; request.Timeout = TimeSpan.FromSeconds(5); request.Headers.Add("Content-Type", "application/json"); if (_token != "") request.Headers.Add("Authorization", "Bearer " + _token); request.Body = body; var response = Http.Send(request); replyText = response.Body ?? ""; if (response.IsSuccessful) return true; // The server says this terminal is no longer paired: drop the // saved token and say so, rather than retrying with it forever. if (response.StatusCode == 401 && _token != "") { ForgetToken(); Print("PropGuardBridge: this terminal is no longer paired. Open the app, get a new pairing code for this account, put it in the parameters and restart the cBot."); Stop(); return false; } Throttled("PropGuardBridge: server replied " + response.StatusCode + " to " + path + ": " + replyText); return false; } catch (Exception ex) { Throttled("PropGuardBridge: request to " + path + " failed: " + ex.Message); return false; } } /// Reports problems at most once every 30 seconds to keep the log readable. private void Throttled(string message) { if ((Server.TimeInUtc - _lastErrorPrint).TotalSeconds < 30) return; _lastErrorPrint = Server.TimeInUtc; Print(message); } // MARK: JSON helpers private static string JsonEscape(string value) { if (value == null) return ""; return value.Replace("\\", "\\\\").Replace("\"", "\\\""); } private static string Num(double value, int digits) { return value.ToString("F" + digits, CultureInfo.InvariantCulture); } private static string UnixSeconds(DateTime utc) { return (utc - Epoch).TotalSeconds.ToString("F3", CultureInfo.InvariantCulture); } /// Reads one string value out of a flat JSON reply. Enough for the two /// fields the pairing reply has; no general parser is needed. private static string JsonString(string json, string key) { var needle = "\"" + key + "\":\""; var start = json.IndexOf(needle, StringComparison.Ordinal); if (start < 0) return ""; start += needle.Length; var end = json.IndexOf('"', start); return end < 0 ? "" : json.Substring(start, end - start); } } }