Spectra Charts

Creating a signal bot in Spectra Charts with BUY and SELL labels

Step-by-step guide: how to train ChatGPT, insert a ready-made bot skeleton with visual analytics, write signal logic, and create BUY/SELL labels in Spectra Charts.

Creating a signal bot in Spectra Charts with BUY and SELL labels

Bot Builder cover

In this tutorial, we’ll walk through how to create signal bots with BUY and SELL labels in Spectra Charts. We’ll also show how to add an analytics window (HUD) to quickly test your strategy, with or without martingale “dogon” trades — it’s your choice.

We use ChatGPT as the base model for code generation. The instructions below are written for it but can be adapted to other AI chats. If you get specific errors with other neural networks, we might not be able to help, because their behavior and use cases differ.

Where to start

First, you need to prepare the chat where you’ll describe your strategy.

Important preparation rules:
  1. Use a completely fresh chat with no previous conversation history — otherwise results may be distorted.
  2. We recommend a paid version of the model with a basic plan, otherwise you’ll quickly hit message limits and answers will start getting cut off.

1. Training GPT: prompt + bot skeleton

Below is a single block with instructions for the model and a ready-made bot skeleton (including visual analytics and martingale parameters). You need to send this entire code block to the chat as is to initialize it.

Training prompt:

– I will give you code; you need to study all settings related to rendering statistics, analytics, and martingale “dogon” trades.  
– Also study the code structure so you understand how to write bots correctly.  
– Learn how to properly integrate additional filters.  
– When you send me code fixes, updates, or small tweaks, always return the **full** updated code, not just fragments.  
– In your reply, just tell me that you understood everything and wait for my further commands.  
– In the parameters, always name settings in my language with clear, human-readable labels.  
– The code itself:
// ======================== 📈 MACD Crossover Bot v1.2 (RU UI, HUD: statistics) ========================
export const meta = {
  name: "MACD Crossover v1.2",
  defaultParams: {
    // ===== MACD =====
    fastEMA: 12,
    slowEMA: 26,
    signalEMA: 9,

    // ===== COOLDOWN (1 filter) =====
    useCooldown: true,
    cooldownBars: 2,                       // minimum number of bars between signals
    cooldownMode: "По любому направлению", // По любому направлению | Только по стороне сигнала | Выкл.

    // ===== APPEARANCE =====
    buyText: "BUY",
    sellText: "SELL",
    buyColor: "#16a34a",
    sellColor: "#ef4444",
    buyTextColor: "#d1fae5",
    sellTextColor: "#fee2e2",

    // ===== HUD (stats card) =====
    showHudCard: true,
    hudAnchor: "TR",
    hudTop: 12, hudRight: 12, hudBottom: 12, hudLeft: 12,
    hudFontPx: 12, hudMaxWidth: 320, hudPadX: 12, hudPadY: 8,
    hudLineGap: 4, hudRadius: 10, hudBg: "rgba(17,24,39,.72)",
    hudBorder: "rgba(148,163,184,.28)", hudNeutral: "#94a3b8",
    hudAlertWrThresholdPct: 80,          // highlight card if WR is below this

    // ===== STATS / MARTINGALE =====
    useWinRate: true,
    useWrBreakdownPct: true,
    expiryBars: 1,            // trade duration in bars
    dogonBars: 0,             // 0 = same as expiryBars
    dogons: 0,                // 0..7
    tieMode: "Продолжить догоны",  // Return (=): Продолжить догоны | Остановить догоны | Считать как минус
    statsLookbackBars: 300    // statistics lookback window in bars
  },
  paramMeta: {
    // ===== MACD =====
    fastEMA:   { label: "MACD: fast EMA", type: "number", min: 2,  max: 200, step: 1 },
    slowEMA:   { label: "MACD: slow EMA", type: "number", min: 2,  max: 400, step: 1 },
    signalEMA: { label: "MACD: signal EMA", type: "number", min: 1,  max: 200, step: 1 },

    // ===== COOLDOWN =====
    useCooldown:  { label: "⏱ Cooldown: enable", type: "boolean" },
    cooldownBars: { label: "Cooldown: window (bars)", type: "number", min: 0, max: 20, step: 1 },
    cooldownMode: {
      label: "Cooldown: mode",
      type: "select",
      options: [
        { label: "Any direction",      value: "По любому направлению" },
        { label: "Only same side as signal",  value: "Только по стороне сигнала" },
        { label: "Off",                      value: "Выкл." }
      ]
    },

    // ===== APPEARANCE =====
    buyText:      { label: "🎨 BUY label text",  type: "text",  maxLength: 12 },
    sellText:     { label: "🎨 SELL label text", type: "text",  maxLength: 12 },
    buyColor:     { label: "🎨 BUY color",     type: "color" },
    sellColor:    { label: "🎨 SELL color",    type: "color" },
    buyTextColor: { label: "🎨 BUY text color",  type: "color" },
    sellTextColor:{ label: "🎨 SELL text color", type: "color" },

    // ===== HUD =====
    showHudCard: { label: "🪪 HUD: stats card — enable", type: "boolean" },
    hudAnchor:   {
      label: "Card position",
      type: "select",
      options: [
        { label: "Top-right (TR)", value: "TR" },
        { label: "Top-left (TL)",  value: "TL" },
        { label: "Bottom-right (BR)",  value: "BR" },
        { label: "Bottom-left (BL)",   value: "BL" }
      ]
    },
    hudTop:      { label: "Top offset (px)",    type: "number", min: 0, max: 300, step: 1 },
    hudRight:    { label: "Right offset (px)",  type: "number", min: 0, max: 300, step: 1 },
    hudBottom:   { label: "Bottom offset (px)", type: "number", min: 0, max: 300, step: 1 },
    hudLeft:     { label: "Left offset (px)",   type: "number", min: 0, max: 300, step: 1 },
    hudFontPx:   { label: "Font size (px)",         type: "number", min: 8, max: 20, step: 1 },
    hudMaxWidth: { label: "Max width (px)",  type: "number", min: 120, max: 600, step: 10 },
    hudPadX:     { label: "Padding X",          type: "number", min: 4, max: 40, step: 1 },
    hudPadY:     { label: "Padding Y",          type: "number", min: 2, max: 40, step: 1 },
    hudLineGap:  { label: "Line spacing",        type: "number", min: 0, max: 20, step: 1 },
    hudRadius:   { label: "Corner radius",             type: "number", min: 0, max: 24, step: 1 },
    hudBg:       { label: "Card background",       type: "color" },
    hudBorder:   { label: "Border color",              type: "color" },
    hudNeutral:  { label: "Neutral text color",        type: "color" },

    // ===== STATS / MARTINGALE =====
    useWinRate:        { label: "📊 Show WR %",        type: "boolean" },
    useWrBreakdownPct: { label: "📊 Percent by attempts",     type: "boolean" },
    expiryBars:        { label: "📊 Expiry N (bars)",  type: "number", min: 1, max: 50, step: 1 },
    dogonBars:         { label: "📊 Dogon bars (0 = same as N)", type: "number", min: 0, max: 50, step: 1 },
    dogons:            { label: "📊 Number of dogons (0..7)",     type: "number", min: 0, max: 7, step: 1 },
    tieMode: {
      label: "📊 Return (=): behavior",
      type: "select",
      options: [
        { label: "Continue dogons", value: "Продолжить догоны" },
        { label: "Stop dogons", value: "Остановить догоны" },
        { label: "Count as loss", value: "Считать как минус" }
      ]
    },
    statsLookbackBars: { label: "📊 Stats window (bars)", type: "number", min: 20, max: 5000, step: 10 }
  }
};

