// ===================================================================== // TradeJournal Pro — Native cTrader cBot (v1.0) // // Same role as the .mq4/.mq5 EAs: polls the TradeJournal API for new // signals + command queue, executes them against the connected cTrader // account, reports closes back, supports all the v7 features (risk modes, // spread guard, circuit breakers, auto symbol mapping). // // How to use: // 1. Open cTrader → Automate (left sidebar). // 2. Click "New" → paste this file as a cBot. // 3. Build (F8) → drag onto a chart of any symbol. // 4. Paste your TJP API_TOKEN in the inputs and Start. // // Architecture: // • OnStart() — fires once. Detects broker symbol prefix/suffix // and pushes it to /api/ea/detect-symbols. // • OnTimer() — polls /api/ea/signals every POLL_INTERVAL_SEC. // • ProcessSignal() — applies risk + spread + breaker gates, then // ExecuteMarketOrder() or PlaceLimitOrder(). // • Positions.Closed — async hook reports closes via /api/ea/trade-close. // // Not yet supported (vs the MT4/5 EA): // • Multi-TP partial closes (this version sets one TP per position) // • TP chain trailing (USE_TRAILING_TP) // • Per-TP RR overrides // These can be added by replicating the relevant blocks from the .mq4. // ===================================================================== using cAlgo.API; using cAlgo.API.Internals; using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Net; using System.Text; using System.Web.Script.Serialization; namespace cAlgo.Robots { [Robot(AccessRights = AccessRights.FullAccess, AddIndicators = true)] public class TradeJournalCopier : Robot { // ── INPUTS ───────────────────────────────────────────────────── [Parameter("API URL", DefaultValue = "https://tradejournalpro.net")] public string ApiUrl { get; set; } [Parameter("API Token", DefaultValue = "")] public string ApiToken { get; set; } [Parameter("Poll interval (s)", DefaultValue = 30, MinValue = 5)] public int PollIntervalSec { get; set; } [Parameter("Symbol prefix", DefaultValue = "")] public string SymbolPrefix { get; set; } [Parameter("Symbol suffix", DefaultValue = "")] public string SymbolSuffix { get; set; } [Parameter("Risk %", DefaultValue = 1.0, MinValue = 0)] public double RiskPercent { get; set; } [Parameter("Default lot", DefaultValue = 0.01, MinValue = 0.01)] public double DefaultLot { get; set; } [Parameter("Max lot", DefaultValue = 5.0, MinValue = 0.01)] public double MaxLot { get; set; } [Parameter("Magic / Label", DefaultValue = "TJP")] public string Label { get; set; } [Parameter("Max spread (pips, 0=off)", DefaultValue = 0)] public double MaxSpreadPips { get; set; } [Parameter("Max consec losses today (0=off)", DefaultValue = 0)] public int MaxConsecLosses { get; set; } [Parameter("Equity DD breaker % (0=off)", DefaultValue = 0)] public double EquityDdBreakerPct { get; set; } [Parameter("Reverse signal direction", DefaultValue = false)] public bool ReverseSignal { get; set; } // ── STATE ────────────────────────────────────────────────────── private DateTime _lastPoll = DateTime.MinValue; private readonly HashSet _processedIds = new HashSet(); private readonly JavaScriptSerializer _json = new JavaScriptSerializer(); private int _consecLossesToday = 0; private DateTime _consecDay = DateTime.MinValue; private bool _breakerTripped = false; protected override void OnStart() { if (string.IsNullOrEmpty(ApiToken)) { Print("ERROR: API_TOKEN is empty. Set it in cBot inputs."); Stop(); return; } Print("TradeJournal Pro cBot v1.0 | Token: ...", ApiToken.Substring(Math.Max(0, ApiToken.Length - 6))); // Push the broker's symbol list so the server can detect the // (prefix, suffix, renameMap) once. Fire-and-forget. DetectSymbolsAsync(); Timer.Start(TimeSpan.FromSeconds(PollIntervalSec)); Positions.Closed += OnPositionClosed; } protected override void OnStop() { Timer.Stop(); } protected override void OnTimer() { PollSignals(); } // ── SYMBOL DETECTION ─────────────────────────────────────────── private void DetectSymbolsAsync() { try { // cTrader exposes Symbols.GetSymbolsLight() in newer versions; // for max compatibility we fall back to Symbols.Keys-like access. var names = new List(); foreach (var s in Symbols.GetSymbolNames()) names.Add(s); if (names.Count < 5) return; var body = _json.Serialize(new { symbols = string.Join(",", names) }); Post(ApiUrl + "/api/ea/detect-symbols?token=" + ApiToken, body, false); } catch (Exception e) { Print("[SYMBOL-MAP] ", e.Message); } } // ── SIGNAL POLLING ───────────────────────────────────────────── private void PollSignals() { try { var raw = Get(ApiUrl + "/api/ea/signals?token=" + ApiToken); if (string.IsNullOrEmpty(raw)) return; var parsed = _json.DeserializeObject(raw) as Dictionary; if (parsed == null) return; if (!parsed.ContainsKey("signals")) return; var arr = parsed["signals"] as object[]; if (arr == null) return; foreach (var item in arr) { var obj = item as Dictionary; if (obj == null) continue; var id = obj.ContainsKey("id") ? Convert.ToString(obj["id"]) : null; if (string.IsNullOrEmpty(id) || _processedIds.Contains(id)) continue; _processedIds.Add(id); ProcessSignal(obj); } } catch (Exception e) { Print("[POLL] ", e.Message); } } // ── EXECUTE ONE SIGNAL ───────────────────────────────────────── private void ProcessSignal(Dictionary s) { string pair = s.ContainsKey("pair") ? Convert.ToString(s["pair"]) : null; string dir = s.ContainsKey("direction") ? Convert.ToString(s["direction"]) : null; if (string.IsNullOrEmpty(pair) || string.IsNullOrEmpty(dir)) return; // Optional reverse if (ReverseSignal) dir = dir == "LONG" ? "SHORT" : "LONG"; // Resolve broker symbol name string symName = SymbolPrefix + pair.Replace("/", "") + SymbolSuffix; var sym = Symbols.GetSymbol(symName); if (sym == null) { Print("[SIGNAL] symbol not found: ", symName); return; } // Breakers RefreshConsecLossesToday(); if (_breakerTripped) { Print("[BREAKER] consec losses tripped — skipping ", pair); return; } if (EquityBreakerCheck()) { Print("[BREAKER] equity DD breached — skipping ", pair); return; } // Spread guard if (MaxSpreadPips > 0) { double spreadPips = (sym.Ask - sym.Bid) / sym.PipSize; if (spreadPips > MaxSpreadPips) { Print("[SPREAD] ", symName, " spread ", spreadPips.ToString("F1"), "p > ", MaxSpreadPips, "p — skipping"); return; } } // Parse risk levels double? entry = TryDouble(s, "entryPrice"); double? sl = TryDouble(s, "stopLoss"); double? tp = TryDouble(s, "takeProfit1"); double? lotIn = TryDouble(s, "lotSize"); // Lot sizing double volume; if (RiskPercent > 0 && sl.HasValue && entry.HasValue) { double slPips = Math.Abs(entry.Value - sl.Value) / sym.PipSize; double riskUsd = Account.Balance * (RiskPercent / 100.0); double pipVal = sym.PipValue; // $ per 1 unit per pip double units = pipVal > 0 ? riskUsd / (slPips * pipVal) : sym.VolumeInUnitsMin; volume = sym.NormalizeVolumeInUnits(units, RoundingMode.Down); } else { double lots = lotIn ?? DefaultLot; lots = Math.Min(lots, MaxLot); volume = sym.QuantityToVolumeInUnits(lots); } if (volume < sym.VolumeInUnitsMin) volume = sym.VolumeInUnitsMin; if (volume > sym.VolumeInUnitsMax) volume = sym.VolumeInUnitsMax; // Fire var side = dir == "LONG" ? TradeType.Buy : TradeType.Sell; double? slPipsExec = sl.HasValue && entry.HasValue ? (double?)Math.Abs(entry.Value - sl.Value) / sym.PipSize : null; double? tpPipsExec = tp.HasValue && entry.HasValue ? (double?)Math.Abs(tp.Value - entry.Value) / sym.PipSize : null; var label = Label + ":" + (s.ContainsKey("id") ? Convert.ToString(s["id"]) : ""); var res = ExecuteMarketOrder(side, sym.Name, volume, label, slPipsExec, tpPipsExec); if (!res.IsSuccessful) { Print("[ORDER] FAILED ", res.Error); Post(ApiUrl + "/api/ea/sync-trade?token=" + ApiToken, _json.Serialize(new { signalId = s["id"], status = "failed", error = res.Error.ToString() }), true); return; } // Notify server that this signal opened Post(ApiUrl + "/api/ea/sync-trade?token=" + ApiToken, _json.Serialize(new { signalId = s["id"], ticket = res.Position.Id, openPrice = res.Position.EntryPrice, direction = dir, pair = pair, lotSize = sym.VolumeInUnitsToQuantity(volume), sl = sl, tp = tp, }), true); } // ── CLOSE HOOK ───────────────────────────────────────────────── private void OnPositionClosed(PositionClosedEventArgs args) { var p = args.Position; if (p.Label == null || !p.Label.StartsWith(Label)) return; var body = _json.Serialize(new { ticket = p.Id, closePrice = (double?)null, // cTrader doesn't expose post-close price here; server reconciles profit = p.NetProfit, closeTime = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture), pair = p.SymbolName, direction = p.TradeType == TradeType.Buy ? "LONG" : "SHORT", openPrice = p.EntryPrice, lotSize = Symbols.GetSymbol(p.SymbolName).VolumeInUnitsToQuantity(p.VolumeInUnits), sl = p.StopLoss, tp = p.TakeProfit, }); Post(ApiUrl + "/api/ea/trade-close?token=" + ApiToken, body, true); } // ── BREAKERS ─────────────────────────────────────────────────── private void RefreshConsecLossesToday() { var day = Server.Time.Date; if (_consecDay != day) { _consecDay = day; _consecLossesToday = 0; _breakerTripped = false; } int losses = 0; foreach (var t in History.Where(h => h.ClosingTime >= day && h.Label != null && h.Label.StartsWith(Label)) .OrderByDescending(h => h.ClosingTime)) { if (t.NetProfit < 0) losses++; else if (t.NetProfit > 0) { losses = 0; break; } } _consecLossesToday = losses; if (MaxConsecLosses > 0 && _consecLossesToday >= MaxConsecLosses) _breakerTripped = true; } private bool EquityBreakerCheck() { if (EquityDdBreakerPct <= 0) return false; double bal = Account.Balance; if (bal <= 0) return false; double ddPct = (bal - Account.Equity) / bal * 100.0; if (ddPct < EquityDdBreakerPct) return false; if (!_breakerTripped) { Print("[BREAKER] Equity DD ", ddPct.ToString("F2"), "% ≥ ", EquityDdBreakerPct, "% — closing all."); foreach (var p in Positions.Where(p => p.Label != null && p.Label.StartsWith(Label))) ClosePosition(p); _breakerTripped = true; } return true; } // ── HTTP helpers ─────────────────────────────────────────────── private string Get(string url) { try { var req = (HttpWebRequest)WebRequest.Create(url); req.Method = "GET"; req.Timeout = 8000; using (var res = (HttpWebResponse)req.GetResponse()) using (var sr = new StreamReader(res.GetResponseStream())) return sr.ReadToEnd(); } catch (WebException e) { Print("[HTTP] GET ", e.Message); return null; } } private void Post(string url, string body, bool log) { try { var req = (HttpWebRequest)WebRequest.Create(url); req.Method = "POST"; req.ContentType = "application/json"; req.Timeout = 8000; var data = Encoding.UTF8.GetBytes(body); req.ContentLength = data.Length; using (var s = req.GetRequestStream()) s.Write(data, 0, data.Length); using (var res = (HttpWebResponse)req.GetResponse()) { if (log) Print("[HTTP] POST ", url.Split('?')[0], " ", (int)res.StatusCode); } } catch (WebException e) { Print("[HTTP] POST ", e.Message); } } // ── Util ─────────────────────────────────────────────────────── private double? TryDouble(Dictionary o, string key) { if (!o.ContainsKey(key) || o[key] == null) return null; try { return Convert.ToDouble(o[key], CultureInfo.InvariantCulture); } catch { return null; } } } }