Спектра Чартс

Создаём сигнальный бот в Spectra Charts с метками BUY и SELL

Пошаговый гайд: как обучить ChatGPT, вставить готовый каркас бота с визуальнойаналитикой, написание логики сигналов и создание меток BUY/SELL в Spectra Charts.

Создаём сигнальный бот в Spectra Charts с метками BUY и SELL

Обложка конструктора ботов

В этом обучающем посте разберём, как создавать сигнальные боты с метками BUY и SELL в Spectra Charts. Дополнительно покажем, как встроить окно аналитики (HUD) для быстрого тестирования вашей стратегии с учётом догонов по мартингейлу или без них — по вашему выбору.

Мы используем ChatGPT как базовую модель для генерации кода. Инструкции ниже ориентированы на неё, но их можно адаптировать под другие чаты. Если при работе с другими нейросетями возникнут специфические ошибки, мы не сможем подсказать решение, так как сценарии и в целом сами нейросети отличаются.

С чего начать

Сначала нужно обучить чат, в котором вы будете писать стратегию.

Важные условия подготовки:
  1. Используйте полностью чистый чат без предыдущей переписки — иначе результаты могут исказиться.
  2. Рекомендуем платную версию модели с базовым тарифом, иначе лимиты сообщений быстро закончатся и ответы начнут урезаться.

1. Обучение GPT: промпт + каркас бота

Ниже — единый блок с инструкциями для модели и готовым каркасом бота (включая визуальную аналитику и параметры догонов). Этот код-блок целиком нужно отправить в чат для инициализации.

Обучающий промпт:

– Я тебе даю код, тебе нужно изучить все настройки которые связаны с отрисовкой статистики, аналитики и догонов, 
– Так же изучи структуру кода для правильного написания ботов,
– Изучи как правильно встраивать доп фильтры,
– Когда будешь писать мне исправления кода, обновления, вносить мелкие правки, всегда отдавай готовый код полностью,
– В ответ скажи мне что все понял и жди от меня дальнейших команд,
– Настройки в параметрах всегда прописывай на моем языке понятными словами
– Сам код:
// ======================== 📈 MACD Crossover Bot v1.2 (RU UI, HUD: статистика) ========================
export const meta = {
  name: "MACD Crossover v1.2",
  defaultParams: {
    // ===== MACD =====
    fastEMA: 12,
    slowEMA: 26,
    signalEMA: 9,

    // ===== КУЛДАУН (1 фильтр) =====
    useCooldown: true,
    cooldownBars: 2,                       // минимум баров между сигналами
    cooldownMode: "По любому направлению", // По любому направлению | Только по стороне сигнала | Выкл.

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

    // ===== HUD (карточка статистики) =====
    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,          // подсветка карточки, если WR ниже

    // ===== СТАТИСТИКА / ДОГОНЫ =====
    useWinRate: true,
    useWrBreakdownPct: true,
    expiryBars: 1,            // длительность сделки в барах
    dogonBars: 0,             // 0 = как expiryBars
    dogons: 0,                // 0..7
    tieMode: "Продолжить догоны",  // Возврат (=): Продолжить догоны | Остановить догоны | Считать как минус
    statsLookbackBars: 300    // окно статистики по барам
  },
  paramMeta: {
    // ===== MACD =====
    fastEMA:   { label: "MACD: быстрая EMA", type: "number", min: 2,  max: 200, step: 1 },
    slowEMA:   { label: "MACD: медленная EMA", type: "number", min: 2,  max: 400, step: 1 },
    signalEMA: { label: "MACD: EMA сигнала", type: "number", min: 1,  max: 200, step: 1 },

    // ===== КУЛДАУН =====
    useCooldown:  { label: "⏱ Кулдаун: включить", type: "boolean" },
    cooldownBars: { label: "Кулдаун: окно (баров)", type: "number", min: 0, max: 20, step: 1 },
    cooldownMode: {
      label: "Кулдаун: режим",
      type: "select",
      options: [
        { label: "По любому направлению",      value: "По любому направлению" },
        { label: "Только по стороне сигнала",  value: "Только по стороне сигнала" },
        { label: "Выкл.",                      value: "Выкл." }
      ]
    },

    // ===== ВИД =====
    buyText:      { label: "🎨 Подпись BUY",  type: "text",  maxLength: 12 },
    sellText:     { label: "🎨 Подпись SELL", type: "text",  maxLength: 12 },
    buyColor:     { label: "🎨 Цвет BUY",     type: "color" },
    sellColor:    { label: "🎨 Цвет SELL",    type: "color" },
    buyTextColor: { label: "🎨 Цвет текста BUY",  type: "color" },
    sellTextColor:{ label: "🎨 Цвет текста SELL", type: "color" },

    // ===== HUD =====
    showHudCard: { label: "🪪 HUD: карточка — включить", type: "boolean" },
    hudAnchor:   {
      label: "Позиция карточки",
      type: "select",
      options: [
        { label: "Верх-право (TR)", value: "TR" },
        { label: "Верх-лево (TL)",  value: "TL" },
        { label: "Низ-право (BR)",  value: "BR" },
        { label: "Низ-лево (BL)",   value: "BL" }
      ]
    },
    hudTop:      { label: "Отступ top (px)",    type: "number", min: 0, max: 300, step: 1 },
    hudRight:    { label: "Отступ right (px)",  type: "number", min: 0, max: 300, step: 1 },
    hudBottom:   { label: "Отступ bottom (px)", type: "number", min: 0, max: 300, step: 1 },
    hudLeft:     { label: "Отступ left (px)",   type: "number", min: 0, max: 300, step: 1 },
    hudFontPx:   { label: "Шрифт (px)",         type: "number", min: 8, max: 20, step: 1 },
    hudMaxWidth: { label: "Макс. ширина (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: "Межстрочный",        type: "number", min: 0, max: 20, step: 1 },
    hudRadius:   { label: "Радиус",             type: "number", min: 0, max: 24, step: 1 },
    hudBg:       { label: "Фон карточки",       type: "color" },
    hudBorder:   { label: "Рамка",              type: "color" },
    hudNeutral:  { label: "Цвет нейтр.",        type: "color" },

    // ===== СТАТИСТИКА / ДОГОНЫ =====
    useWinRate:        { label: "📊 Показать WR %",        type: "boolean" },
    useWrBreakdownPct: { label: "📊 Проценты по фазам",     type: "boolean" },
    expiryBars:        { label: "📊 Экспирация N (баров)",  type: "number", min: 1, max: 50, step: 1 },
    dogonBars:         { label: "📊 Догон, баров (0 = как N)", type: "number", min: 0, max: 50, step: 1 },
    dogons:            { label: "📊 Кол-во догонов (0..7)",     type: "number", min: 0, max: 7, step: 1 },
    tieMode: {
      label: "📊 Возврат (=): поведение",
      type: "select",
      options: [
        { label: "Продолжить догоны", value: "Продолжить догоны" },
        { label: "Остановить догоны", value: "Остановить догоны" },
        { label: "Считать как минус", value: "Считать как минус" }
      ]
    },
    statsLookbackBars: { label: "📊 Окно статистики (баров)", type: "number", min: 20, max: 5000, step: 10 }
  }
};

// =================== HUD Primitive (показываем только статистику) ===================
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;
  }

  // Приведение русских значений к каноническим
  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);

    // ===== сигналы: пересечения MACD/Signal =====
    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;
      }
    }

    // ===== исходы + статистика с догонами =====
    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); // индексы 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; // вход со следующей свечи
        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;

    // ===== маркеры (над сигналами — цифра попытки, на которой победили) =====
    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}`; // ✅ цифра — с какого догона выиграли

      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 статистика =====
    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 };
}

Пример отправленного промпта в чат:

2. Пишем логику нового бота

Теперь, когда чат обучен одним сообщением, переходите к описанию логики вашей стратегии, чтобы получить готовый инструмент.

Пишите условия максимально конкретно: что смотрим, где смотрим, когда выдаём сигнал. Размытые формулировки заставляют модель додумывать и часто ведут к ошибкам.
РазделТекст
Пример удачной формулировкиЛиния MACD пересекает сигнальную линию сверху вниз, RSI < 40SELL.
Линия MACD пересекает сигнальную линию снизу вверх, RSI > 60BUY.
Пример неудачной формулировки«Линия макди пересекается выше 60 рси, тогда продаем и покупаем когда наоборот макди внизу и рси выше 60».
Важно: это лишь простой пример для понимания подхода. Стратегии могут быть сложнее и включать больше условий и фильтров — всё зависит от вашей идеи и того, насколько чётко вы её опишете.

3. Тестируем полученный код

Перейдите в меню инструментов и нажмите Создать бота.

Откроется окно создания, выбора категории и прав доступа к коду:

  1. Вставьте код стратегии из ChatGPT.
  2. Выберите тип инструмент Бот, Индикатор или Другое
  3. Нажмите Проверить — сервис выполнит быструю проверку синтаксиса.
  4. Нажмите Создать бота — инструмент появится на графике с метками BUY/SELL.

Частые вопросы

Перед реальной торговлей протестируйте бота на демо и соберите статистику по win rate. Не повышайте риск до появления стабильных результатов.