// =================== HUD Primitive (showing stats only) ===================
class MacdHudPrimitive {
  constructor(series, opt) { this._series = series; this._opts = { ...opt }; this._data = {}; this._paneView = new MacdHudPaneView(series, () => this._data, () => this._opts); }
  setData(d) { this._data = d || {}; }
  setOptions(opt) { Object.assign(this._opts, opt || {}); }
  paneViews() { return [this._paneView]; }
  priceAxisViews() { return []; }
  timeAxisViews() { return []; }
}
class MacdHudPaneView { constructor(series, dg, og) { this._renderer = new MacdHudRenderer(series, dg, og); } renderer() { return this._renderer; } zOrder() { return 'top'; } update() {} }
class MacdHudRenderer {
  constructor(series, dg, og) { this._series = series; this._dg = dg; this._og = og; }
  draw(target) {
    const opts = this._og(); const data = this._dg() || {}; const lines = [];
    if (data.stats) lines.push(data.stats);
    if (data.stats2) lines.push(data.stats2);
    if (data.stats3) lines.push(data.stats3);
    if (!lines.length || !target.useBitmapCoordinateSpace) return;

    target.useBitmapCoordinateSpace(scope => {
      const ctx = scope.context, hr = scope.horizontalPixelRatio || 1, vr = scope.verticalPixelRatio || 1;
      const cssW = scope.cssWidth || scope.bitmapSize?.width || 0;
      const cssH = scope.cssHeight || (scope.bitmapSize?.height || 0) / vr;

      const padX = (opts.paddingX || 12) * hr, padY = (opts.paddingY || 8) * vr, gap = (opts.lineGap || 4) * vr;
      const maxW = (opts.maxWidth || 320) * hr, radius = (opts.radius ?? 10) * hr, fontPx = (opts.fontSize || 12) * vr;

      ctx.save(); ctx.font = `${fontPx}px system-ui,-apple-system, Segoe UI, Roboto, Ubuntu`; ctx.textBaseline = 'top';

      let w = 0, h = padY * 2 - gap;
      for (const ln of lines) { const m = ctx.measureText(ln.text); w = Math.min(Math.max(w, m.width), maxW); h += Math.ceil(fontPx * 1.2) + gap; }

      const anc = (opts.anchor || 'TR').toUpperCase();
      let x = 0, y = 0;
      if (anc.includes('T')) y = (opts.top || 12) * vr; else y = (cssH - (h) - (opts.bottom || 12) * vr);
      if (anc.includes('R')) x = (cssW - (w + padX * 2) - (opts.right || 12) * hr); else x = ((opts.left || 12) * hr);

      roundRect(ctx, x, y, w + padX * 2, h, radius);
      ctx.fillStyle = opts.background || "rgba(17,24,39,.72)"; ctx.fill();
      ctx.strokeStyle = opts.border || "rgba(148,163,184,.28)"; ctx.lineWidth = 1 * hr; ctx.stroke();

      let cy = y + padY;
      for (const ln of lines) { ctx.fillStyle = ln.color || opts.neutral || "#94a3b8"; ctx.fillText(ln.text, x + padX, cy, maxW); cy += Math.ceil(fontPx * 1.2) + gap; }
      ctx.restore();
    });
  }
  hitTest() { return null; }
}
function roundRect(ctx, x, y, w, h, r) {
  const rr = Math.max(0, Math.min(r, w / 2, h / 2));
  ctx.beginPath();
  ctx.moveTo(x + rr, y);
  ctx.arcTo(x + w, y, x + w, y + h, rr);
  ctx.arcTo(x + w, y + h, x, y + h, rr);
  ctx.arcTo(x, y + h, x, y, rr);
  ctx.arcTo(x, y, x + w, y, rr);
  ctx.closePath();
}

// =================== BOT CORE ===================
export function init(ctx) {
  const { candleSeries, params, createSeriesMarkers } = ctx;
  const p = params || meta.defaultParams;

  const markersApi = createSeriesMarkers(candleSeries, []);

  let hudPrimitive = candleSeries.__macdHud || null;

  function ensureHudAttached() {
    if (!p.showHudCard || typeof candleSeries.attachPrimitive !== 'function') {
      if (hudPrimitive && typeof candleSeries.detachPrimitive === 'function') candleSeries.detachPrimitive(hudPrimitive);
      candleSeries.__macdHud = hudPrimitive = null;
      return null;
    }
    const opt = {
      anchor: p.hudAnchor, top: p.hudTop, right: p.hudRight, bottom: p.hudBottom, left: p.hudLeft,
      maxWidth: p.hudMaxWidth, fontSize: p.hudFontPx, paddingX: p.hudPadX, paddingY: p.hudPadY,
      lineGap: p.hudLineGap, radius: p.hudRadius, background: p.hudBg, border: p.hudBorder, neutral: p.hudNeutral
    };
    if (!hudPrimitive) {
      hudPrimitive = new MacdHudPrimitive(candleSeries, opt);
      candleSeries.attachPrimitive(hudPrimitive);
      candleSeries.__macdHud = hudPrimitive;
    } else {
      hudPrimitive.setOptions(opt);
    }
    return hudPrimitive;
  }

  // ===== helpers =====
  const EPS = 1e-12;

  function calcEMA(src, L) {
    const n = src.length;
    if (L <= 1) return src.slice();
    const out = new Array(n).fill(null);
    let ema = src[0];
    out[0] = ema;
    const k = 2 / (L + 1);
    for (let i = 1; i < n; i++) {
      ema = src[i] * k + ema * (1 - k);
      out[i] = ema;
    }
    return out;
  }

  // Map Russian string values to canonical internal modes
  function normalizeCooldown(v) {
    const s = String(v || "").toLowerCase().trim();
    if (["по любому направлению","по_всем_направлениям","global","любой","any"].includes(s)) return "global";
    if (["только по стороне сигнала","по_стороне_сигнала","side","side-only"].includes(s)) return "side";
    if (["выкл.","выкл","none","off","disabled"].includes(s)) return "none";
    return "global";
  }
  function normalizeTie(v) {
    const s = String(v || "").toLowerCase().trim();
    if (["продолжить догоны","продолжить","retry"].includes(s)) return "retry";
    if (["остановить догоны","остановить","stop"].includes(s)) return "stop";
    if (["считать как минус","минус","asloss","as loss"].includes(s)) return "asloss";
    return "retry";
  }

  function update(candles) {
    const len = candles.length;
    if (len < 2) { markersApi.setMarkers([]); return []; }

    // arrays
    const times = new Array(len), highs = new Array(len), lows = new Array(len), opens = new Array(len), closes = new Array(len);
    for (let i = 0; i < len; i++) { const d = candles[i]; times[i] = d.time; highs[i] = d.high; lows[i] = d.low; opens[i] = d.open; closes[i] = d.close; }

    // ===== MACD =====
    const f = Math.max(2, Math.floor((p.fastEMA ?? 12)));
    const s = Math.max(2, Math.floor((p.slowEMA ?? 26)));
    const sg = Math.max(1, Math.floor((p.signalEMA ?? 9)));

    const emaFast = calcEMA(closes, f);
    const emaSlow = calcEMA(closes, s);

    const macd = new Array(len).fill(null);
    for (let i = 0; i < len; i++) macd[i] = (emaFast[i] != null && emaSlow[i] != null) ? (emaFast[i] - emaSlow[i]) : null;

    // signal line (EMA of MACD)
    const macdSrc = macd.map(v => v ?? 0);
    const signal = calcEMA(macdSrc, sg);

    // ===== signals: MACD/Signal crossovers =====
    const events = [];
    let lastIdx = -1, lastIdxBuy = -1, lastIdxSell = -1;

    function cooldownOk(i, side) {
      if (!p.useCooldown || normalizeCooldown(p.cooldownMode) === "none") return true;
      const gap = Math.max(0, Math.floor(p.cooldownBars || 0));
      const mode = normalizeCooldown(p.cooldownMode);
      if (mode === "global") return !(lastIdx >= 0 && i - lastIdx <= gap);
      if (mode === "side") {
        const last = side === 'buy' ? lastIdxBuy : lastIdxSell;
        return !(last >= 0 && i - last <= gap);
      }
      return true;
    }

    for (let i = 1; i < len; i++) {
      if (macd[i - 1] == null || signal[i - 1] == null || macd[i] == null || signal[i] == null) continue;

      const crossUp = (macd[i - 1] <= signal[i - 1]) && (macd[i] > signal[i]);   // BUY
      const crossDn = (macd[i - 1] >= signal[i - 1]) && (macd[i] < signal[i]);   // SELL

      if (crossUp && cooldownOk(i, 'buy')) {
        events.push({ side: "buy", i });
        lastIdx = lastIdxBuy = i;
      } else if (crossDn && cooldownOk(i, 'sell')) {
        events.push({ side: "sell", i });
        lastIdx = lastIdxSell = i;
      }
    }

    // ===== outcomes + stats with martingale =====
    const K = Math.max(0, Math.min(7, Math.floor(p.dogons || 0)));
    const N1 = Math.max(1, Math.floor(p.expiryBars || 1));
    const stepN = Math.max(0, Math.floor(p.dogonBars || 0)) || N1;
    const tieMode = normalizeTie(p.tieMode);

    let ties = 0, losses = 0;
    const winsAt = new Array(1 + K + 1).fill(0); // indices 1..(1+K)
    const sigOutcome = new Map(); // iSig -> { resolved, win, attempt }

    {
      const Lb = Math.max(1, Math.floor(p.statsLookbackBars || 300));
      const fromBar = Math.max(0, (len - 1) - Lb);

      for (const ev of events) {
        const iSig = ev.i; if (iSig < fromBar) continue;
        const sign = ev.side === "buy" ? 1 : -1;

        let attempt = 1;
        let idxEntry = iSig + 1; // entry on the next bar
        let finished = false;

        while (!finished) {
          const dur = (attempt === 1) ? N1 : stepN;
          const jExit = idxEntry + dur - 1;
          if (idxEntry >= len || jExit >= len) break;

          const d = (closes[jExit] - opens[idxEntry]) * sign; // OPEN→CLOSE

          if (Math.abs(d) < EPS) {
            ties++;
            if (tieMode === "retry") {
              idxEntry = jExit + 1;
              continue;
            } else if (tieMode === "asloss") {
              if (attempt <= K) {
                attempt++;
                idxEntry = jExit + 1;
                continue;
              } else {
                losses++; finished = true;
                sigOutcome.set(iSig, { resolved: true, win: false, attempt });
                continue;
              }
            } else { // "stop"
              break;
            }
          }

          if (d > 0) {
            winsAt[attempt]++; finished = true;
            sigOutcome.set(iSig, { resolved: true, win: true, attempt });
          } else {
            if (attempt <= K) {
              attempt++;
              idxEntry = jExit + 1;
            } else {
              losses++; finished = true;
              sigOutcome.set(iSig, { resolved: true, win: false, attempt });
            }
          }
        }
      }
    }

    const totalWins = winsAt.reduce((a, b) => a + b, 0);
    const denom = totalWins + losses;
    const wrPct = denom > 0 ? Math.round((totalWins / denom) * 100) : null;

    const pctAt = winsAt.map((w, idx) => (idx === 0 ? null : (деном > 0 ? Math.round((w / деном) * 100) : null)));
    const pctL = denom > 0 ? Math.round((losses / denom) * 100) : null;

    // ===== markers (above signals — attempt number on which the trade won) =====
    const markers = [];
    for (const ev of events) {
      const i = ev.i;
      const side = ev.side;
      const base = side === 'buy' ? p.buyText : p.sellText;

      const out = sigOutcome.get(i);
      let text = base;
      const showAttemptNumber = (p.dogons > 0) && out && out.resolved && out.win === true && out.attempt != null;
      if (showAttemptNumber) text += ` ${out.attempt}`; // attempt number that closed in profit

      if (side === 'buy') {
        markers.push({
          name: "buy", time: times[i], position: "belowBar", shape: "arrowUp",
          color: p.buyColor, text, textColor: p.buyTextColor, price: lows[i]
        });
      } else {
        markers.push({
          name: "sell", time: times[i], position: "aboveBar", shape: "arrowDown",
          color: p.sellColor, text, textColor: p.sellTextColor, price: highs[i]
        });
      }
    }

    // ===== HUD stats =====
    const prim = ensureHudAttached();
    if (prim) {
      const thr = Math.max(0, Math.min(100, p.hudAlertWrThresholdPct ?? 80));
      const isAlert = (wrPct != null && wrPct < thr);
      if (isAlert) prim.setOptions({ background: "rgba(220,38,38,.76)", border: "rgba(239,68,68,.75)", neutral: "#ffe4e6" });
      else prim.setOptions({ background: p.hudBg, border: p.hudBorder, neutral: p.hudNeutral });

      const colN = p.hudNeutral || "#94a3b8";
      const colBuy = p.buyColor || "#16a34a";
      const colSell = p.sellColor || "#ef4444";
      const statsColor = isAlert ? "#ffe4e6"
        : (totalWins > losses) ? colBuy : (losses > totalWins) ? colSell : colN;

      const baseStats = `📊: +${totalWins} −${losses} =${ties}` + (p.useWinRate && wrPct != null ? ` | WR ${wrPct}%` : "");
      const parts = []; for (let a = 1; a <= 1 + Math.max(0, Math.min(7, Math.floor(p.dogons || 0))); a++) parts.push(`${a}:+${winsAt[a] || 0}`);
      const breakdownText = `🏆: ${parts.join(", ")}, =:${ties}, −:${losses}`;

      let pctText = null;
      if (p.useWrBreakdownPct) {
        const pp = []; for (let a = 1; a <= 1 + Math.max(0, Math.min(7, Math.floor(p.dогons || 0))); a++) pp.push(`${a}:${pctAt[a] ?? 0}%`);
        pctText = `📈: ${pp.join(", ")}, −:${pctL ?? 0}%`;
      }

      prim.setData({
        stats:  { text: baseStats, color: statsColor },
        stats2: { text: breakdownText, color: statsColor },
        stats3: { text: pctText, color: statsColor }
      });
    }

    markersApi.setMarkers(markers);
    return markers;
  }

  function destroy() {
    markersApi.setMarkers([]);
    if (candleSeries.__macdHud && typeof candleSeries.detachPrimitive === 'function') {
      candleSeries.detachPrimitive(candleSeries.__macdHud);
    }
    candleSeries.__macdHud = null;
  }

  return { update, destroy };
}

Example of the prompt sent to the chat:

2. Writing the logic for a new bot

Now that the chat has been trained with a single message, move on to describing the logic of your strategy so you can get a ready-to-use tool.

Write conditions as specifically as possible: what you look at, where you look, and when you trigger a signal. Vague phrasing forces the model to guess and often leads to mistakes.
SectionText
Good example of a descriptionThe MACD line crosses the signal line from top to bottom, RSI < 40SELL.
The MACD line crosses the signal line from bottom to top, RSI > 60BUY.
Bad example of a description“The MACD line crosses somewhere above 60 RSI, then we sell, and we buy when MACD is below and RSI is above 60, something like that.”
Important: this is just a simple example to show the approach. Your strategies can be more complex and include more conditions and filters — everything depends on your idea and how clearly you can describe it.

3. Testing the generated code

Open the tools menu and click Create bot.

You’ll see a window for creation, category selection, and code access permissions:

  1. Paste the strategy code from ChatGPT.
  2. Choose the tool type: Bot, Indicator, or Other.
  3. Click Check — the service will quickly run a syntax validation.
  4. Click Create bot — the tool will appear on the chart with BUY/SELL labels.

FAQ

Before trading live, test your bot on demo and gather statistics on win rate. Do not increase risk until you see stable results.